Skip to content
Agentic Control Plane

How to block rm -rf in Claude Code

David Crowe David Crowe · · 9 min read
claude-code permissions hooks defense-in-depth
Share X HN LinkedIn

The built-in way is a deny rule in .claude/settings.json:

{
  "permissions": {
    "deny": ["Bash(rm -rf *)", "Bash(rm -r *)", "Bash(rm -fr *)"]
  }
}

That refuses the usual spellings of a recursive rm, including rm -rf node_modules, which you probably want to allow. It misses rm -Rf, /bin/rm -rf and bash -c 'rm -rf ...'. The hook below is narrower: it blocks a recursive delete only when the target is outside the project.

Where the deny rule goes

Rules go in permissions.deny in .claude/settings.json for the repo, or ~/.claude/settings.json for every project. Deny is checked before ask and allow (permissions docs).

What the docs say a Bash rule does for you here:

  • A deny rule matches past a leading variable assignment, so FOO=bar rm -rf tmp/ is still caught.
  • Wrappers such as timeout, nice and nohup are stripped before matching.
  • Compound commands are split, so cd build && rm -rf ../data is checked part by part.

And what they say it does not do. A Bash(rm *) rule stops rm -rf build/. It does not stop /bin/rm -rf build/ or bash -c 'rm -rf build/'. The docs call a deny rule cover for “the invocation Claude usually produces”, not a boundary around the program.

To be asked instead of refused, move the rules to permissions.ask.

A hook that checks the path

This hook lets rm -rf node_modules and rm -rf ./dist run and blocks a recursive delete whose target starts with /, ~, .. or a variable. Save it as ~/.claude/hooks/no-rm-outside.mjs:

#!/usr/bin/env node
let raw = "";
process.stdin.on("data", (d) => (raw += d));
process.stdin.on("end", () => {
  let cmd = "";
  try { cmd = String(JSON.parse(raw).tool_input?.command ?? ""); } catch {}
  for (const m of cmd.matchAll(/(?:^|[;&|(]\s*|\bsudo\s+)rm\s+([^;&|\n]*)/g)) {
    const words = m[1].trim().split(/\s+/);
    const recursive = words.some((w) => /^-[a-zA-Z]*[rR]/.test(w) || w === "--recursive");
    const targets = words.filter((w) => !w.startsWith("-")).map((w) => w.replace(/^["']/, ""));
    const outside = targets.some((t) => /^(\/|~|\$|\.\.)/.test(t) || t.includes("/../"));
    if (recursive && outside) {
      console.error("Blocked: recursive delete outside the project. Stop and ask the human.");
      process.exit(2);
    }
  }
});

Register it in ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$HOME/.claude/hooks/no-rm-outside.mjs\"", "onFailure": "block" }
        ]
      }
    ]
  }
}

The script exits with code 2 to block, and the model reads the stderr line. onFailure: "block" makes a crashed or timed-out hook block as well, and needs Claude Code 2.1.295 or later (hooks docs).

Deletes that get past the rule and the hook

Fourteen commands through the script:

BLOCKED  rm -rf ~/projects/old
BLOCKED  rm -rf /var/www/html
BLOCKED  cd build && rm -rf ../data
BLOCKED  rm -rf $BUILD_DIR
BLOCKED  sudo rm -r /opt/app
ran      rm -rf node_modules
ran      rm -rf ./dist
ran      rm notes.txt
ran      find ~/projects -name '*.log' -delete
ran      python3 -c "import shutil; shutil.rmtree('/srv/data')"
ran      bash -c 'rm -rf ~/projects/old'
ran      /bin/rm -rf ~/projects/old
ran      git clean -fdx
ran      rm -rf src

Five of the ran lines are deletes you would have wanted to see first. find -delete and shutil.rmtree never use rm. bash -c and /bin/rm hide it from a pattern that looks for rm at the start of a command. git clean -fdx deletes every untracked file with no rm in sight.

The last line is a different problem. rm -rf src is inside the project, so the hook lets it through. If the work in src is not committed, it is gone. No path check fixes that. Commit before you start a run. If a delete outside the project has to be impossible, use the sandbox.

What ACP does with a delete

ACP’s hook treats a recursive delete at two levels:

  • Always blocked: a recursive delete of /, your home directory, or a system directory such as /etc or /usr. No approval reaches these.
  • Held for your OK: a recursive delete of any other literal path outside the working directory, except temp directories such as /tmp. You get a prompt in the session and can say yes.

Its check also matches /bin/rm and reads the command inside bash -c '...', the two forms the hook above missed. It does not catch find -delete or a Python shutil.rmtree either. A delete inside the project and a variable path such as rm -rf $BUILD_DIR are recorded in the log, not held. An rm rule of your own holds those.

Frequently asked questions

How do I block rm -rf in Claude Code?

Add Bash(rm -rf *) and Bash(rm -r *) to permissions.deny in .claude/settings.json. That refuses the usual recursive rm spellings, including rm -rf node_modules. To block only deletes outside the project, use a PreToolUse hook that checks the target path and exits with code 2.

Does a Bash(rm *) deny rule stop every delete?

No. Claude Code’s docs list /bin/rm -rf build/ and bash -c ‘rm -rf build/’ as forms a Bash(rm *) rule does not match. find with -delete, git clean and a Python shutil.rmtree call do not match either.

Can I make Claude Code ask before rm instead of blocking it?

Yes. Put Bash(rm *) in permissions.ask. Claude Code then prompts on every rm, and a matching ask rule still prompts when a hook returned allow.

Does the Claude Code sandbox stop rm -rf outside the project?

The sandbox is the OS-level control for this, and Claude Code’s docs point to it for restrictions that have to hold against any process. A rule or a hook only sees the command text. Read the sandboxing docs for what it restricts on your platform.

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog