Your Claude Code policy hook probably fails open. Here's how to test it
A hook that crashes, times out or can't start lets the action through by default. Ten minutes to find out which of your gates are really gates.

You wrote a PreToolUse hook to stop force pushes. It works: you asked Claude to force push a scratch branch and got your block message. Then a teammate clones the repo on a fresh laptop with no jq, and the same hook quietly stops blocking anything. Nothing on screen tells them.
That’s the default behaviour, and Anthropic’s own docs say so. In Claude Code, a policy hook that errors out lets the action proceed. Release 2.1.295 on October 8 added an onFailure: "block" option for command and HTTP hooks, per the changelog
, so a hook that can’t start, times out or exits unexpectedly blocks the action instead. Whether or not you adopt that, the useful exercise is the same: find out which of your hooks are gates and which are suggestions.
What counts as a failure
The hooks reference
is blunt about this. For most events, exit code 2 is the only exit code that blocks through the code alone. Exit 1, the conventional Unix failure code, is a non-blocking error when there’s no valid JSON on stdout. The action proceeds, and the transcript shows a notice that starts with Failed with non-blocking status code:.
Three ordinary failures land in that bucket:
- The script can’t start. A mistyped path in
settings.json, or a script that isn’t executable, makes the shell exit with a code like 127. The docs call this out directly: a mistyped path leaves the gate silently disabled. - A dependency is missing. The hooks guide lists
jq: command not foundas a common troubleshooting case.jqis the usual way to read the JSON a hook gets on stdin, so a machine without it breaks every hook built on that pattern. - The hook hangs. A
command,httpormcp_toolhook gets cancelled at itstimeout, which defaults to 600 seconds. On PreToolUse, a timed-out hook of those types doesn’t block the call, and the docs say plainly not to count on a stalled hook as a gate.
None of this is a bug. A hook that crashes on every tool call would make the agent unusable, so failing open is a sensible default for hooks that format code or send notifications. It’s the wrong default for the hook whose job is to say no.
Reproduce it
Here’s the force-push hook in its naive form, the same shape as the one in our PreToolUse guardrail post
. I saved it as naive.sh in a scratch directory:
#!/bin/bash
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eq 'git( .*)? push( .*)? (-f|--force)( |$)'; then
echo "Blocked: no force pushes." >&2
exit 2
fi
exit 0
I fed it a force push three ways: normally, with PATH pointed at nothing (no jq, no grep), and with garbage on stdin. This is what it printed:
--- naive, jq present
Blocked: no force pushes.
exit=2
--- naive, jq missing
./naive.sh: line 2: jq: command not found
./naive.sh: line 3: grep: command not found
exit=0
--- naive, garbage stdin
jq: parse error: Invalid numeric literal at line 1, column 4
exit=0
Exit 0 on a force push. The script’s last line is exit 0, and every failure above it was swallowed. Under the documented rules, Claude Code reads that as “no decision” and the normal permission flow takes over. In a session running in bypass mode, that means the push goes through.
I ran the scripts directly in a shell. I did not run an agent session against them, so what happens inside Claude Code at each exit code is the documented behaviour, not something I watched.
The fix: make the script fail closed
The principle is that the hook should only exit 0 when it has positively decided the call is fine. Every other path, including crashes, ends in exit 2. Here’s the version I tested:
#!/bin/bash
# Fail closed: any unexpected error becomes exit 2, which blocks the call.
set -uo pipefail
deny() { echo "Blocked: $1" >&2; exit 2; }
trap 'deny "policy hook crashed (line $LINENO), refusing by default"' ERR
set -E
command -v jq >/dev/null || deny "jq is not installed, so this hook can't judge the command"
CMD=$(jq -er '.tool_input.command' 2>/dev/null) || deny "couldn't read tool_input.command"
if printf '%s' "$CMD" | grep -Eq 'git( .*)? push( .*)? (-f|--force|\+[^ ]+)( |$)'; then
deny "force pushes aren't allowed here. Use --force-with-lease, or ask the user."
fi
exit 0
A few choices in there are deliberate.
deny writes to stderr and exits 2, which is the documented way to block a PreToolUse call and hand Claude a reason. The ERR trap is the backstop for anything I didn’t anticipate: a failing command outside a conditional trips it and turns into a block. set -E makes the trap apply inside functions too.
jq -e exits non-zero when the field is missing or null, and I check that explicitly. Without -e, a payload with no tool_input.command would produce an empty string, the regex wouldn’t match, and the script would allow the call. An empty command isn’t evidence of a safe command.
I also widened the pattern to catch git push origin +main, the refspec form of a force push, which the naive regex misses. The ( |$) after the alternation keeps --force-with-lease allowed, which matters because the block message recommends it.
Same battery of inputs, new script:
--- closed, jq present, status
exit=0
--- closed, jq missing
Blocked: jq is not installed, so this hook can't judge the command
exit=2
--- closed, garbage stdin
Blocked: couldn't read tool_input.command
exit=2
--- closed, no command field
Blocked: couldn't read tool_input.command
exit=2
And the command variants, fed through jq -nc --arg c "$c" '{tool_input:{command:$c}}' so quoting stays honest:
git push --force origin main => exit=2
git push origin +main => exit=2
git push --force-with-lease origin main => exit=0
git push origin main => exit=0
git push -f => exit=2
A missing jq now stops the agent from running Bash at all on that machine, which is annoying and also exactly what you want. The message tells the developer what to install.
What the script can’t fix
Fail-closed scripting only covers failures inside your script. Two failure modes sit outside it.
First, the script never starts. If the path in settings.json is wrong, there’s no script to fail closed. This is the case the 2.1.295 onFailure: "block" option is aimed at, since the harness itself is deciding what happens when the hook can’t run. I’m not going to show a config snippet for it. The changelog line is the only place I could find it described, and the hooks reference didn’t document the field when I checked on October 9, so look up the current docs for where it goes before relying on it. Until you’ve confirmed it in your version, treat it as an extra, not as the gate.
Second, the timeout. A script that calls out to the network and hangs will be cancelled at the timeout value, and per the docs a cancelled PreToolUse command hook doesn’t block. If your policy check can hang, don’t do the slow thing in the hook. Keep gates local, quick and deterministic, and set timeout to a few seconds so a stall surfaces fast instead of after the 600-second default. A lowered timeout doesn’t make the stall block, but it shrinks the window and makes the failure visible sooner.
One related detail from the reference: an Agent SDK callback hook that times out on PreToolUse does block the call. If you’re building on the SDK rather than shell hooks, that’s a different, safer default, and worth knowing before you port a policy from one to the other.
Test the gate, not the script
The deeper habit is to test the hook as installed, not only the script as written. Three checks catch most real-world gate failures, and each takes a minute.
- Run the registered command exactly as the hook runner would. If your settings use shell form, the command goes through
sh -con macOS and Linux. Runsh -c '"$CLAUDE_PROJECT_DIR"/.claude/hooks/no-force-push.sh' < payload.jsonwithCLAUDE_PROJECT_DIRset, and check the exit code, not just the output. - Break it on purpose. Rename the script, remove its execute bit, unset
PATH. If the action would still proceed, you’ve found a place where the gate is decorative. - Watch the first real run. The docs advise looking for the
hook errornotice when you set up a policy hook, because that notice is the only sign a gate is off.
If the hook references a path through a placeholder or you have users on Windows, consider exec form: set args on the hook and Claude Code spawns the executable directly with no shell, which removes quoting and shell-selection as ways to fail. The reference notes that on Windows, exec form needs a real executable, and .cmd shims from npm need shell form.
When not to bother
Most hooks should stay fail-open. A formatter, a notification, a logger or a context injector that crashes shouldn’t stop work, and making it blocking just teaches people to disable hooks. Reserve fail-closed for the short list of things where “the check didn’t run” is worse than “the agent was interrupted”: force pushes, writes to protected paths, production credentials, deploy commands.
Also, if the rule can be a plain permission deny, use that. The hooks guide recommends the permission system for hard allows and denies, and a deny rule has no script to crash. Hooks earn their place when the decision needs logic a pattern can’t hold.
First ten minutes
Find your most important PreToolUse hook and run it with PATH=/nonexistent /bin/bash ./your-hook.sh < payload.json; echo $?. If it prints 0, add the deny helper and the explicit checks from the script above, then rerun until every broken input gives 2. After that, check the 2.1.295 docs for onFailure and decide whether to add it on top.