The annoyance
I registered a PreToolUse hook on Bash so a project script could see the command before it ran. The path in .claude/settings.json was one directory out. The transcript showed a hook error. ls still ran.
A crash, a timeout, and any exit other than 0 or 2 do the same. The row looks like a guard. The tool call goes ahead.
What Anthropic actually says
From the official hooks reference, under exit-code output:
“Non-blocking error: your hook exits with any other code, or fails in some other way, such as not starting or printing invalid JSON.”
“If you want a failed hook to block the action, set onFailure: "block".”
Under other exit codes:
“When your hook exits with a code other than 0 or 2 and prints plain text or nothing to stdout, the run is a non-blocking error.”
The notice carries Failed with non-blocking status code: and the first line of stderr. “A hook that can't start is a non-blocking error too.” In shell form, “the shell exits with a code like 127”, and “a mistyped path in settings.json means the hook never runs.” That was my directory.
On PreToolUse: “A timed-out command, http, or mcp_tool hook doesn't block the tool call.” “don't count on a stalled hook to act as a gate.”
Then “Block the action when a hook fails”:
“On most events, when a hook fails or times out, Claude Code still carries out the action, so a policy hook with a wrong path or a crashing script lets everything through. To block the action instead, set "onFailure": "block" on a command or http hook. The default value is "continue". Requires Claude Code v2.1.295 or later.”
“Plain-text stdout from a command hook isn't a failure”. “To return a JSON decision, exit 0”. With the field set, “a failure does what exit code 2 does on that event, except on PermissionRequest, where it denies the request.” Then “The field has no effect on these hooks:” Stop, SubagentStop, TaskCompleted, and TeammateIdle, because “Claude can't repair a hook that won't run”. The same for “command hooks that set async or asyncRewake”.
A hook that started and means to deny still exits 2. “Exit with code 2 to block the action.” That contract is Exit 1 Does Not Block. PermissionRequest Exit 2 Does Not Deny is the event where the decision object is the deny. This note is the hook that failed before it could choose.
The fix
The example from that section. type is command, command is node, and args is the project script, with onFailure set to block.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
"onFailure": "block"
}
]
}
]
}
}The same key belongs on an http hook. “To block the action when the request fails or returns a non-2xx status, set onFailure: "block".” A non-2xx status or a failed connection stays a non-blocking error while the field is at its default.
Claude Code v2.1.295 or later. On an older build the key is unread, and the default stays continue.
Prove it
Leave check-command.js missing and ask for ls. The reference says what happens: “Claude Code blocks the call, and the error includes failed; blocking because onFailure is "block"”.
# .claude/settings.json carries the PreToolUse Bash hook above
# Leave .claude/hooks/check-command.js missing
# Ask Claude to run:
ls
# The call must not run.
# The notice includes:
# failed; blocking because onFailure is "block"The notice the docs print:
PreToolUse:Bash hook error: [node ${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js]: failed; blocking because onFailure is "block"“After a timeout, the message says timed out instead of failed. Without onFailure set, the same missing script is a non-blocking error and ls runs.”
I then put the real script in place.
Checklist
- Claude Code v2.1.295 or later. Earlier builds leave the default in place.
- Set
"onFailure": "block"on each command or http policy hook. An omitted field is"continue". - Leave the script missing and ask for
ls. The notice saysfailed; blocking because onFailure is "block". - A timeout says
timed outin that notice, and the call stays blocked. - A healthy deny is still exit 2. A JSON decision exits 0.
Stop,SubagentStop,TaskCompleted,TeammateIdle,async, andasyncRewakeignore the field.
Why it fails
I had read the row in settings.json as a closed gate. The default is continue, so the transcript notice is a log line and the tool still runs, until onFailure is block.
Same family as Exit 1 Does Not Block, the note on a hook that did run and exited outside 0 or 2, and PermissionRequest Exit 2 Does Not Deny, the event where the decision object is the deny. Standalone Hooks File Never Loads is the file Claude Code never reads. This one is the handler that was listed and still failed open.
I keep the operator notes for this series in the NavAIgate classroom at https://www.skool.com/navaigate.
Sources
From idea to operation
Make the next AI decision concrete.
NavAIgate helps leadership teams identify high-value AI opportunities, prove them safely and turn the winners into working systems.
