Tutorial1 day ago

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

Photo by Adrian Frentescu on Pexels

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 codeMeaningEffect on the tool call
0SuccessProceeds, unless your JSON says deny
2Blocking errorBlocked, regardless of stdout
1, 126, 127, anything elseNon-blocking errorProceeds 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:

CommandDeletes a tree?Hook verdict
rm -rf ./distYesDenied
cd dist && rm -r .YesDenied
git clean -fdxYesAllowed
find . -name '*.ts' -deleteYesAllowed
npx rimraf ./distYesAllowed
python3 -c "import shutil;shutil.rmtree('dist')"YesAllowed

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:

ScenarioOriginalFail-closed version
git clean -fdxAllowedDenied
find . -name x -deleteAllowedDenied
npx rimraf ./distAllowedDenied
shutil.rmtree('d')AllowedDenied
npm run buildAllowedAllowed
git statusAllowedAllowed
Malformed stdinexit 0, proceedsexit 2, blocked
jq missingexit 0, proceedsexit 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 matcher is 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|Write is a list. ^Notebook is 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.

Share

Our hook to block rm -rf caught 2 of 6 ways to delete a directory. Then we broke the hook itself and it exited 0 and let the delete through anyway. #ClaudeCode #DevTools #Automation

Never miss a ship

The best stuff that shipped this week, delivered every Thursday. Free, no spam. We read all the boring stuff so you get the fun parts.

Keep reading