Security How-to

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.

A white cube split by horizontal scanline tears, with a gold glow leaking through the gap

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 found as a common troubleshooting case. jq is 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, http or mcp_tool hook gets cancelled at its timeout, 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.

  1. Run the registered command exactly as the hook runner would. If your settings use shell form, the command goes through sh -c on macOS and Linux. Run sh -c '"$CLAUDE_PROJECT_DIR"/.claude/hooks/no-force-push.sh' < payload.json with CLAUDE_PROJECT_DIR set, and check the exit code, not just the output.
  2. 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.
  3. Watch the first real run. The docs advise looking for the hook error notice 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.