Claude Code Hooks Fail Closed: onFailure "block" Tutorial for Guard Hooks

Introduction

A guard hook that is supposed to block dangerous tool calls only protects you when the hook itself works. If your PreToolUse guard crashes, times out, or points at a script path that no longer exists, Claude Code’s default behavior is to let the tool call through. That is the fail-open trap, and until October 2026 there was no built-in way to close it. This tutorial is for developers who use Claude Code hooks as policy gates against force pushes, secret leaks, or risky deploys. You will learn how to flip individual command and HTTP hooks to fail closed with onFailure: "block", how to set sane timeouts so a hung guard cannot stall your session, and how the other major coding agents handle the same problem.

Quick answer

Add "onFailure": "block" to each command or HTTP hook handler, plus a short "timeout". Requires Claude Code v2.1.295 or later. A hook that cannot start, times out, or exits unexpectedly then blocks the action.

Why guard hooks used to fail open: the evidence

The scale of the problem starts with how many people rely on hooks. The anthropics/claude-code repository had 151,274 GitHub stars as of October 17, 2026 (checked via the GitHub API the same morning), which makes Claude Code one of the most adopted coding agents in the world. Every one of those installations that runs a PreToolUse guard as a security gate was exposed to the same failure semantics: the official hooks reference states that a hook exiting with any code other than 2, failing to start, or printing invalid JSON is a non-blocking error, and the action goes ahead. A mistyped script path produced exit code 127, which the runner treated exactly like a quiet success, and the transcript showed only a small notice while the dangerous command ran.

The second number that matters is the default timeout. Command, HTTP, and MCP tool hooks default to 600 seconds before Claude Code cancels them, far too long for a hot-path guard, and a timed-out hook does not block the tool call: it is canceled and the call continues through the normal permission flow. The official guidance for this new field is direct: “To block the action when the hook can’t start, set onFailure: "block".” That one line, from Anthropic’s own hooks reference, is the fix this tutorial puts into practice.

If you are comparing tools before committing, the cross-agent picture was mapped in an October 9 comparison guide that tested the same failure modes across four agents. Cursor already had a failClosed boolean on each hook (fail-open by default, with permission hooks blocking on malformed output even when it is false). Gemini CLI’s official hooks reference documents exit 0 as success, exit 2 as a system block, and any other code as a warning where the interaction proceeds, with no documented fail-closed switch. Codex documents no fail-closed switch for hooks, and the comparison found its documented PreToolUse failure paths continue the tool call. Claude Code’s October 8 v2.1.295 release closed the gap for command and HTTP hooks. The sections below cite the official Claude Code hooks reference for the onFailure field and timeout defaults, the official Cursor hooks documentation for the failClosed option, and the Gemini CLI hooks reference for its exit-code contract.

Prerequisites

  • Claude Code v2.1.295 or later installed (run claude --version; use claude update on the native installer if you are behind).
  • A project with a .claude/settings.json file, or a user-level ~/.claude/settings.json.
  • jq installed and on your PATH (the guard scripts below parse hook input from stdin).
  • Familiarity with the basic hooks shape: event, matcher group, and handler list. If you need a refresher on where hooks live and how they fire in subagents, see the Claude Code subagent effort levels tutorial, which covers the agent contexts hooks run in.

Step 1, Check your version and confirm the field exists

Run claude --version and confirm you are on 2.1.295 or later. The onFailure field does not exist in older versions, and an older CLI silently ignores unknown fields, which would leave your hooks fail-open while you believe they are closed. That silent-ignore behavior is the first thing this tutorial fixes by making you verify the version before editing anything. If the update command is available on your installer, run claude update and check again.

Step 2, Write a guard that fails loudly

Create .claude/hooks/no-force-push.sh in your project and make it executable with chmod +x. This guard refuses force pushes, a classic irreversible action worth gating:

#!/bin/bash
# .claude/hooks/no-force-push.sh
set -euo pipefail
input=$(cat)
command=$(jq -r '.tool_input.command // ""' <<<"$input")
if grep -Eq 'git push.*(--force|-f( |$))' <<<"$command"; then
  echo "Force push blocked by project hook" >&2
  exit 2
fi
exit 0

The set -euo pipefail line is load-bearing. Without it, a machine missing jq produces an empty command, grep finds nothing, and the script exits 0. That is a guard that runs “successfully” while checking nothing. With it, a missing jq makes the script exit with an error code, and that is exactly the class of failure onFailure: "block" converts into a blocked action. stderr carries your log and rejection text; keep stdout free of anything except the final JSON decision, because non-JSON output counts as a hook failure.

Step 3, Register the hook with onFailure block and a short timeout

