Start Debugging

How to Make a Claude Code Hook Fail Closed with onFailure: "block"

Since Claude Code v2.1.295, a command or HTTP hook can set onFailure to "block" so a guard that crashes, times out, cannot start or prints invalid JSON stops the tool call instead of waving it through. Here is the config, what counts as a failure, the events where it does nothing, and the exit-0 trap no setting can catch.

Short answer: add "onFailure": "block" next to type and command (or url) in the hook handler. Since Claude Code v2.1.295, released 2026-10-08, a command or http hook with that field turns any failure into the same outcome as exit code 2: a PreToolUse guard that cannot start, times out, exits with a code other than 0 or 2, or returns JSON that fails validation now blocks the tool call, and a PermissionRequest hook that fails denies the request. Without the field, every one of those failures is a non-blocking error and the action goes ahead. It does not help a script that swallows its own error and exits 0, so pair it with set -euo pipefail and an explicit dependency check.

Why a guard hook used to fail open

Claude Code hooks have a three-way exit code contract: exit 0 means success (stdout is parsed as JSON if it is a single object), exit 2 means “block this”, and anything else is a non-blocking error. The action proceeds and you get a <event> hook error notice showing the first line of stderr.

That last rule is the right default for formatters and notifiers. Nobody wants a broken Slack webhook to stop an edit. It is the wrong default for a security guard. If you wrote a PreToolUse hook to refuse rm -rf /, git push --force or terraform apply against production, every way that hook can break lets the dangerous command through:

In all four cases the agent keeps working and the only trace is a hook error line in the transcript. In a headless run, nobody reads it.

onFailure is the per-hook switch that flips this. Two values exist: "continue" (the current behaviour, and what you get when the field is unset) and "block".

The minimal config

Put the field on the handler object, not on the matcher group:

// .claude/settings.json, Claude Code v2.1.295+
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "node",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
            "timeout": 5,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Three choices in that snippet matter as much as onFailure itself:

HTTP hooks take the same field:

// Claude Code v2.1.295+
{
  "type": "http",
  "url": "http://localhost:8080/hooks/pre-tool-use",
  "timeout": 5,
  "onFailure": "block"
}

For an HTTP hook, a failed connection, a non-2xx status, a timeout, or a 2xx body that is neither empty nor a JSON object all count as failures.

What counts as a failure, verified

I ran each failure mode against Claude Code 2.1.303 with a UserPromptSubmit hook, because a blocked prompt never reaches the model and the result is visible straight from claude -p. The same classification applies to PreToolUse; only the thing being blocked differs.

# Claude Code 2.1.303, macOS. t.json holds one UserPromptSubmit handler.
claude -p "say OK" --settings t.json < /dev/null
HandlerWithout onFailureWith "onFailure": "block"
"command": "exit 1"prompt goes through[exit 1]: failed; blocking because onFailure is "block"
"command": "sleep 10", "timeout": 2prompt goes through[sleep 10]: timed out; blocking because onFailure is "block"
"command": "/nope/guard.sh"prompt goes throughfailed; blocking ... plus /bin/sh: /nope/guard.sh: No such file or directory
script prints {"decision": 5}non-blocking errorfailed; blocking ... plus Hook JSON output validation failed, naming the decision field
script prints {"continue": true} and exits 1JSON is honouredfailed; blocking ...
"type": "http" to a closed portprompt goes throughfailed; blocking ... plus connect ECONNREFUSED 127.0.0.1:9
exit 2 with a reason on stderrblocked with that reasonblocked with that reason
script prints plain text and exits 0text added as contexttext added as context

A few rows deserve a closer look.

Exit 2 is never a “failure”. It already blocks on events that support blocking, and onFailure does not change it. Your intentional deny and your broken-guard deny produce different messages, which is useful when reading transcripts later.

Exit 1 with allow JSON is now a failure. Without onFailure, a hook that prints a valid JSON decision and exits 1 still has its JSON fields applied. With "block", the non-zero exit wins and the action is blocked, even if the JSON said permissionDecision: "allow". The rule to follow: when you return a JSON decision, exit 0. In my test, a script that printed {"decision":"block","reason":"x"} and exited 1 was blocked with reason x, so a JSON block still reads as a block. The JSON is only overruled when it tries to let something through.

Plain text is fine. Stdout that is not a single JSON object is treated as text, not as invalid JSON, and is not a failure. A stray echo in your guard will not start blocking everything.

claude -p still exits 0. The headless process exits 0 even when a hook blocks the prompt. If CI needs to know a guard fired, inspect the output (or use --output-format stream-json) instead of the process exit code.

The trap onFailure cannot close: a script that exits 0 by accident

onFailure only acts on failures Claude Code can see. If the guard script itself fails open and exits 0, Claude Code reads that as “no opinion” and lets the call through. This is the shape of the canonical rm -rf guard most people copy:

#!/bin/bash
# naive.sh: fails open when jq is missing
cmd=$(jq -r '.tool_input.command')
if echo "$cmd" | grep -qE 'rm -rf /'; then
  echo "Blocked: destructive command" >&2
  exit 2
fi
exit 0

On a machine without jq (a fresh CI image, a teammate’s Windows box, a devcontainer that dropped it), the assignment fails, bash keeps going because there is no set -e, $cmd is empty, grep finds nothing, and the script reaches exit 0:

$ echo '{"tool_input":{"command":"rm -rf /"}}' | PATH=/minimal ./naive.sh
./naive.sh: line 2: jq: command not found
$ echo $?
0

