How to block rm -rf in Claude Code
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,niceandnohupare stripped before matching. - Compound commands are split, so
cd build && rm -rf ../datais 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/etcor/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.
Related
- How to block rm -rf in Claude Code, Codex and Cursor
- How to block rm -rf in Cursor with a beforeShellExecution hook
- How to stop Codex from deleting your files
- Claude Code hooks reference