A Claude Code guard hook that cannot run lets the command through. Make it fail closed, then test it by breaking it
A PreToolUse hook that blocks rm -rf, secrets or production commands only blocks while it runs correctly. We broke a working guard five ways on Claude Code 2.1.312 (a typo in the path, exit 1, a hang past the timeout, node missing from a cron PATH, an HTTP guard whose server was down) and the canary command ran every time, with no trace in headless output. Setting onFailure to block turned all five into refusals, but version 2.1.286 ignores that key without a warning, and in bypass mode Claude removed the broken guard from settings.json in three runs out of three until two deny rules stopped it. The settings, a jq audit, a crash test that costs nothing, and the stream-json filter that catches a failed guard in CI.
If you run Claude Code with a PreToolUse hook that refuses dangerous commands, the hook is a guard only while it starts, finishes in time and exits with a code Claude Code reads as a decision. When any of those fails, the default is to carry on with the tool call.
We tested that on 29 October with Claude Code 2.1.312 and Claude Haiku 4.5. The guard was a short script that exits 2 when a Bash command contains the word CANARY, and each run asked Claude to touch a canary file. The session ran in bypassPermissions mode, so the hook was the only thing between the request and the shell. Then we broke the guard in the ways guards break in real setups:
| What went wrong with the guard | What the hook returned | Default | With "onFailure": "block" |
|---|---|---|---|
| None, the guard worked and exited 2 | exit 2 | blocked | blocked |
Path in settings.json misspelled (gaurd.sh) | exit 127, No such file or directory | ran | blocked |
Guard written to deny with exit 1 | exit 1 | ran | blocked |
Guard hung past its timeout of 3 seconds | cancelled | ran | blocked |
#!/usr/bin/env node guard, session started with PATH=/usr/bin:/bin, the PATH cron gives a job | exit 127, env: node: No such file or directory | ran | blocked |
HTTP guard at 127.0.0.1:9 with nothing listening | connect ECONNREFUSED | ran | blocked |
Each row was one claude -p run, at about $0.013 a run, with a prompt that told Claude not to retry or look for another way if the command was refused. The canary file existed afterwards in every row marked "ran". What Claude does when you leave that instruction out is covered further down.
The hooks reference describes the same rule. Exit 2 blocks. Any other exit code, a hook that cannot start, a timeout and output that fails to parse are all "non-blocking errors", and the action goes ahead. Exit 1 is the conventional Unix failure code, so a guard that uses it to refuse something allows it instead. The default timeout for a command or HTTP hook is 600 seconds, so a guard that hangs holds the call for ten minutes and then lets it run.
Which sessions it reaches
A failed guard hands the decision back to the normal permission flow. The danger is any session in which that flow would have allowed the command anyway. We ran the misspelled-path guard twice more in default mode. With --allowedTools "Bash(touch:*)" the command ran. With no allow rule it was denied, because a headless session has nobody to answer the prompt. Bypass mode, allow rules in your settings and --allowedTools in a script are therefore where a broken guard means no guard.
These are the setups where people lean on hooks most: a scheduled claude -p job, a CI step, a container started with --dangerously-skip-permissions on the theory that the hook will catch the one command that matters.
The headless runs also gave no sign of the failure. In --output-format stream-json --verbose the five fail-open runs printed (Bash completed with no output) as the tool result and nothing else. The word "hook" did not appear on stdout, and stderr was empty. A guard can stop working in a nightly job and every log line will still look normal.
The setting
From Claude Code 2.1.295, a command or http hook takes onFailure. With "block", a failure does what exit 2 does on that event, so on PreToolUse the tool call is refused:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.sh",
"timeout": 10,
"onFailure": "block"
}
]
}
]
}
}
The failure is now visible as well. Claude receives the reason as the tool error, for example:
PreToolUse:Bash hook error: ["$CLAUDE_PROJECT_DIR"/.claude/hooks/gaurd.sh]: failed; blocking because onFailure is "block"
/bin/sh: /private/tmp/bt/proj/.claude/hooks/gaurd.sh: No such file or directory
After a timeout the message says timed out; blocking because onFailure is "block".
Three details decide whether this setting protects you.
Your version has to know the key. stable is 2.1.308 and latest is 2.1.312, so both have it. We installed 2.1.286 into a scratch directory and ran the misspelled-path guard with "onFailure": "block" set. The canary file was created and no warning appeared in the output or on stderr. A CI image or a laptop that has not updated since early October reads the setting and does nothing with it. Check claude --version wherever the guard runs. An organisation can set "requiredMinimumVersion": "2.1.295" in managed settings, and Claude Code then refuses to start below that version. The key is honoured only in managed settings, and Claude Code has supported it since 2.1.163.
A broken guard now stops everything it matches. With the path still misspelled and block set, we asked for a harmless date +%Y and it was refused with the same error. That is the correct behaviour for a guard, and it is also why the setting belongs on policy hooks only. A formatter on PostToolUse or a logging hook should keep the default, so that a broken formatter does not take Bash down with it.
Some hooks ignore it. The documentation lists Stop, SubagentStop, TaskCompleted and TeammateIdle, along with any hook marked async or asyncRewake. None of those should be your guard in any case.
Separately, make the guard refuse with exit 2, or exit 0 and print a JSON permissionDecision of "deny". With block set, an exit 1 refusal does block, but it is reported as a failure of the hook. Your logs will then count a broken guard and a working one the same way.
In bypass mode, the agent can remove the guard
When we ran the end-to-end check below without telling Claude to stop on a refusal, a fail-closed guard with a broken path did not hold. In three runs out of three, in bypassPermissions mode, Claude read the error, decided the hook was broken and opened .claude/settings.json. Twice it deleted the hook entry. Once it first created an empty gaurd.sh that exits 0 and then edited the settings. Each time the next Bash call went through and the canary file appeared. Claude Code picked up the edited settings within the same session. A guard that worked was left alone: three more runs with the correct path stopped at the refusal.
The permission-modes documentation lists .claude as a protected path whose writes are never auto-approved, except in bypassPermissions, where they are allowed. Allow rules cannot change that, but deny rules still apply in bypass mode. With these two rules added next to the hook, the same three runs ended differently. Claude tried Edit and Write on the settings file, both were refused, and the canary was never created:
{
"permissions": {
"deny": ["Edit(/.claude/**)", "Write(/.claude/**)"]
}
}
In .claude/settings.json the leading / anchors at the project directory. For a guard defined in ~/.claude/settings.json, write the rule with a home-relative path, Edit(~/.claude/**). Any session you run in bypass mode with a guard needs both: block so a broken guard refuses, and the deny rules so the agent cannot repair its way past the refusal.
Find the guards that still fail open
This lists every command or HTTP hook on the three events that can block a turn (PreToolUse, PermissionRequest and UserPromptSubmit) that does not set onFailure to block. Run it from the project root:
for f in ~/.claude/settings.json .claude/settings.json .claude/settings.local.json; do
[ -f "$f" ] || continue
jq -r --arg f "$f" '
.hooks // {} | to_entries[]
| select(.key == "PreToolUse" or .key == "PermissionRequest" or .key == "UserPromptSubmit")
| .key as $event | .value[] | (.matcher // "*") as $m | .hooks[]
| select((.type == "command" or .type == "http") and .onFailure != "block" and (.async != true))
| "\($f) \($event) \($m) \(.command // .url)"' "$f"
done
On our test project it printed:
.claude/settings.json PreToolUse Bash "$CLAUDE_PROJECT_DIR"/.claude/hooks/guard.sh
.claude/settings.json PreToolUse Edit|Write "$CLAUDE_PROJECT_DIR"/.claude/hooks/guard.js
It skipped the HTTP guard that already had block and the PostToolUse formatter. It does not read managed settings or hooks that ship inside plugins. For plugins, the post on what a plugin can do to your deny rules covers what to check.
Crash-test the guard for nothing
You do not need a model to learn whether a guard can fail. Pipe it the JSON it would receive, in the environment it will run in under cron or CI, and read the exit code. Only 0 and 2 are acceptable:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' \
| env -i PATH=/usr/bin:/bin HOME="$HOME" CLAUDE_PROJECT_DIR="$PWD" \
.claude/hooks/guard.sh; echo "exit $?"
env -i removes everything your shell profile adds, which is what a cron job, a launchd agent or a slim CI image sees. Then repeat with '{}' as the input, to see what happens when the input does not have the shape the guard expects. Our two guards gave:
| Guard | Input | Your terminal | PATH=/usr/bin:/bin |
|---|---|---|---|
guard.sh (bash + jq) | command to refuse | 2 | 2 |
guard.sh | {} | 0 | 0 |
guard.js (node) | command to refuse | 2 | 127 |
guard.js | {} | 1 | 127 |
The Node guard refused correctly in the terminal it was written in. In a cron environment it could not start, and on an unexpected input it threw before it could decide. Both are exit codes the default treats as "carry on". On macOS, /usr/bin/jq ships with the system, so the bash guard survived the stripped PATH. A guard that calls node, python3 from Homebrew or uv needs an absolute interpreter path in the hook's command, or it needs that directory on the PATH the job really gets.
For the end-to-end check, put a guard that matches a harmless canary in a scratch project and ask Claude to run the canary:
claude -p "Run exactly this bash command once: touch /tmp/CANARY-test" \
--model claude-haiku-4-5 --permission-mode bypassPermissions \
--add-dir /tmp < /dev/null
ls /tmp/CANARY-test # must not exist
Then misspell the guard's path and run it again. With block set and the deny rules above, the file still must not exist. With block alone, check git diff .claude/ afterwards as well. Without block, the file will be there.
Watch for failed guards in headless runs
Since a failed guard leaves nothing in ordinary headless output, ask for the hook events. --include-hook-events works only with --output-format stream-json, and adds a hook_started and a hook_response line for each hook that runs. What we saw on 2.1.312:
| What happened | outcome | exit_code |
|---|---|---|
| Guard allowed the call | success | 0 |
| Guard refused with exit 2 | error | 2 |
| Guard refused with exit 1 | error | 1 |
| Script path wrong | error | 127 |
| Guard timed out | cancelled | 1 |
A refusal and a crash both report error, so filter on the exit code as well:
claude -p "$TASK" --output-format stream-json --verbose --include-hook-events < /dev/null \
| tee run.jsonl \
| jq -c 'select(.type == "system" and .subtype == "hook_response"
and (.outcome == "cancelled" or (.outcome == "error" and .exit_code != 2)))
| {hook_name, outcome, exit_code, stderr}'
Any line this prints means a guard did not decide. With block set, the call was refused. Without it, the call ran. For a run you are debugging, --debug-file <path> writes lines such as finished with status 127, together with the hook's stderr, to a file.
A hook is a program with its own dependencies, PATH and failure modes, and its default on failure is to step aside. Set block on the hooks that exist to say no, deny edits to the file that defines them, and test each guard by breaking it on purpose before you trust it. If a guard is the only boundary around an unattended agent, the four dimensions a containment rule has to name, from what an agent sandbox rule does not cover, are the next thing to check.
Get the next post when it ships
One email on Sunday with the new post and a short list of what shipped that week — new guides, tool updates, and a couple of links worth reading.