Setting onFailure: "block" on this hook changes nothing. The fix belongs in the script: give each outcome a distinct exit code and make every unexpected path exit with something other than 0 or 2.

#!/usr/bin/env bash
# guard.sh, Claude Code 2.1.295+, PreToolUse guard for Bash.
# Exit 0 = no opinion, 2 = deny, anything else = guard is broken.
set -euo pipefail

command -v jq >/dev/null 2>&1 || { echo "guard: jq not found on PATH" >&2; exit 3; }

input=$(cat)
cmd=$(jq -er '.tool_input.command' <<<"$input") \
  || { echo "guard: no tool_input.command in payload" >&2; exit 4; }

if grep -qE '(^|[;&|[:space:]])rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f?[[:space:]]+/([[:space:]]|$)' <<<"$cmd"; then
  echo "Blocked by guard.sh: refusing to rm -rf /" >&2
  exit 2
fi

exit 0

Run against the same inputs:

rm -rf / with jq        -> exit 2  (deny)
rm -rf / without jq     -> exit 3  (broken, onFailure blocks)
ls -la                  -> exit 0  (no opinion)
{"tool_input":{}}       -> exit 4  (broken, onFailure blocks)
not json                -> exit 4  (broken, onFailure blocks)

jq -e matters here: without it, a missing field prints null and exits 0, and the guard would happily test the string null. In Node the equivalent discipline is to not wrap the whole handler in a try/catch that logs and returns. Let malformed input throw; the uncaught exception exits 1, which onFailure: "block" turns into a block.

// check-command.js, Node 22, Claude Code 2.1.295+
const input = JSON.parse(require("fs").readFileSync(0, "utf8")); // throws on bad JSON -> exit 1
const cmd = input.tool_input.command;                             // throws if tool_input missing
if (typeof cmd !== "string") throw new Error("command is not a string");

if (/\bgit\s+push\b.*--force(?!-with-lease)/.test(cmd)) {
  process.stderr.write("Blocked: use --force-with-lease\n");
  process.exit(2);
}
process.exit(0);

Where onFailure does nothing

The docs list two places where the field is ignored:

The field is also only defined for command and http handlers. prompt, agent and mcp_tool handlers do not accept it, which is a good reason not to use an LLM-judged hook as your only security gate.

Which hooks should fail closed

A useful rule: fail closed when the hook runs before an irreversible action and its job is to refuse things. Fail open when the hook runs after the fact or only observes.

HookRecommended onFailureWhy
PreToolUse guard on Bash, Write, deploy tools"block"Its whole purpose is to stop a call.
PermissionRequest auto-approver/denier"block"A failure denies the request, which is the safe direction.
PostToolUse formatter or linterunsetThe edit already happened; blocking only confuses the agent.
Audit log, telemetry, notificationsunsetObservability must not take down the session.
UserPromptSubmitusually unset"block" stops every prompt until the hook is fixed.

The last row deserves emphasis. A fail-closed UserPromptSubmit hook with a missing dependency makes the session unusable, including for asking Claude to fix the hook. Keep fail-closed guards dependency-light, and make sure you can edit .claude/settings.json from outside the session.

Windows: two changes worth knowing

On Windows, shell-form command hooks run through Git Bash. Since v2.1.299, a PreToolUse or PermissionRequest command hook that sets no shell and fails because Git for Windows is not installed blocks the call even without onFailure, with this error:

blocking because Git Bash wasn't found and this hook doesn't set "shell" to "powershell", so only exit code 0 or 2 counts as its verdict.

If you hit that, the documented fixes are to install Git for Windows or set CLAUDE_CODE_GIT_BASH_PATH, switch to exec form with args for a single .exe plus "onFailure": "block", set "shell": "powershell" plus "onFailure": "block" for PowerShell hooks, or set "onFailure": "continue" explicitly when the action really should proceed on failure. That last option is the only reason to ever write "continue" by hand.

The same release also changed plugin hooks: actions a plugin hook can block are now refused, not let through, when the hook cannot be handed them.

Rolling it out without bricking sessions

  1. Upgrade first. Older versions ignore the unknown field: the same exit 1 hook with "onFailure": "block" let the prompt straight through on Claude Code 2.1.197. Below 2.1.295 your "block" is a silent no-op. Check with claude --version.
  2. Harden the scripts with distinct exit codes and dependency checks, as above, before turning on "block". Otherwise you trade “guard silently off” for “agent silently stuck”.
  3. Add a short timeout to every fail-closed handler.
  4. Test the failure path on purpose. Temporarily rename the guard script, ask the agent to run ls, and confirm you see failed; blocking because onFailure is "block". Restore the script and confirm ls runs.
  5. Audit for gaps. A quick check for guards that still fail open:
# Lists PreToolUse/PermissionRequest command or http handlers without onFailure: "block".
jq -r '.hooks | to_entries[]
  | select(.key == "PreToolUse" or .key == "PermissionRequest")
  | .key as $e | .value[] | .matcher as $m | .hooks[]
  | select((.type == "command" or .type == "http") and .onFailure != "block")
  | "\($e) \($m // "*"): \(.command // .url)"' .claude/settings.json

Run it against user, project and managed settings. It cannot find scripts that swallow errors and exit 0; only reading the scripts finds those.

onFailure also composes with the rest of the permission system rather than replacing it. A hook allow still cannot override a deny rule, as covered in why a PreToolUse allow is still blocked by a deny rule, and a fail-closed guard is one layer next to permission modes and network controls, not a substitute for them.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back