Add the hook to .claude/settings.json in your project (shared, committable) or ~/.claude/settings.json (personal). The handler sits at the third nesting level, and onFailure goes next to type, command, and timeout:

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-force-push.sh",
            "timeout": 5,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Two choices here are deliberate. The timeout of 5 seconds replaces the 600-second default, because a guard that has not answered in 5 seconds is either broken or doing work that does not belong on the hot path. And onFailure: "block" changes the failure contract for this handler: if the script cannot start, times out, or exits with an unexpected code, the tool call is blocked instead of proceeding. Confirm the hook is loaded with the /hooks command inside a Claude Code session; a hook that is not registered protects nothing.

Step 4, Prove the trap is closed: the missing-script test

The official hooks reference documents a test for exactly this, and it is worth running yourself before trusting the config. Temporarily rename the script so the configured path does not exist, then ask Claude to run any Bash command, such as ls.

Without onFailure set, the same missing script is a non-blocking error: the transcript shows a hook error notice and ls runs anyway. With "onFailure": "block" on the handler, Claude Code blocks the call instead, and the error includes failed; blocking because onFailure is "block" followed by the shell’s own error output (exit 127 for the missing file). Restore the script path afterward and re-run /hooks to confirm the handler is healthy again.

Step 5, Prove the timeout path is closed too

A guard that hangs is the failure mode onFailure alone does not cover without a timeout, because the 600-second default means “hang for ten minutes, then fail open”. Replace your guard temporarily with a stub that sleeps longer than the configured timeout:

#!/bin/bash
# timeout-stub.sh: simulates a hung guard
sleep 30
exit 0

Point the hook at this stub with "timeout": 5 and "onFailure": "block", then trigger a Bash call. After 5 seconds the hook is canceled, and the transcript message says timed out instead of failed. The tool call is blocked. Without the field, the same hang would have produced a canceled hook and a call proceeding through the normal permission flow, which the docs explicitly warn against relying on: do not count on a stalled hook to act as a gate. This test is also why the timeout and onFailure belong together on every policy hook. One sets the budget, the other sets the consequence of exceeding it.

Step 6, Catch the silent exit-0 trap that onFailure cannot fix

There is one failure onFailure: "block" cannot catch, and the October comparison guide demonstrated it directly: a guard that succeeds while checking nothing. Take the script from Step 2 and remove the set -euo pipefail line, then run it on a machine without jq. The script reads stdin, jq is not found, command ends up empty, the regex never matches, and the script exits 0. From Claude Code’s point of view the hook succeeded, so there is no failure for onFailure to convert into a block. rm -rf would run.

Fix this in the script, not the config. Add an explicit dependency check at the top:

if ! command -v jq &>/dev/null; then
  echo "no-force-push: jq not installed, failing closed" >&2
  exit 2
fi

Exiting 2 is a deliberate block decision, so the guard fails closed by its own logic even on a machine where nothing else is configured. The layered rule is: the script turns its own detectable errors into exit 2, and onFailure: "block" catches everything the script cannot detect about itself, such as a missing file, a hang, or a crash. For teams, this script-level discipline pairs well with shareable project hooks, which the Claude Code projects tutorial covers as part of a team-ready repository setup.

Step 7, Apply fail-closed selectively, not globally

Not every hook deserves to block. The decision table that keeps coming back in practice:

  • Fail closed (onFailure: "block"): hooks that enforce policy on irreversible side effects, such as blocking force pushes, secret-bearing file reads, production deploys, or destructive shell commands.
  • Fail open (leave unset): observability, audit logging, formatting, and context-injection hooks. A crashed formatter must never be able to stop your work, and a lost additionalContext from a timed-out UserPromptSubmit hook is degradation, not an incident.
  • Never a security gate: prompt, agent, and MCP tool hooks, because onFailure is only honored on command and HTTP handlers. The Claude Code MCP setup tutorial shows how MCP servers plug into the tool layer, which is a separate trust boundary from hooks.

HTTP hooks follow the same rule with status codes instead of exit codes: 2xx with an empty body is success, 2xx with a JSON body is parsed as a decision, and a non-2xx status or connection failure is a non-blocking error unless you set onFailure: "block".

Troubleshooting

1. I added onFailure: block but the hook still lets everything through

The most likely cause is the CLI version. onFailure requires v2.1.295 or later, and older versions silently ignore the unknown field, so your settings look correct while the runtime behaves fail-open. Run claude --version, then claude update on the native installer, and re-run the missing-script test from Step 4 to confirm the block message appears.

2. onFailure has no effect on my prompt hook

That is by design. The hooks reference documents onFailure only on command and HTTP hook handlers. A prompt hook that times out has its output discarded and the prompt still reaches the model, which is a context loss, not a block. If you were using a prompt hook as a security gate, move the policy into a PreToolUse command hook with onFailure: "block".

3. Every tool call is suddenly blocked after I enabled fail-closed

Your guard is probably exiting non-zero on benign input. The classic case is set -euo pipefail combined with a missing dependency: without jq, the script errors before it can decide anything, and onFailure: "block" dutifully blocks the call. Add the dependency check from Step 6 so the failure is explicit, or install jq on the machines where the hook runs. Check the transcript for the hook error notice naming the hook and the exit code.

