Do Your Agent Hooks Fail Open? Claude Code vs Codex vs Cursor vs Gemini CLI

TL;DR
A guard hook that crashes, times out or points at a missing script lets the tool call through by default in Claude Code. v2.1.295 adds onFailure: block.
A coding agent hook that is meant to block something only blocks when it runs correctly. In Claude Code, Cursor and Gemini CLI, a guard script that crashes, times out or cannot start lets the tool call through by default, and Codex documents several of the same paths. Claude Code v2.1.295 (October 8) added onFailure: "block" to flip that per hook, Cursor has had failClosed: true, and the most portable fix is a guard script that turns its own errors into exit code 2.
Last updated: October 9, 2026. Claude Code behaviour below was tested on v2.1.295; Codex, Cursor and Gemini CLI rows come from their current docs and source, linked inline.
The trigger for this page is two lines in the Claude Code changelog. v2.1.294 fixed "prompt and agent hooks written as instructions (such as 'Block commands that...') allowing what they should block". A day later, v2.1.295 added onFailure: "block" for command and HTTP hooks, so "a hook that can't start, times out, or exits with an unexpected code blocks the action instead of letting it through". If you wrote a PreToolUse guard after reading our Claude Code hooks guide, this is the setting it was missing.
The short answer#
- Claude Code: fails open by default. Add
"onFailure": "block"and a shorttimeoutto every command or HTTP hook that enforces policy. Requires v2.1.295 or later. Prompt, agent and MCP tool hooks are not covered, so do not use them as a security gate. - Cursor: fails open by default for crashes, timeouts and odd exit codes, but already fails closed on malformed output from permission hooks. Add
"failClosed": trueto security hooks. - Codex: no fail-closed switch in the docs as of today, and the failure paths it does document for
PreToolUsecontinue the tool call. Treat hooks as a guardrail and keep the sandbox and approval policy as the real boundary. - Gemini CLI: the docs say any exit code other than 0 and 2 is a warning and the action proceeds. No documented fail-closed switch.
- Everywhere: write the guard so its own failures exit 2. That works in all four, today, with no new settings.
What fail open looks like#
I ran the same request through Claude Code v2.1.295 (claude -p with Haiku, Bash allowed for echo) six times, changing only the PreToolUse hook in .claude/settings.json. The request was "run echo HOOK_TEST_RAN".
| Hook setup | onFailure | Did echo run? |
|---|---|---|
| Script path does not exist | not set | Yes |
| Script path does not exist | "block" | No, blocked |
Script sleeps 30s, timeout: 3 | not set | Yes |
Script sleeps 30s, timeout: 3 | "block" | No, blocked |
| Script prints a jq error and exits 1 | "block" | No, blocked |
| Working guard, safe command | "block" | Yes |
The first and third rows are the dangerous ones. In both runs the final answer gave no sign that the guard had not run; the command simply executed. Anthropic's hooks reference is explicit about this: a mistyped path in settings.json "leaves the gate silently disabled", exit code 1 is a non-blocking error "even though 1 is the conventional Unix failure code", and a timed-out command hook "doesn't block the tool call".
With onFailure: "block", the same failures produced block messages Claude reported back verbatim:
PreToolUse:Bash hook error: [/nonexistent/guard.sh]: failed; blocking because onFailure is "block"
/bin/sh: 1: /nonexistent/guard.sh: not found
PreToolUse:Bash hook error: [.../slow.sh]: timed out; blocking because onFailure is "block"
The last row matters too: a healthy guard with onFailure: "block" still lets safe commands through. The setting changes what a broken hook does, not what a working one does.
Claude Code: turn on onFailure for policy hooks#
This is the config I tested. The only new field is onFailure; the rest is the standard hook handler shape:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard.sh",
"timeout": 5,
"onFailure": "block"
}
]
}
]
}
}
Three things to get right alongside it:
- Set a short
timeout. The default for command and HTTP hooks is 600 seconds. A hung guard with the default timeout stalls the session for ten minutes before it fails, open or closed. A shell guard needs a few seconds at most. - Only gate events that can block.
onFailureonly has something to block on events where exit code 2 already blocks:PreToolUse,UserPromptSubmit,Stop,PreCompactand the rest of the "Can block? Yes" rows in the per-event table. APostToolUsehook runs after the tool did its thing. - Do not lean on prompt, agent or MCP tool hooks for enforcement. The release note scopes
onFailureto command and HTTP hooks. MCP tool hooks still produce a non-blocking error when the tool returnsisErroror the server is not connected. Prompt and agent hooks are a model making a judgement, and v2.1.294 had to fix exactly that judgement letting through what it was told to block. Use them for quality gates onStop, where a wrong call costs you a turn, not a deleted directory.
If you are deciding between a hook, a permission rule and a mod for a given job, our Claude Code mods guide has the decision table, and the permissions settings guide covers the deny rules that do not depend on a script running at all. A deny rule in permissions cannot crash, which makes it the first thing to reach for when the policy fits a pattern.
The four agents side by side#
| Claude Code v2.1.295 | Codex | Cursor | Gemini CLI | |
|---|---|---|---|---|
| Exit 2 blocks | Yes | Yes | Yes | Yes |
| Guard crashes or exits 1 | Proceeds | Not documented for command hooks | Proceeds | Proceeds with a warning |
| Guard times out | Proceeds (tested) | Managed hooks: can fail without blocking | Proceeds | Proceeds, failure reported |
| Malformed output | Proceeds, hook error shown | Unsupported fields: hook marked failed, call continues | Permission hooks block | Defaults to allow |
| Opt-in fail closed | onFailure: "block" (command, HTTP) | None documented | failClosed: true | None documented |
| Default timeout | 600s (command, HTTP) | 600s | Platform default | 60s |
Sources for each column: Claude Code hooks reference plus the test runs above; Codex hooks docs; Cursor hooks docs; Gemini CLI hooks overview and hook runner source. Only the Claude Code column was run; the others are what the vendors document.
Codex#
Codex hooks look a lot like Claude Code hooks (same event names, exit 2 blocks, and Codex even sets CLAUDE_PLUGIN_ROOT for compatibility), but the failure story is less spelled out. What the docs do say all points the same way:
- A
PreToolUsehook that returns a field Codex does not support yet, such aspermissionDecision: "ask"orcontinue: false, is marked failed, "reports the error, and continues the tool call". - MCP tool hooks: "Errors, missing servers, and unavailable tools don't block the operation."
- For managed hooks, "a
PreToolUsecallback error, timeout, or malformed response can fail the hook without blocking the tool." - New or changed hooks are "skipped until trusted". Edit your guard script's command line and it stops running until someone re-trusts it in
/hooks. That is a fail-open path Claude Code and Cursor do not have, traded for protection against a repo quietly adding a hook.
The one place Codex fails closed on purpose is PermissionRequest: returning the reserved updatedInput, updatedPermissions or interrupt fields fails closed today. And the docs say it plainly: "Treat tool hooks as a useful guardrail, not a complete enforcement boundary." On Codex, the boundary is the sandbox and approval mode, which we cover in the permissions, logs and rollback piece.
Cursor#
Cursor is the most explicit of the four. Its hooks docs list "Other exit codes - Hook failed, action proceeds (fail-open by default)", and the per-script failClosed option, default false, makes "crash, timeout, non-zero exit code, no output" block instead. Cursor also goes one step further than Claude Code by default: for permission hooks (preToolUse, beforeShellExecution, beforeMCPExecution, beforeReadFile, subagentStart), invalid JSON or a response that does not match the schema already blocks, even without failClosed.
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{ "command": "./hooks/guard.sh", "timeout": 5, "failClosed": true }
]
}
}
That snippet follows the documented per-script options; I did not run Cursor for this piece. One sharp edge if you share hooks between tools: Cursor can import Claude Code plugin hooks, and a bug report on the Cursor forum from September 27 says converted hooks are always failClosed: false, receive tool_name: "Shell" instead of "Bash", and drop the legacy {"decision": "block"} shape. A guard that checks tool_name == "Bash" concludes the call is not its business and allows it.
Gemini CLI#
Gemini CLI still ships (v0.63.0 landed this week) for Code Assist Standard and Enterprise users, though most individual users moved to Antigravity CLI after the June transition, and I have not checked Antigravity's hook semantics. The Gemini CLI docs say exit 0 is success, exit 2 is a "System Block", and any other code is a warning where "the interaction proceeds using original parameters". Non-JSON on stdout means "the CLI will default to 'Allow'". The default timeout is 60 seconds, the shortest of the four.
Reading the current hook runner on main shows the code is stricter than the docs in one case: only exit code 1 is treated as a soft warning, and any other non-zero exit that prints non-JSON text is converted to a deny. A missing script, which the shell reports as exit 127 with an error message, would therefore block. A timeout returns no decision and is reported as a failed hook. I did not run Gemini CLI to confirm, so treat the docs as the contract and the source as a hint.
Write the guard so it fails closed anyway#
Settings flags are per tool and per version. Exit code 2 means "block" in all four. So the most portable fix is a guard whose own failures exit 2:
#!/usr/bin/env bash
# Fail-closed PreToolUse guard: any unexpected error exits 2 (block), never 1.
set -euo pipefail
trap 'echo "guard.sh crashed on line $LINENO, blocking to be safe" >&2; exit 2' ERR
input=$(cat)
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
if [[ "$cmd" =~ (^|[[:space:]\;\&\|])rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f ]]; then
echo "Blocked by guard.sh: recursive force delete" >&2
exit 2
fi
exit 0
I fed it four payloads directly, then the same script with the set and trap lines removed:
safe command -> exit 0
cd app && rm -rf build -> exit 2 Blocked by guard.sh: recursive force delete
payload is not JSON -> exit 2 guard.sh crashed on line 7, blocking to be safe
PATH without jq or cat -> exit 2 guard.sh crashed on line 6, blocking to be safe
# without set -euo pipefail and the trap:
payload is not JSON -> exit 0
PATH without jq or cat,
rm -rf payload -> exit 0
The last line is the one to remember. A naive guard on a machine without jq did not even fail with an error code: jq was not found, $cmd was empty, the regex did not match, and the script exited 0. No hook error, no notice, and rm -rf would have run. onFailure: "block" would not save you here either, because from the agent's point of view the hook succeeded. Only the script can catch that.
What the trap does not catch: a guard that hangs. That is what the short timeout plus onFailure: "block" (Claude Code) or failClosed: true (Cursor) is for. Use both layers.
The regex above is deliberately small to keep the example readable. Pattern-matching shell strings is a weak policy on its own, and a model can route around it with find -delete or a Python one-liner. Pair guards like this with a sandbox or deny rules, and see the agent firewalls comparison for dedicated tools; the hook is the tripwire, not the wall.
When failing open is the right call#
Failing closed has a cost: your guard's bug becomes an outage. Every tool call blocks until someone fixes a typo, and an unattended run stalls overnight. Two good examples of people choosing fail open on purpose:
- GitGuardian's ggshield hooks for coding agents fail open by design. Its docs say this "keeps your AI coding tool usable when ggshield is misconfigured", and pair it with a
ggshield machine doctorcheck that reports whether each agent's hook is installed and can reach the service. - Himanshu Shukla's hooks write-up keeps both of his
PreToolUseguards fail open, because they protect him "from my own automation moving quickly, not from an adversary". If his guard crashes, the worst case is a messy commit he can rewrite.
The rule that falls out: fail closed when the action is irreversible or the threat is adversarial; fail open, loudly, when the hook is a convenience and an outage costs more than a miss. Formatters, notifiers and loggers fail open. Guards on deletes, force pushes, secret reads, production deploys and outbound network calls fail closed. If you fail open, make the failure visible: a doctor command, a log line, a SessionStart check that the guard script exists and runs.
Decision guide#
| Your hook | Recommendation |
|---|---|
| Blocks destructive shell commands | Fail closed: trap-to-exit-2 script, timeout: 5, onFailure: "block" or failClosed: true |
Blocks reads of secrets or .env files | Fail closed, and add a deny permission rule so it does not depend on a script |
| Scans prompts for pasted API keys | Fail closed on UserPromptSubmit; a blocked prompt is cheap to resend |
| Formats or lints after edits | Fail open; it runs on PostToolUse and cannot block anyway |
Keeps the agent working until tests pass (Stop) | Fail open, or a turn limit; a broken gate should not trap a session in a loop |
| Imported from a Claude Code plugin into Cursor | Check the payload it actually receives before trusting it |
| Runs on Codex | Keep it, but treat the sandbox and approval mode as the enforcement boundary |
If you run several agents against one repo, our Claude Code vs Codex vs Cursor vs OpenCode comparison covers the wider differences. And if you want the argument for why a passing hook still does not prove the right thing happened, Approve Effects, Not Invocations is the long version.
What people are actually saying#
- The fail-open default is catching people by surprise. The Cursor forum report on imported Claude Code hooks describes a "block
git commituntil review passes" plugin being "silently bypassed under Cursor, with no error shown", and the reporter marked it as making Cursor unusable for them. A r/ClaudeWorkflows post this week walks through the same trap on anrm -rfguard and a fail-closed wrapper for it. - The sharpest counter-case is that fail closed is a choice, not a virtue. Shukla's post argues the default should follow your threat model, and ggshield ships fail open deliberately. Both pair it with visibility, which is the part most hand-written guards skip.
- Mods raise the same question one level up. A Medium walkthrough of Claude Code mods puts it as "A guard that fails open isn't a guard", and the v2.1.295 notes include a fix for calls getting past a mod's guard hook during a hooks worker restart. The new hook surfaces are inheriting the same failure mode, and Anthropic is closing those paths one release at a time.
FAQ#
Do Claude Code hooks fail open?#
Yes, by default. For most events, only exit code 2 blocks. A hook that exits 1, crashes, times out, cannot start, or prints invalid JSON produces a non-blocking error and the action proceeds. Since v2.1.295 you can add "onFailure": "block" to a command or HTTP hook to make those failures block instead.
What does onFailure block do in Claude Code?#
It changes what happens when the hook itself fails: a hook that cannot start, times out, or exits with an unexpected code blocks the action rather than letting it through. A hook that runs and exits 0 still allows the action. In my test, a missing script and a hook that hit its 3-second timeout both blocked a Bash call with a message ending blocking because onFailure is "block".
Which Claude Code version added onFailure?#
v2.1.295, released October 8, 2026. Check yours with claude --version. It applies to command and HTTP hooks, not prompt, agent or MCP tool hooks.
Does Cursor have a fail-closed option for hooks?#
Yes. Set "failClosed": true on the hook in hooks.json. It defaults to false. Cursor permission hooks also block on invalid JSON output even without it.
Can Codex hooks fail closed?#
Not through a documented switch as of October 9, 2026. Exit code 2 and a deny decision block, but the docs describe unsupported output fields, MCP hook errors and managed-hook timeouts as continuing the tool call. Write the guard so its own errors exit 2, and rely on the Codex sandbox and approval mode as the boundary.
Why does exit code 1 not block?#
All four agents reserve exit code 2 for "block" so that an ordinary script failure does not freeze the agent. That is a reasonable default for formatters and loggers and a bad one for security guards, which is why the guard script above converts every unexpected error into exit 2.
Continue Reading#
- Claude Code Hooks Explained - every hook event, matchers, exit codes and eight practical examples
- Claude Code Mods: What They Are and How to Write One - when a mod beats a hook, and the trust question to settle first
- Claude Code Permissions Settings Guide - deny rules that enforce policy without a script
- Permissions, Logs and Rollback for AI Coding Agents - the layers to put around an agent that hooks alone do not cover
- Approve Effects, Not Invocations - why an approved call and the thing that ran are different objects
Sources#
- Claude Code v2.1.295 release notes -
onFailure: "block"for command and HTTP hooks, mod guard fix (October 8, 2026) - Claude Code v2.1.294 release notes - prompt and agent hook fix
- Claude Code hooks reference - exit codes, timeouts, default timeouts, HTTP and MCP tool hook failure handling (fetched October 9, 2026)
- Codex hooks documentation - trust review, failure handling, managed hooks (fetched October 9, 2026)
- Cursor hooks documentation - exit codes,
failClosed, permission hook validation (fetched October 9, 2026) - Gemini CLI hooks overview and hooks reference - exit codes and default timeout
- Gemini CLI hookRunner.ts - plain-text exit code handling and timeouts on
main(read October 9, 2026) - Cursor forum: imported Claude Code plugin hooks fail open - community bug report, September 27, 2026
- r/ClaudeWorkflows: robust Claude Code PreToolUse hook - community thread
- GitGuardian ggshield for AI coding tools - deliberate fail-open design
- Himanshu Shukla: Claude Code hooks as guardrails - practitioner case for failing open
- Jerry PM: Write your first Claude Code mod - mod guard advice
- Test runs: Claude Code 2.1.295,
claude -p --model haiku --allowedTools "Bash(echo:*)", six hook configurations, October 9, 2026
Get the next comparison like this in your inbox
One email a week on Claude Code and the rest of the AI dev stack. Free.
Read next on Cursor
Claude Code Hooks Explained
Hooks give you deterministic control over Claude Code. Auto-format on save, block dangerous commands, run tests before commits, fire desktop notifications. Here's how to set them up.
12 min readClaude Code Mods: What They Are and How to Write One
Claude Code mods are plugins whose JavaScript handlers run inside Claude Code itself. What they can do that hooks and skills cannot, a first mod you can run today, and the trust question you must settle first.
8 min readClaude Code Permissions: settings.json Allow, Deny, Ask
Configure Claude Code permissions in settings.json: allow, deny, and ask rules, scope precedence, tool specifiers, and the headless flags you need for CI.
11 min readNew here? Start with
Technical content at the intersection of AI and development. Building with AI agents, Claude Code, and modern dev tools - then showing you exactly how it works.








