Claude Code Hooks onFailure: Make a Broken Hook Block
By default, a Claude Code hook that breaks lets the action go ahead. If your PreToolUse policy hook points at a script that was renamed, crashes on bad input, or times out, the tool call it was guarding runs anyway. Since v2.1.295 you can change that per hook with "onFailure": "block".
Verified 2026-10-31 against the hooks reference at code.claude.com and the changelog (newest entry on the page was 2.1.316). We did not run a live Claude Code session for this article, so the behavior below is what the docs state, and the one script we did run is a local exit-code check, labelled as such.
What counts as a failing hook?
The reference lists five failures, and the third one catches people out:
- Can't start: the script or executable doesn't exist.
- Exit code other than 0 or 2: this counts even if the hook printed JSON that allows the action. To return a JSON decision, exit 0.
- HTTP error: an
httphook's connection fails or the status isn't 2xx. - Timeout: the hook reaches its
timeout. - Invalid output: JSON that can't be parsed or fails schema validation. Plain-text stdout from a command hook is not a failure.
Exit 1, the conventional Unix failure code, is therefore a non-blocking error. The docs say so directly: if your hook is meant to enforce a policy, use exit 2. A script that dies with a Python traceback or a set -e abort usually exits 1, and the action proceeds with only a hook error notice in the transcript.
How do you make a hook fail closed?
Add onFailure to a command or http hook:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
"onFailure": "block"
}
]
}
]
}
}
This is the example from the hooks reference. With "block" set, any failure from the list above does what exit 2 does on that event. On PreToolUse that cancels the tool call. On PermissionRequest it denies the request. Per the docs, if the script is missing and Claude runs a Bash command, the call is blocked with an error containing failed; blocking because onFailure is "block". Without the field, the same missing script is a non-blocking error and the command runs.
The values are "continue" and "block". Unset behaves as "continue": the docs name it the default for http hooks, and for command hooks they describe the action carrying on after a failure when the field is unset.
Where does onFailure do nothing?
Two places, both documented:
Stop,SubagentStop,TaskCompletedandTeammateIdle. Exit 2 on these events sends Claude back to keep working, and the docs' reasoning is that Claude can't repair a hook that won't run. A failing hook here would loop, so the field is ignored.- Background command hooks. Command hooks with
asyncorasyncRewake.
It is also only listed for command and http hooks. It does not apply to mcp_tool, prompt or agent handlers.
One more limit sits beside it: exit 2 only blocks on events that can block. PostToolUse, PostToolUseFailure, PermissionDenied, Notification and SessionStart are not among them, so a policy has to live on an event that can say no, usually PreToolUse.
What should a fail-closed hook script look like?
Make the script's own failures explicit instead of leaning on exit 1. This local check (bash, jq required) uses the stdin shape from the hooks guide and treats anything it can't parse as a block:
#!/bin/bash
# .claude/hooks/check-command.sh
input=$(cat)
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty') || {
echo "could not parse hook input" >&2
exit 2
}
case "$cmd" in
*"rm -rf /"*|*"git push --force"*)
echo "Blocked by policy: $cmd" >&2
exit 2 ;;
esac
exit 0
We ran it against three inputs in a scratch directory. {"tool_input":{"command":"ls"}} exited 0, a git push --force origin main command exited 2 with the policy message on stderr, and the text not json exited 2 through the parse branch. That checks the script, not Claude Code's handling of it. Keep onFailure as well, because it covers the cases a script can't: a missing interpreter, a timeout, a wrong path.
Remember the if field is a filter, not a security boundary. The docs call the Bash matching best-effort and recommend permission rules for hard allow/deny.
How do hook failures show up?
On events such as PreToolUse a non-blocking failure shows a <hook name> hook error notice with the first line of stderr, for example Failed with non-blocking status code: something broke. Full stderr needs debug logging. If you have never looked at those notices, search a recent transcript for them: a hook that has been failing silently for weeks looks exactly like that.
FAQ
Does exit code 1 block a Claude Code hook?
No. Only exit 2 blocks, on events that can block. Exit 1 without valid JSON is a non-blocking error, unless the hook has onFailure set to "block", in which case it blocks too.
What happens when a Claude Code hook times out?
The action goes ahead by default and you see a hook error notice. With onFailure: "block" a timeout blocks. Command hooks default to a 600 second timeout, except 30 seconds on UserPromptSubmit, PreModelSwitch and PostModelSwitch, and 10 seconds on MessageDisplay.
What version of Claude Code added onFailure?
2.1.295. Older versions ignore the field, so a policy that depends on it should be paired with a minimum version check in your team setup.
Should every hook use onFailure block?
No. Formatters, notifiers and logging hooks should fail open: a broken auto-formatter should not stop your edits. Reserve block for hooks whose job is to prevent something, and for those, see the hooks guide for event choice and matchers.