4. The hook reports timed out even though the script works fine

Your timeout is shorter than the script’s normal runtime, or the script does expensive work on the hot path. Measure it with time outside Claude Code, then set the timeout with headroom, or move the heavy check to a PostToolUse hook. Note that UserPromptSubmit lowers the command hook default to 30 seconds and SessionEnd hooks share a 1.5-second budget.

5. My JSON decision is ignored and the hook counts as failed

Stdout pollution. Command hooks must print only the final JSON decision to stdout; any extra echo, progress line, or stack trace breaks parsing and counts as a hook failure, which under onFailure: "block" becomes a blocked action. Move all logging to stderr, which Claude Code captures but never parses as a decision. This applies to the block path too: exit 2 with the reason on stderr is the correct shape.

FAQ

What exactly counts as a hook failure for onFailure?

A hook fails when it exits with any code other than 2, cannot start (for example a mistyped script path), prints invalid JSON, or exceeds its timeout. With onFailure set to "block", each of these failures blocks the action that triggered the hook instead of letting it through.

Does onFailure: block work on prompt, agent, or MCP tool hooks?

No. The official hooks reference documents onFailure only for command and HTTP hook handlers. Prompt, agent, and MCP tool hooks do not honor it, so do not use those hook types as security gates.

Is exit code 2 still needed if I use onFailure: block?

Yes. Exit code 2 is how your script issues a deliberate block decision. onFailure: "block" only changes what happens when the hook itself fails: it cannot start, times out, or exits with an unexpected code. A healthy hook still blocks by exiting 2.

Does onFailure: block slow down Claude Code?

No. It adds no latency of its own. The hook’s own runtime is the cost, and that is controlled by the timeout field. Keep guard hooks under a few hundred milliseconds and move expensive checks out of the PreToolUse hot path.

Should I set onFailure: block on every hook?

No. Put it on hooks that enforce policy, such as blocking force pushes, secret leaks, or production deploys. Leave observability, formatting, and context-injection hooks fail-open, because a crashed logger should never be able to stop your work.

How do Cursor, Codex, and Gemini CLI handle hook failures?

Cursor offers a failClosed boolean on each hook with the same fail-open default. Gemini CLI treats any exit code other than 0 or 2 as a warning and lets the action proceed, with no documented fail-closed switch. Codex documents no fail-closed switch either, so its hooks are best treated as guardrails rather than enforcement.

Conclusion

Before v2.1.295, a Claude Code guard hook was a contract with a hole in it: it blocked exactly the actions it was healthy enough to inspect. onFailure: "block" plus a short timeout closes that hole for command and HTTP hooks, and the layered script discipline from Step 6 covers the one failure config cannot see. Audit your existing hooks today: any handler that enforces policy gets both fields, everything else stays fail-open on purpose. For the next layer of the stack, the Claude Code mods tutorial shows how in-process mods add their own interception points, which compose with, rather than replace, the fail-closed hooks you just built.

Frequently asked questions

What exactly counts as a hook failure for onFailure?

A hook fails when it exits with any code other than 2, cannot start (for example a mistyped script path), prints invalid JSON, or exceeds its timeout. With onFailure set to "block", each of these failures blocks the action that triggered the hook instead of letting it through.

Does onFailure: block work on prompt, agent, or MCP tool hooks?

No. The official hooks reference documents onFailure only for command and HTTP hook handlers. Prompt, agent, and MCP tool hooks do not honor it, so do not use those hook types as security gates.

Is exit code 2 still needed if I use onFailure: block?

Yes. Exit code 2 is how your script issues a deliberate block decision. onFailure: block only changes what happens when the hook itself fails: it cannot start, times out, or exits with an unexpected code. A healthy hook still blocks by exiting 2.

Does onFailure: block slow down Claude Code?

No. It adds no latency of its own. The hook's own runtime is the cost, and that is controlled by the timeout field. Keep guard hooks under a few hundred milliseconds and move expensive checks out of the PreToolUse hot path.

Should I set onFailure: block on every hook?

No. Put it on hooks that enforce policy, such as blocking force pushes, secret leaks, or production deploys. Leave observability, formatting, and context-injection hooks fail-open, because a crashed logger should never be able to stop your work.

How do Cursor, Codex, and Gemini CLI handle hook failures?

Cursor offers a failClosed boolean on each hook with the same fail-open default. Gemini CLI treats any exit code other than 0 or 2 as a warning and lets the action proceed, with no documented fail-closed switch. Codex documents no fail-closed switch either, so its hooks are best treated as guardrails rather than enforcement.

TP
ToolPilot Team

We install, break, and fix automation tools so you don't have to. Every tutorial on ToolPilot is tested hands-on before publishing.

Keep reading