We Wrote a Claude Code Hook to Block rm -rf and Four Deletes Walked Right Past It
A PreToolUse hook that blocks rm -rf took ten minutes to write and caught 2 of 6 ways to delete a directory. Worse, it failed open: broken, it exited 0 and let the delete through.
The WJS Desk
Sep 1, 2026 · updated 4 hours ago · 8 min read

We wrote a Claude Code hook to stop an agent from running rm -rf in our repo. It worked on the first try, which should have been the warning sign. Then we fed it five other ways to delete a directory tree and four of them walked straight through.
This is a tutorial about writing a PreToolUse hook that actually holds, and about the two ways the obvious version fails silently. We measured the overhead too, because a hook fires on every single tool call and nobody tells you what that costs. It is 9.2 ms on our machine, which matters more than it sounds like.
What you will end up with
A shell script at .claude/hooks/guard.sh that Claude Code runs before every Bash command, which blocks recursive deletes and, critically, refuses to let the tool call proceed when the hook itself is broken. That second property is the whole point and it is the one the documentation example does not give you.
Budget about 25 minutes. You need Claude Code installed, jq on your PATH, and a project directory you do not mind experimenting in. We used a throwaway directory rather than a real repo, for reasons that will become obvious in the "what broke" section.
Why we bothered
We run Claude Code against a Next.js monorepo with a Supabase backend. The rule we care about is that every write path runs locally and everything deployed is read-only. Prose in CLAUDE.md stating that rule is a suggestion. A hook is a mechanism.
The gap between the two is not theoretical. Earlier in this project we had a secret-scanning check that passed a build containing a live service role key, because it grepped for the literal string service_role and the key is a base64-encoded JWT. The check was green. The key was in the bundle. A guard that cannot catch the thing it exists to catch is worse than no guard, because you stop looking.
A guard that fails silently does not degrade to "no protection". It degrades to "false confidence", which is strictly worse.
How hooks actually work
Hooks are shell commands Claude Code runs at defined points in its lifecycle. They fire at three cadences: once per session (SessionStart, SessionEnd), once per turn (UserPromptSubmit, Stop), and on every tool call (PreToolUse, PostToolUse, PermissionRequest).
The contract is plain Unix. Your script gets a JSON object on stdin, writes an optional JSON decision to stdout, and signals with its exit code. Here is what a PreToolUse event looks like on the way in:
{
"session_id": "abc123",
"transcript_path": "/Users/you/.claude/projects/.../transcript.jsonl",
"cwd": "/Users/you/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/build",
"description": "Remove build artifacts"
},
"tool_use_id": "toolu_01ABC123"
}
The exit codes are where people get hurt, so read this table twice:
| Exit code | Meaning | Effect on the tool call |
|---|---|---|
0 | Success | Proceeds, unless your JSON says deny |
2 | Blocking error | Blocked, regardless of stdout |
1, 126, 127, anything else | Non-blocking error | Proceeds anyway |
Read that third row again. If your hook script crashes, is missing, or is not executable, the tool call it was supposed to guard runs normally. Hooks fail open by default. Everything below follows from that one fact.
The version from the documentation
Configuration goes in .claude/settings.json. The matcher field filters by tool name, and the if field adds a second filter using permission-rule syntax:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"
}
]
}
]
}
}
And the script itself, which is close to what you will find in most examples:
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if echo "$CMD" | grep -qE 'rm[[:space:]]+-[a-zA-Z]*[rf]'; then
jq -n --arg c "$CMD" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Recursive delete blocked: " + $c)
}
}'
fi
exit 0
chmod +x .claude/hooks/guard.sh
You can test a hook without launching Claude Code at all, which is the single most useful thing we learned. The stdin contract is just JSON, so pipe it in yourself:
echo '{"tool_input":{"command":"rm -rf ./build"}}' | ./.claude/hooks/guard.sh
That printed a well-formed deny decision. Green tick, ship it. That is where we should have kept going and did not.
What broke
Failure 1: four out of five deletes got through
The regex matches the string rm -rf. It does not match "delete a directory tree", which is the thing we actually care about. We wrote out every way we could think of to remove ./dist and ran each one past the hook:
| Command | Deletes a tree? | Hook verdict |
|---|---|---|
rm -rf ./dist | Yes | Denied |
cd dist && rm -r . | Yes | Denied |
git clean -fdx | Yes | Allowed |
find . -name '*.ts' -delete | Yes | Allowed |
npx rimraf ./dist | Yes | Allowed |
python3 -c "import shutil;shutil.rmtree('dist')" | Yes | Allowed |
Two denied, four allowed. And that list took us about ninety seconds to write. It is not an exhaustive set, it is the set we thought of before getting bored.
Be honest about what this is: a regex over a command string is a typo guard, not a security boundary. It catches the accidental rm -rf in a repo root. It does not stop anything that is trying to get past it, because the same effect has unbounded syntactic forms. Treat it as a seatbelt, not a lock.
Failure 2: the hook fails open when it breaks
This is the one that actually changed how we write hooks. We forgot the chmod +x once, and instead of an error we got nothing. So we measured it directly:
chmod -x .claude/hooks/guard.sh
echo '{"tool_input":{"command":"rm -rf x"}}' > /tmp/in.json
./.claude/hooks/guard.sh < /tmp/in.json; echo "exit=$?"
bash: ./.claude/hooks/guard.sh: Permission denied
exit=126
Exit 126 is in the "non-blocking error" row of that table. The delete proceeds. A hook you believe is protecting you, that has been silently inert since the day you created it, is the worst possible state.
It gets better. We simulated jq being absent from the PATH, which is realistic on a fresh machine or in CI:
printf '{"tool_input":{"command":"rm -rf x"}}' | PATH=/tmp/nojq bash ./.claude/hooks/guard.sh; echo "exit=$?"
./.claude/hooks/guard.sh: line 3: jq: command not found
./.claude/hooks/guard.sh: line 5: grep: command not found
exit=0
Exit 0, empty stdout, no decision, delete proceeds. The script reported success while doing literally nothing, because the trailing exit 0 masked every failure above it. Malformed JSON on stdin produced the same result: exit 0, silence, tool call allowed.
The version that holds
Two changes. Fail closed on any internal error, and match on effect rather than on one spelling.
#!/bin/bash
set -uo pipefail
deny() {
jq -n --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",
permissionDecision:"deny", permissionDecisionReason:$r}}'
exit 0
}
command -v jq >/dev/null 2>&1 || { echo "guard: jq not found" >&2; exit 2; }
INPUT=$(cat)
CMD=$(printf '%s' "$INPUT" | jq -re '.tool_input.command // ""') || { echo "guard: unparseable hook input" >&2; exit 2; }
DESTRUCTIVE='rm[[:space:]]+-[a-zA-Z]*[rf]|git[[:space:]]+clean|[[:space:]]-delete\b|rimraf|shutil\.rmtree|find[[:space:]].*-exec[[:space:]]+rm'
if printf '%s' "$CMD" | grep -qE "$DESTRUCTIVE"; then
deny "Recursive delete blocked: $CMD"
fi
exit 0
The important lines are the two exit 2 guards. Exit 2 is the only code that blocks unconditionally, so a broken hook now stops the tool call instead of waving it through. Re-running the full matrix:
| Scenario | Original | Fail-closed version |
|---|---|---|
git clean -fdx | Allowed | Denied |
find . -name x -delete | Allowed | Denied |
npx rimraf ./dist | Allowed | Denied |
shutil.rmtree('d') | Allowed | Denied |
npm run build | Allowed | Allowed |
git status | Allowed | Allowed |
| Malformed stdin | exit 0, proceeds | exit 2, blocked |
jq missing | exit 0, proceeds | exit 2, blocked |
Six of six delete forms caught, both benign commands still allowed, and both broken-hook conditions now block instead of passing.
What it costs you
A PreToolUse hook on Bash runs before every single shell command in your session. We timed 200 sequential invocations on an M-series Mac:
start=$(python3 -c 'import time;print(time.time())')
for i in $(seq 200); do ./.claude/hooks/guard.sh < /tmp/in.json >/dev/null; done
end=$(python3 -c 'import time;print(time.time())')
9.2 ms per invocation. Most of that is process startup for bash and jq, not the matching. In a session with 300 tool calls that is under 3 seconds total, which is nothing next to model latency. But the number scales with how many hooks you register, and it is per hook, per matching call. Four hooks on an unfiltered "*" matcher is roughly 37 ms of pure overhead on every tool call.
Pro tip: Use the matcher field aggressively. "matcher": "Bash" costs nothing on Read, Edit, Grep, or Glob calls, which are usually the majority of a session. The if field narrows further: "if": "Bash(git *)" only fires the hook on git subcommands.
Common mistakes
- Ending with a bare
exit 0. It swallows every failure above it. This is the single most common way a hook becomes decorative. - Forgetting
chmod +x. Exit 126, non-blocking, completely silent. Check it first whenever a hook seems to have stopped working. - Using a relative path in the config. Use
${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh. The working directory when a hook runs is not guaranteed to be what you assume. - Assuming
matcheris a glob. It is an exact string or a pipe-separated list if it only contains letters, digits, underscores, hyphens, spaces, commas, and pipes. Add any other character and it becomes an unanchored JavaScript regex.Edit|Writeis a list.^Notebookis a regex. That switch is easy to trip by accident. - Writing debug output to stdout. Stdout is the decision channel and is parsed as JSON. Send diagnostics to stderr.
- Testing only the happy path. Every failure we found came from testing the hook broken, not the hook working.
What we would not do yet
We would not use hooks as a security control against an adversary. The regex is enumerable and the surface is a full shell language. Ours is aimed at an agent making a plausible mistake, not one trying to get around us, and that is an honest description of the threat we have.
We would also not put a prompt or agent type hook on a per-tool-call event. Those exist and they call a model to make the decision, which is genuinely more robust at understanding intent than any regex. But paying model latency on every Bash call is a different product. We would reach for those on Stop or UserPromptSubmit, which fire once per turn.
The escape hatch
If a hook starts blocking work you need done, you do not have to debug it under pressure. Delete the PreToolUse entry from .claude/settings.json and it stops firing immediately, no restart required. Keeping hooks in .claude/settings.local.json instead of the shared .claude/settings.json means you can experiment without pushing a broken guard to everyone on the team.
The thing worth carrying away is smaller than the hook itself: test your guard by breaking it, not by using it. Ours passed every test we ran until we ran the ones where it was supposed to fail.


