Magic Tools
Pitfall NotesBy CooconOctober 21, 2026208 views8 min read

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 -Rf and rm -r delete 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 xxx2 directories 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:

  1. The whole compound command is dropped. Most denied commands chained "delete old dir, rebuild, run tests". rm was one piece, but the build and tests didn't run either, and the model spent an extra turn splitting it up.
  2. Junk directories pile up. snap and snap2, .venv and .venv311, wtt-film and wtt-film2 sit 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 the tool_result text 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 sandbox s/ holding a/x.txt, a/sub/y.txt, b.txt, t_1.jpg and t_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 + deny Bash(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 && and ; to find an rm -rf inside, and it sees past a leading D=… assignment. Reorder the flags and it no longer matches. The official permissions docs say it plainly: Bash(rm *) stops rm -rf build/ but not /bin/rm -rf build/ or bash -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, even echo before didn'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/rm and find -delete were sent to the classifier in auto mode instead of being allowed outright.
  • In auto mode, rm -fr inside the working directory skips the classifier. The debug log says Skipping 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-permissions still gets blocked.
  • Rewriting to "${D:?}"/* as suggested gets through. If the variable is empty, the shell exits with an error before rm runs. 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_result and looped forever. In newer requests the message list can end with a role: system entry, so the tool_result isn't in the last message. Scan all messages.
  • trash ignores the lab isolation. It follows macOS rules and moves files to the current user's Trash, regardless of CLAUDE_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.


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.

Get field notes like this every Saturday

Subscribe to Dev Breakfast: daily AI coding picks at 8:00, plus a Saturday roundup of this week's hands-on tests with Claude Code / Codex / local models. Written in Chinese.

Related Articles

Claude Code Auto Mode with DeepSeek, GLM or Kimi: What Each "temporarily unavailable" Reason Means and When Retrying Is Pointless (Fault-Injection Test)

On a third-party model, Claude Code auto mode sends one extra classifier request per side-effecting action, from your session model to the same backend: a 132,470-character system prompt, about 29.7K input tokens per check. When it fails, you see "xxx is temporarily unavailable, so auto mode cannot determine the safety of Bash". We injected faults and recorded each message: 500 (server error), 429 (rate-limited), 529 (overloaded) and connection resets (connection failed) are retried 5 times; no reply in 60 s gives (timed out). **No parentheses means a 4xx rejection: tried once, and retrying never helps.** A reply with no verdict gives "could not evaluate"; three in a row bring back manual approval. CLAUDE_CODE_AUTO_MODE_MODEL has no effect. Claude Code 2.1.285, real DeepSeek v4-pro and MiMo v2.5 backends.

claude-codedeepseek+7
pitfallsOct 11, 202612 min
422

Claude Code "Prompt is too long" vs "maximum context length": Why Auto-Compact Works for One and Not the Other (Tested)

Claude Code decides a request was too long by matching the error text. If the backend says prompt is too long or input is too long for requested model, it auto-compacts the conversation and retries — invisible to you. DeepSeek and OpenAI-style gateways say This model's maximum context length is …, which it doesn't recognize: you get API Error: 400 on every turn. Manual /compact works; the better fix is to declare the real window with CLAUDE_CODE_MAX_CONTEXT_TOKENS (non-claude- model IDs) or CLAUDE_CODE_AUTO_COMPACT_WINDOW (claude- IDs) so it compacts before hitting the limit. Tested on Claude Code 2.1.285 against a local stub.

claude-codedeepseek+6
pitfallsOct 5, 20267 min
437

Claude Code Stuck on a Spinner With No Response: It Waits 6 Minutes per Attempt, and 10 Retries Can Hang It for Over an Hour (Tested)

When Claude Code spins without output or sits at Retrying in 0s, the backend is usually not sending anything. Tested on 2.1.285 (API key + ANTHROPIC_BASE_URL): with no response headers it waits 6 minutes (360 s) per attempt before timing out — setting API_TIMEOUT_MS to 600000 or 900000 doesn't change that; only lower values work. With the default 10 retries it can hang for over an hour. A gateway that buffers the reply until generation finishes will never deliver a reply that takes over 6 minutes. Press Esc to interrupt. All tested against a local stub.

claude-codetroubleshooting+5
pitfallsOct 5, 20269 min
377

Claude Code 429 "Request rejected (429)": How Long It Retries, and Why retry-after Over 60 Seconds Fails Instantly (Tested)

On a 429, Claude Code retries up to 10 times over about 3 minutes. If retry-after is 60 seconds or less it waits the full time (10 s and 60 s both recovered in testing); at 61 seconds or more it does not retry at all and fails immediately with API Error: Request rejected (429). Gateway messages such as new-api's are shown verbatim; an empty body shows status code (no body). In interactive mode every prompt sends 2 requests, so rate-limit usage doubles. All tested against a local stub on Claude Code 2.1.285.

claude-codetroubleshooting+4
pitfallsOct 4, 20268 min
341

Published by Magic Tools