When a coding-agent hook crashes, times out or exits with the wrong code, the agent treats it as a non-blocking error and carries on as if the hook did not exist. In Claude Code that is the documented default, and Codex and Cursor behave the same way out of the box. A security guard stops guarding, a token-saving filter stops filtering, and the session keeps running.
The only trace is a one-line notice in the transcript. This page is part of the Claude Code hooks series; that article maps what each hook event costs in tokens, and this one covers what happens when the hook itself breaks.
Behaviour below was read in the Claude Code hooks reference, the Codex hooks docs and the Cursor hooks docs on 2026-10-15.
What does "fail open" mean for an agent hook?
A hook fails open when its failure lets the action through. The opposite, failing closed, blocks the action whenever the hook cannot give an answer.
Claude Code's reference puts it plainly: "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" (reference, block the action when a hook fails).
That default is reasonable for most hooks. A broken desktop notification should not freeze your agent. It hurts in exactly two kinds of hook:
- Guards: hooks that deny
rm -rf, block writes outsidesrc/, or stop secrets from leaving the machine. - Filters you rely on for budget: hooks that rewrite or compact tool output so a long session stays affordable.
When a guard fails open, you lose the protection. When a filter fails open, you lose nothing visible, and the bill quietly goes up.
How does each failure mode behave in Claude Code?
Every way a hook can break, apart from a clean exit 2 or a valid JSON decision, ends as a "non-blocking error": the action goes ahead, and on events such as PreToolUse you see a <hook name> hook error notice (reference, exit code output).
| Failure | What Claude Code does by default | What you see |
|---|---|---|
| Exit 1 (or any code but 0 and 2), no valid JSON | Non-blocking error, action proceeds | Notice with the first line of stderr |
| Exit 1 with valid JSON on stdout | Success: the JSON fields decide | Nothing unusual |
| JSON that does not parse or fails schema validation, exit 0 | Non-blocking error, action proceeds | Notice with the parse or validation message |
| Script path missing or not executable (shell exits 127) | Non-blocking error, action proceeds | Failed with non-blocking status code: /bin/sh: ...: No such file or directory |
command, http or mcp_tool hook reaches its timeout on PreToolUse | Hook canceled, output discarded, call continues through the normal permission flow | timed out in the debug log |
| HTTP hook gets a non-2xx status or cannot connect | Non-blocking error, execution continues | Notice |
Exit 1 is not a block
The reference carries a warning box for this: "Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use exit 2."
Every Unix habit says 1 means "no". In a Claude Code hook, 1 means "the hook had a problem, carry on". Exit 2 is the block; on PreToolUse it stops the tool call and your stderr becomes the reason.
Anything on stdout before the JSON
Claude Code parses stdout as JSON only "when the whole output is one JSON object with nothing around it but whitespace". A shell profile that prints a greeting, a set -x left in, or a debug echo turns your decision into plain text or a parse failure.
A parse failure is a non-blocking error, so a permissionDecision: "deny" with a banner above it denies nothing. On the events that add plain stdout to context, Claude Code also drops stdout it failed to parse.
A missing binary or a mistyped path
When the script path does not exist, the shell exits 127 and you get a non-blocking notice. The reference's own advice: "watch for this notice on its first run, because a mistyped path in settings.json means the hook never runs."
The sneakier version is a dependency inside the script. The reference's Bash examples use jq and tell you to make sure it is on your PATH. If jq is missing, a typical guard extracts an empty command, matches nothing and exits 0. That is not even a hook error: the script succeeded at doing nothing.
Hooks run "with Claude Code's environment", meaning whatever environment launched Claude Code. A binary that lives in a directory your interactive shell adds to PATH may not be there when the agent is started by an IDE, a cron job or a CI runner.
Timeouts
The default timeout is 600 seconds for command, http and mcp_tool hooks, lowered to 30 on UserPromptSubmit, PreModelSwitch and PostModelSwitch (reference, common fields). A hook that hangs on PreToolUse therefore stalls the call for up to ten minutes, then lets it through.
The reference is explicit: a timed-out PreToolUse command hook "doesn't block the tool call... so don't count on a stalled hook to act as a gate." PreModelSwitch is the exception: a hook canceled at its timeout blocks the switch. Agent SDK callback hooks that time out on PreToolUse also block.
Do Codex and Cursor fail open too?
Yes, both fail open by default, and Cursor gives you a switch to change it.
Codex. The Codex docs say an unsupported field in a PreToolUse result, such as continue: false or permissionDecision: "ask", makes Codex mark the hook run as failed, report the error, "and continues the tool call."
For managed remote MCP hooks, the same page warns that "a PreToolUse callback error, timeout, or malformed response can fail the hook without blocking the tool." Exit 2 with a reason on stderr blocks; the default timeout is 600 seconds for most hooks (Codex hooks).
The docs list no fail-closed option for local command hooks. An open request, openai/codex#41979 (opened 2026-09-01), describes the current behaviour as: a timeout, spawn failure, crash or malformed output "is treated as a failed hook with should_block=false, so the tool is dispatched", and asks for an opt-in block mode.
Codex adds one silent-off mode of its own: non-managed hooks must be reviewed and trusted, and a new or changed hook is "skipped until trusted." Codex prints a startup warning pointing to /hooks when that happens, so read it after every edit to hooks.json.
Cursor. For beforeShellExecution and beforeMCPExecution, "crashes, timeouts, and non-zero exit codes other than 2 fail open by default: Cursor logs the failure and allows the action through."
Setting failClosed: true on a hook makes "hook failures (crash, timeout, non-zero exit code, no output) block the action instead." Cursor's permission hooks already block on invalid JSON even without it (Cursor hooks).
How do you find out a hook has failed?
You find out by looking for it on purpose, because the agent will not tell the model, and often not you. Four checks, cheapest first.
- Read the notice on the first run. In Claude Code, a non-blocking error leaves a
<hook name> hook errorline in the transcript. It is short and easy to scroll past, but it names the failure. - Open
/hooks. It lists every configured hook with its source (user, project, local settings, plugin or session) and the exact command it runs. A hook that is not in this list never runs, whatever your settings file says. - Turn on the debug log. Start with
claude --debug-file <path>, orclaude --debugand read~/.claude/debug/<session-id>.txt(reference, debug hooks). From v2.1.296, each command hook on tool events leaves aHooks:line ending infinished with status 0 (31ms),timed out after <N>msorfailed to start. Grep for the last two. - Run a canary. Make your guard deny one harmless sentinel, for example any Bash command containing
HOOK_CANARY. At the start of a long or overnight session, ask the agent to runecho HOOK_CANARY. If the word comes back, the guard is down.
For filters, the canary is the size of the output. Run one noisy command you know, such as your test suite, and check what the model received. If it got 4,000 lines instead of 40, the filter is not loading. Measuring agent token usage shows where those numbers live in the transcript.
How do you make a hook fail safely?
Decide, per hook, whether failure should block or pass, then make the configuration say so. Claude Code now has a field for it.
Set onFailure: "block" on guards. On a command or http hook, "onFailure": "block" turns any failure into what exit 2 does on that event. It needs Claude Code v2.1.295 or later. The reference counts as failures: a hook that cannot start, an exit code other than 0 or 2 (even with JSON that allows), an HTTP error, a timeout, and invalid JSON.
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
"onFailure": "block"
}
]
}
]
}
}
The reference's own test is the right one: delete the script, ask for ls, and expect failed; blocking because onFailure is "block". Note the limits. The field has no effect on Stop, SubagentStop, TaskCompleted and TeammateIdle, or on async hooks.
Block with exit 2 or JSON, never exit 1. Exit 0 with a JSON permissionDecision, or exit 2 with a reason on stderr. Anything else is a non-blocking error.
Keep stdout clean. Print only the JSON object. Send debug lines to stderr, which Claude never sees from a hook that exits 0.
Use exec form for scripts. With args set, Claude Code resolves command on PATH and spawns it with no shell, so quoting around ${CLAUDE_PROJECT_DIR} cannot break the path. Use absolute paths, or check that every binary your script calls resolves in the agent's environment.
Make the script fail loudly. In Bash, set -euo pipefail turns a missing jq into a non-zero exit instead of an empty variable. Combined with onFailure: "block", that missing dependency now blocks instead of approving.
Set a timeout you can live with. Ten minutes is a long time for a guard to think. If your check normally takes 200 ms, a timeout of 5 seconds plus onFailure: "block" means a hung check costs you one blocked call, not ten minutes and an unchecked one.
On Cursor, use failClosed: true. On Codex, with no such switch, the canary is your safety net.
Should a token-saving filter fail open or closed?
A filter should fail open, and you should monitor it like a guard. Those two statements are not in conflict.
If a compaction hook breaks and fails closed, every Bash call is blocked and the agent cannot work. That is worse than paying for a noisy log. Failing open keeps the work going, with the cost going back to what it was before the filter.
The cost is real on long sessions. On the THOL benchmark, Tokenade cuts session cost by 39% on long sessions (methodology). A filter that silently stops loading gives that back without a single error on screen.
So the rule I use:
- Guards:
onFailure: "block"orfailClosed: true, a short timeout, a canary at session start. - Filters: fail open, plus a check that they are actually running. For Tokenade,
tokenade healthreports whether its hooks are installed and names the fix when they are not. - Everything else (notifications, logging, formatting): leave the default.
For the filtering technique itself, see output filtering for command logs.
What goes wrong (anti-patterns)
- Testing only the happy path. You checked that
lsstill runs. You never checked thatrm -rf /tmp/xis blocked. A gate you have not seen fire is not a gate. - Treating a clean session as proof. No error notice can mean the hook works, or that it is not registered at all.
/hookstells you which. - Editing the script, not the config. Rename
check.shtocheck-command.shand the old path insettings.jsonnow exits 127 on every call. - Assuming another agent copies Claude Code. Cursor blocks on bad JSON in permission hooks; Claude Code and Codex do not. Port a guard and you port its failure semantics too, or you do not.
FAQ
Why does my Claude Code hook not block anything?
The usual causes are exit 1 instead of 2, text printed before the JSON, a script path that does not exist, a missing dependency like jq, or a PreToolUse timeout. All of them are non-blocking errors by default, so the action goes ahead. Open /hooks to confirm it is registered, then read the debug log for failed to start or timed out.
What is the default timeout for a Claude Code hook?
600 seconds for command, http and mcp_tool hooks, 30 for prompt hooks and 60 for agent hooks. The first three drop to 30 seconds on UserPromptSubmit, PreModelSwitch and PostModelSwitch. A timed-out PreToolUse command hook lets the tool call continue unless you set onFailure: "block".
How do I make a Claude Code hook fail closed?
Add "onFailure": "block" to a command or http hook (Claude Code v2.1.295 or later). A failure then does what exit 2 does on that event: on PreToolUse it blocks the tool call. Test it by removing the script and running a command the hook matches; the error should include blocking because onFailure is "block".
Do Codex hooks fail open?
Yes. The Codex docs say a PreToolUse result with an unsupported field is marked failed and the tool call continues, and that callback errors, timeouts or malformed responses from managed MCP hooks can fail without blocking. No fail-closed option is documented for local command hooks; GitHub issue #41979 asks for one.
Should a token-saving hook block when it fails?
No. A filter that fails closed stops the agent from running commands at all. Let it fail open, then check it is loaded: run one noisy command and see how much output the model received, or use the tool's own health check.
See also
- Claude Code hooks: which ones add tokens, which cut them: the cost map of every hook event.
- Output filtering for command logs: what a token-saving hook actually does.
- Overnight agent budget safety: where a dead filter or guard hurts most.
- How to measure agent token usage: verify a filter from the transcript.
- How to reduce Claude Code token usage: the wider list of levers.
- Best Claude Code token optimizers: tools that ship these hooks for you.
Cut your AI agent's token bill.
Ranked #1 on the Token-Harness Optimizer Leaderboard. Zero config.
Tokenade cuts the token bill of AI agents: one install, zero config, and it trims what your agent sends to the model.