Blocking rm -rf in Claude Code: A Deny Rule Stopped 14 Deletes in Two Weeks, 7 Other Spellings Still Get Through, and a Hook That Closes the Gap
The problem
Mid-task, a Claude Code command gets blocked and the model receives this:
Permission to use Bash with command cd /tmp && rm -rf smc2 && mkdir smc2 && cd smc2 && unzip -q ... has been denied.
No reason, no source. If you, like me, once wrote this into ~/.claude/settings.json, you put it there yourself:
{
"permissions": {
"allow": ["Bash"],
"deny": ["Bash(rm -rf *)"]
}
}
The intent is simple. Let Claude Code run whatever it needs, but don't let a stray rm -rf wipe something out. This post answers three questions. What does the rule actually block? What does it miss? How do you close the gap without leaving the model stuck?
The short version
- The deny rule only recognizes a literal
rm -rf.rm -fr,rm -r -f,rm -Rfandrm -rdelete the directory in all three permission modes. - The model plays by the rules. In two weeks it was denied 14 times and never once tried a different spelling. The cost is a pile of
xxx2directories and stale output nobody removed. - There is a second gate you never configured. Claude Code has a built-in dangerous-rm check for targets like
$D/*that would hit the root if the variable were empty. It fires even in bypass mode, and in an interactive session it waits for a person to approve. - To close the gap, use a PreToolUse hook. It runs before the other rules in all three modes, and it can tell the model why the delete was refused and what to do instead.
Two weeks of real sessions: 14 blocked, 0 workarounds
I went through my Claude Code session logs from October 7 to 21, across 5 projects. I pulled every denied tool call that contained rm and looked at what the model did next.
| Denied command (excerpt) | What the model did next |
|---|---|
rm -rf $S && git checkout-index -a --prefix=$S/ |
Started over in snap2/ |
rm -rf .venv && uv venv -p 3.11 .venv |
Created .venv311 |
cd /tmp && rm -rf smc2 && mkdir smc2 && unzip … |
Unzipped into a new directory under the project's tmp |
rm -rf wtt-film && … pipeline.py init wtt-film |
Switched to wtt-film2 |
rm -rf $D; mariadb-install-db --datadir=$D |
Used tmp/mariadb instead |
rm -rf tmp/lt/patchtest && mkdir -p tmp/lt/patchtest … |
Added a timestamp to the directory name |
rm -rf /tmp/pc && mkdir -p /tmp/pc … |
Switched to /tmp/pc2 |
cd /tmp && rm -rf hyperframes && git clone … |
Cloned somewhere else |
rm -rf node_modules package-lock.json; npm i … |
Skipped the delete and checked the current state |
rm -rf dial.mp4.frames sheet.png … |
Skipped the delete and moved on |
The full count:
| Model reaction | Count |
|---|---|
| Retried under a new directory name or location | 8 |
| Dropped the delete and ran only the rest | 6 |
Switched to rm -fr / find -delete / /bin/rm to get around it |
0 |
I also searched every Bash call in those sessions for rm -fr, find -delete, /bin/rm -r, shutil.rmtree and git clean -f. There were none.
So the owner's intent was respected. It came at two costs:
- The whole compound command is dropped. Most denied commands chained "delete old dir, rebuild, run tests".
rmwas one piece, but the build and tests didn't run either, and the model spent an extra turn splitting it up. - Junk directories pile up.
snapandsnap2,.venvand.venv311,wtt-filmandwtt-film2sit side by side. The denial gives no guidance, so the model improvises, and the cheapest improvisation is a new name.
The experiment: 22 delete commands, replayed verbatim
Real sessions only show that the model didn't try to get around the rule. They can't show whether the rule could be gotten around. So I built an isolated lab under /tmp:
- A local stub plays the model. It sends a chosen Bash command to Claude Code as a
tool_use, word for word, then records thetool_resulttext Claude Code returns to the model. Client behavior is real. Model replies are simulated. - A fresh environment every run. An empty
CLAUDE_CONFIG_DIR, a fake API key, all outbound connections blocked. Each run deletes inside a newly created sandboxs/holdinga/x.txt,a/sub/y.txt,b.txt,t_1.jpgandt_2.jpg. - The disk is the judge. After each run I listed what was left in the sandbox, regardless of what the model was told.
- Three permission modes. Same settings (allow
Bash+ denyBash(rm -rf *)), run in manual mode (--permission-mode default), auto mode, and bypass mode (--dangerously-skip-permissions).
One surprise first. On 2.1.285, a sonnet 5.5 session with no mode specified starts in auto mode. Most of my real sessions ran in auto too.
Results: deny rule Bash(rm -rf *)
| Command | Manual | Auto | Bypass |
|---|---|---|---|
rm -rf s/a |
blocked | blocked | blocked |
rm -fr s/a |
deleted | deleted | deleted |
rm -r -f s/a |
deleted | deleted | deleted |
rm -Rf s/a |
deleted | deleted | deleted |
rm -r s/a |
deleted | deleted | deleted |
/bin/rm -rf s/a |
deleted | sent to classifier | deleted |
find s/a -delete |
deleted | sent to classifier | deleted |
bash -c 'rm -rf s/a' |
deleted | sent to classifier | deleted |
cd s && rm -rf a && echo after |
blocked (whole command) | blocked (whole command) | blocked (whole command) |
echo before; rm -rf s/a; echo after |
blocked (whole command) | blocked (whole command) | blocked (whole command) |
D=s/a; rm -rf $D |
blocked | blocked | blocked |
rm -rf "$(pwd)/a" |
blocked | blocked | blocked |
trash s/a |
ran | sent to classifier | ran |
mv s/a s/.trash-a |
ran | ran | ran |
The lab has no real classifier, so the "sent to classifier" cells came back as temporarily unavailable, so auto mode cannot determine the safety of Bash. How a real classifier would rule on them was not tested.
What this shows:
- The rule matches command text. It splits
&∧to find anrm -rfinside, and it sees past a leadingD=…assignment. Reorder the flags and it no longer matches. The official permissions docs say it plainly:Bash(rm *)stopsrm -rf build/but not/bin/rm -rf build/orbash -c 'rm -rf build/', and a deny rule "isn't a security boundary". - A denial drops the whole command. In
echo before; rm -rf …; echo after, evenecho beforedidn't run. - Auto mode discards broad allow rules. The debug log says
Ignoring dangerous permission Bash(*) from ../settings.json (bypasses classifier). That is why/bin/rmandfind -deletewere sent to the classifier in auto mode instead of being allowed outright. - In auto mode,
rm -frinside the working directory skips the classifier. The debug log saysSkipping auto mode classifier for Bash: would be allowed in acceptEdits mode. Deleting something in the working directory is treated like editing a file.
The second gate: the built-in dangerous-rm check
Besides your own deny rules, Claude Code has a built-in delete check. It fired once in my real sessions. The command edited a file through a heredoc, then emptied a temp directory and re-rendered:
python3 - <<'EOF'
...edit film.js...
EOF
D=tmp/jj; rm -f $D/*; node core/render/still.mjs ... --out $D ...
What the model received (excerpt):
Permission for this command was denied by a built-in Claude Code safety check, not by the user.
... Only a person may approve such a removal, and no person did (the permission prompt timed out,
or this session cannot prompt). The command was NOT run; do not claim it succeeded. Do not work
around the check by splitting, scripting, or re-issuing the removal through another tool or shell
... What was flagged: Dangerous rm operation detected in `rm -f $D/*`. The target '$D/*' is a shell
variable expansion: when $D is unset or empty it becomes `/`, `/*` or a top-level path.
This check does not fire on a target that cannot expand to the filesystem root: rewrite it as
`"${D:?}"/*`, which makes the shell stop with an error instead of running rm when $D is unset or
empty, or use a literal absolute path.
That was an interactive auto-mode session. The tool call went out, nobody approved it for 120 seconds, and only then did this denial come back. The heredoc edit at the front of the command didn't run either.
I reproduced it in the lab and found where the line is:
| Command | Manual | Auto | Bypass |
|---|---|---|---|
D=s/a; rm -f $D/* (single line) |
ran | sent to classifier | ran |
A heredoc first, then D=a; rm -f $D/* |
blocked | blocked | blocked |
D=s; rm -f $D/* (s is the working directory) |
blocked | blocked | blocked |
D=s; rm -f "${D:?}"/* |
ran | sent to classifier | ran |
D=s; rm -f $D/t_*.jpg |
ran | sent to classifier | ran |
- In a single-line command, it follows the earlier assignment. Put a heredoc in front and it loses track, so it assumes the variable may be empty.
- When the target is the working directory itself, the wording changes. It reads
This command would remove a workspace directory (the working directory, an additional working directory, or one of their parent directories). This requires explicit approval and cannot be auto-allowed by permission rules. - Bypass mode doesn't skip it.
--dangerously-skip-permissionsstill gets blocked. - Rewriting to
"${D:?}"/*as suggested gets through. If the variable is empty, the shell exits with an error beforermruns. A literal prefix before the glob ($D/t_*.jpg) doesn't trigger it either.
This check has been tightening across recent releases. Per the changelog, 2.1.277 started naming the flagged command and suggesting ${VAR:?}. 2.1.281 added "a variable followed by a top-level directory name" and "a variable derived from the working directory". 2.1.304 added the top level of mount points like /Volumes and /media. I tested 2.1.285, so newer versions may be stricter.
Closing the gap: a PreToolUse hook for every recursive delete
A deny rule can only express a text prefix. A hook can run any logic. This script recognizes the common recursive-delete spellings and, when it refuses, gives the model a reason and a way forward:
#!/usr/bin/env python3
# ~/.claude/hooks/no-recursive-rm.py
# PreToolUse hook: block recursive deletes in any spelling, and tell the model what to do instead
import json, re, sys
cmd = json.load(sys.stdin).get("tool_input", {}).get("command", "")
patterns = [
# rm -rf / -fr / -r -f / -R / /bin/rm -rf ...
r"(^|[\s;&|(`'\"])(\S*/)?rm\s+(-[a-zA-Z]*[rR][a-zA-Z]*\s+|-[a-zA-Z]+\s+)*-[a-zA-Z]*[rR]",
r"(^|[\s;&|(`'\"])(\S*/)?rm\s+--recursive",
r"\bfind\b.*\s-delete\b",
r"\bgit\b.*\bclean\b.*-[a-zA-Z]*[fdx]",
]
if any(re.search(p, cmd) for p in patterns):
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
"The owner of this machine does not allow Claude to delete recursively (rm -r / find -delete / git clean). Do not retry with a different spelling. "
"If you need a clean directory, use a new name (e.g. out_2). If old files really must go, move them to the Trash with trash <path>, "
"or list the paths in your reply and let the owner delete them.")}}, ensure_ascii=False))
Register it in your global settings and keep the original deny rule as a second layer:
{
"permissions": {
"deny": ["Bash(rm -rf *)"]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/no-recursive-rm.py" }]
}
]
}
}
Remember chmod +x. I reran the 19 commands from the deny-rule round:
| Command | Manual | Auto | Bypass |
|---|---|---|---|
rm -rf / rm -fr / rm -r -f / rm -Rf / rm -r |
blocked | blocked | blocked |
/bin/rm -rf / find -delete / bash -c 'rm -rf …' |
blocked | blocked | blocked |
git clean -fdx |
blocked | not tested | blocked |
rm -rf inside compound commands, variables, $(pwd) |
blocked | not tested | blocked |
Non-recursive deletes such as rm -f s/b.txt |
ran | not tested | ran |
trash s/a, mv s/a … |
ran | not tested | ran |
In auto mode I only spot-checked rm -fr, /bin/rm -rf and bash -c. All three were refused at the hook and never reached the classifier.
I also ran the script against 17 everyday commands. docker run --rm, docker rm -f, git rm --cached, grep -rn "rm", rm -f a.txt and git clean -n all pass. sudo rm -fr, xargs rm -r and find -exec rm -rf are all blocked. Two kinds of false positive showed up: git rm -r --cached dist, and the string in echo "do not rm -rf". The first one comes up now and then. When it's blocked, have the model run git rm --cached file by file, or hand it to you.
This time the model receives:
PreToolUse:Bash hook error: The owner of this machine does not allow Claude to delete recursively (rm -r / find -delete / git clean). Do not retry with a different spelling. If you need a clean directory, use a new name (e.g. out_2). If old files really must go, move them to the Trash with trash <path>, or list the paths in your reply and let the owner delete them.
The prefix says hook error, but this is a normal denial, not a broken hook. Compared with the bare has been denied, the model now knows this is the owner's rule and has three ways forward.
The lab logs also show that for every denied call, the PreToolUse hook ran first and PostToolUse never ran. The real denials in my hook logs follow the same order. So a hook sees every refused command, which makes it a good place to keep a record too.
It is still not a security boundary
Both the hook and the deny rule read command text. python3 -c "import shutil; shutil.rmtree('x')", perl -e, or a script written and then executed all slip past. They guard against the model's reflexive rm -rf, not a deliberately constructed delete.
The real safety net is still:
- Keep important directories in git. Commit or stash uncommitted work as you go.
- Have backups. Time Machine or any scheduled backup.
- Use the sandbox when you need hard isolation. For filesystem and network enforcement, the official docs point to sandboxing, which doesn't depend on command text.
Pitfalls I hit along the way
- With no mode specified, 2.1.285 runs in auto. My first round was supposed to test manual mode, but the logs were full of auto-mode messages. I reran with an explicit
--permission-mode default. - The stub couldn't find the
tool_resultand looped forever. In newer requests the message list can end with arole: systementry, so thetool_resultisn't in the last message. Scan all messages. trashignores the lab isolation. It follows macOS rules and moves files to the current user's Trash, regardless ofCLAUDE_CONFIG_DIR. The terminal has no permission to read the Trash, so I couldn't confirm where those sandbox directories ended up. I only confirmed they left the sandbox.
Not verified: how a real auto-mode classifier rules on /bin/rm -rf, find -delete, bash -c and trash, because no classifier was attached; the built-in check's wait time in interactive mode, which rests on one observation from a real session (120 seconds); the hook in auto mode beyond 3 spot checks; versions after 2.1.285.
Related reading
- Claude Code "bash denied by auto mode": Why It Blocks, "could not evaluate" and "unavailable for this model" Tested
- Claude Code's Auto Mode Judges a Model With a Model: When the Model Is Down, You Can't Even Run cat
- The Claude Code Hooks Stdin Trap: Python Heredocs Eat Your Hook JSON
- Claude Code Hooks: Custom Automation Workflows
Tested 2026-10-21 on Claude Code 2.1.285, macOS 27.0.1. Permission-rule behavior is based on the official permissions docs and the lab runs; version history on the Claude Code CHANGELOG. Real-session numbers come from my local Claude Code session logs, October 7 to 21.