Block rm -rf with a Claude Code PreToolUse hook, and know its limits
Two ways to deny a dangerous rm before it runs: exit code 2 and permissionDecision. Why a hook deny works even in bypassPermissions, and where string matching stops being enough.
Contents
Some commands you never want running unreviewed, and rm -rf is the classic one. A PreToolUse hook lets a script inspect the call and refuse it before the tool runs.
What makes this hook worth using is its position in the chain: the documentation states that PreToolUse hooks fire before any permission-mode check, in every permission mode including dontAsk, and that a hook returning permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions.
KEY POINT
What you will learn
- Two ways to block: exit code 2 and
permissionDecision - Why changing permission mode doesn't get around it, and what hooks still can't do
- Where string matching breaks down, and what to pair it with
Blocking with exit code 2
The shortest form. Write a message to stderr and exit 2: the tool call is denied and that stderr is fed back to Claude.
#!/usr/bin/env bash
# .claude/hooks/block-rm-rf.sh
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eq '(^|[;&|[:space:]])rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)'; then
echo "Blocked: recursive force-remove is not allowed here. Name the paths individually." >&2
exit 2
fi
exit 0
On macOS and Linux the script has to be executable:
chmod +x .claude/hooks/block-rm-rf.sh
Register it in .claude/settings.json. As in the documentation's example, you can run a logging hook alongside the guardrail:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r .tool_input.command >> ~/.claude/bash.log"
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh"
}
]
}
]
}
}
The documentation walks through what happens: both hooks execute in parallel, the logging hook exits 0 and reports no decision, and the guardrail exits 2 to deny the call. The deny takes precedence, so the command is blocked — and the log entry is still written, because the logging hook already ran.
Blocking with permissionDecision
Instead of an exit code, print JSON to stdout. This lets you pass a structured reason.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Recursive deletes are blocked here. Run git clean -n first, then remove paths individually."
}
}
PreToolUse accepts three values:
| Value | Effect |
|---|---|
allow | Skip the interactive permission prompt. Deny and ask rules still apply |
deny | Cancel the tool call and send the reason to Claude |
ask | Show the permission prompt as normal |
With deny, permissionDecisionReason is fed back to Claude, so writing why and what to do instead keeps the work moving rather than just stopping it.
allow cannot override a deny rule
The reverse does not hold. A hook returning allow does not bypass deny rules from settings, and it cannot suppress prompts for MCP tools marked requiresUserInteraction or for connector tools your organization set to ask. As the documentation puts it: hooks can tighten restrictions but not loosen them past what permission rules allow.
Cover file edits too
rm arrives through the Bash tool, but an Edit or Write that clobbers an important file is the same class of accident. The documentation shows a path-based guard:
#!/bin/bash
# protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0
Register that one with a matcher of Edit|Write. For the shape of the JSON these scripts read, see The JSON your Claude Code hooks receive on stdin; for matcher values, see Every value you can put in a Claude Code hook matcher.
Where string matching breaks
This is the part to be honest about. Matching the command text is easy to slip past.
| Written as | Matched by a naive rm -rf check |
|---|---|
rm -rf build | Yes |
rm -r -f build | No |
rm --recursive --force build | No |
FLAGS="-rf"; rm $FLAGS build | No |
find . -delete | Not an rm at all |
The script above handles -rf and -fr orderings, and that is still not exhaustive. A hook is an effective guardrail against mistakes; it is not a mechanism for stopping deliberate evasion.
For anything you need genuinely enforced, layer these underneath:
- Permission deny rules: the documentation states that hook decisions don't bypass permission rules — Claude Code evaluates deny and ask rules regardless of what a
PreToolUsehook returns. See Claude Code permissions in settings.json - The sandbox: OS-level restriction that also covers files a script opens indirectly
Worth knowing too: rm and rmdir targeting a critical path are never auto-approved in any mode, and no allow rule or PreToolUse hook allow approves them.
用語解説
PreToolUse: the event that fires immediately before a tool runs. Denying here means the tool call never happens.
Checking it works
After registering, run /hooks and confirm the hook appears under the right event. To test the script by itself, pipe sample JSON into it:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}}' | ./.claude/hooks/block-rm-rf.sh; echo "exit=$?"
exit=2 is what you want to see.
Summary
PreToolUsehooks fire before permission-mode checks, so adenyholds even inbypassPermissions- Two ways to block:
exit 2for brevity,permissionDecisionJSON to pass a reason - Multiple hooks on one event run in parallel, and a deny wins
- A hook's
allowcannot override a deny rule — hooks tighten, they don't loosen - String matching misses
rm -r -fand variable expansion; use deny rules and the sandbox for real enforcement
FAQ
- Does a PreToolUse hook still apply in bypassPermissions?
- Yes. The documentation states that PreToolUse hooks fire before any permission-mode check, in every mode, and that a hook returning permissionDecision deny blocks the tool even in bypassPermissions or with --dangerously-skip-permissions.
- Can a hook returning allow override a deny rule?
- No. The documentation puts it as: hooks can tighten restrictions but not loosen them past what permission rules allow.
- Should I use exit code 2 or permissionDecision?
- Use the permissionDecision JSON when you want to tell Claude why, and exit code 2 when you want the shortest possible script. Both block the call.
- Is matching on the string rm -rf enough?
- No. It misses rm -r -f, long options, and variable expansion. Treat hooks as a guardrail against mistakes and rely on deny rules and the sandbox for real enforcement.
Questions & answers
Q. My PreToolUse hook returns permissionDecision allow, but a compound Bash command still shows a confirmation prompt. Are the cases where allow doesn't take effect documented anywhere?
They are. The docs define allow with the exceptions attached: it "skips the permission prompt, except for actions no mode auto-approves and reads from network paths". Here is the full list.
| Exception | What the docs say |
|---|---|
| Deny and ask rules | "Deny and ask rules are still evaluated regardless of what the hook returns" |
| Reads from network paths | Named in the same sentence |
MCP tools marked requiresUserInteraction | "a hook can't skip its approval prompt with \"allow\", with or without updatedInput" |
Agent SDK tools marked always_ask | Unless the permission mode approves the call |
Connector tools an org set to ask | Same |
AskUserQuestion and ExitPlanMode | "Returning \"allow\" alone is not sufficient for these tools" |
Critical-path rm / rmdir | "no allow rule or PreToolUse hook \"allow\" approves" |
Commands flagged Dangerous rm operation not ruled out | See below |
Of these, the last two are the ones a compound command can hit. The last one is the easier to diagnose, because it has a fixed reason string:
Dangerous rm operation not ruled out — the shell may read this command differently than it was parsed
In auto or bypassPermissions mode, the denial reads:
This command could not be checked for dangerous rm operations: the shell may read it differently than it was parsed.
The docs are explicit about what cannot clear it: "You can't approve the command with a Bash or Bash(*) allow rule, sandbox auto-allow mode, or a PreToolUse hook returning \"allow\"."
No guardrail keyed to compound commands as such could be found
Nothing in the official documentation names a guardrail that fires because a command chains delete verbs with && or ;. The two documented ways a compound command lands here are the parse-failure check above, and a target recognized as a critical path. The critical-path list is: the filesystem root; top-level directories; your home directory; Windows drive roots and their top-level directories; your working directory and its parents; and your additional working directories and their parents, but only for a glob such as rm -rf <dir>/*. A subdirectory inside your working directory is not on that list.
Nested forms and inline scripts are inspected too: "a subshell with (...), a brace group with { ...; }, command substitution with $(...) or backticks, or process substitution with <(...)", and "a script passed to sh, bash, zsh, or a similar POSIX shell with -c".
One more thing if you pair allow with updatedInput. Returning updatedInput with no permissionDecision in auto mode can get the changed call denied outright. Even with a decision attached, the docs name two cases where the denial still appears: "when any hook changed an earlier call in the same Claude response or a Bash command asks to run outside the sandbox". Both are worth ruling out.
For what hooks can and cannot do, see Block rm -rf with a Claude Code PreToolUse hook, and know its limits.
Primary sources
This article was drafted by AI from official documentation and reviewed by the site operator before publishing. Found a mistake? Let us know via the contact page.