I was copying the rm -rf blocking hook from the official docs into my own settings. It worked. Then, just to be thorough, I took jq off the PATH and ran it again.
Exit code 0. Empty stdout. One line on stderr: jq: command not found. The guard I had put there to stop things had opened the door, silently, because a tool it depended on wasn't installed.
Here is what I'd like to say up front: the onFailure: "block" option added in v2.1.295 does not catch this kind of pass-through. It catches hooks that fail with a non-zero exit. A hook that quietly returns 0 is invisible to it. As an indie developer running my own site on Next.js and Cloudflare Workers, I ran ten failure modes for real and wrote down what each one does, and how I fixed the guard.
The short version: what block catches, and what it doesn't
With "onFailure": "block", a failing PreToolUse hook stops the tool call. Without it, the call goes ahead. The docs count the following as failures.
| Failure | Example | Stopped by onFailure: block? |
|---|---|---|
| Can't start | The script or executable doesn't exist | Yes |
| Exit code other than 0 or 2 | An uncaught exception (1), shell "not found" (127) | Yes (even if it printed an allow JSON) |
| Timeout | The hook reached its timeout | Yes |
| Invalid output | Unparseable JSON, schema violation | Yes |
| HTTP hook: connection error or non-2xx | Your validation server is down | Yes |
| Exit 0 with no complaint | A value failed to load and the empty string flows into a branch | No (it isn't a failure) |
The last row is what this whole article is about. onFailure only works when the hook reports a failure, or when Claude Code can detect one. If your script swallows the error and exits 0, no setting will close the door for you.
Reading the docs' outcome table as a gatekeeper
The docs have a table that combines stdout and exit code. Read as a guard, it comes down to this.
| Stdout | Exit 0 | Exit 2 | Any other exit |
|---|---|---|---|
| JSON that passes validation | Success (the fields decide) | Blocking error (JSON can't override it) | Success. With block set, counts as a failure |
| Unparseable JSON | Non-blocking error | Blocking error (stderr is the reason) | Non-blocking error |
| Plain text, or nothing | Success | Blocking error (stderr is the reason) | Non-blocking error |
The cell that stopped me is the bottom right: "non-blocking error". In Unix, exit 1 is the textbook failure. For a guard, though, exit 1 means "I couldn't decide, so go ahead." The docs say it directly: if the hook is meant to enforce a policy, use exit 2.
Timeouts matter too. The default for command hooks is 600 seconds. A hook that hangs is cancelled without a decision, and the call continues through the normal permission flow. A stalled guard does not stop anything.
Ten failure modes, actually run
Reading wasn't enough for me, so I ran the scripts under ten conditions on a local Linux box (jq 1.6, Node 22). To be clear about scope: this measures what the hook script returns (exit code, stdout, time), not a live Claude Code session. A way to check the session side is in a later section.
First, the docs' example copied as-is, and a version that doesn't swallow failures.
#!/bin/bash
# block-rm-doc.sh — copied from the docs, for comparison
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Destructive command blocked by hook"}}'
else
exit 0
fi#!/bin/bash
# block-rm-strict.sh — if a dependency is missing, die with a non-zero code
set -euo pipefail
command -v jq >/dev/null || { echo "jq not found" >&2; exit 3; }
COMMAND=$(jq -er '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
echo "Destructive command blocked by hook" >&2
exit 2
fi
exit 0And a small runner that prints exit code and elapsed milliseconds per scenario. The "no jq" condition uses an empty PATH containing symlinks to only the commands the scripts need.
#!/bin/bash
# run_matrix.sh — exit code and elapsed ms per scenario
IN_RM='{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/build"}}'
IN_OK='{"tool_name":"Bash","tool_input":{"command":"ls"}}'
NOJQ_PATH=$(mktemp -d)
for b in bash sh cat echo grep env node; do p=$(command -v $b) && ln -s "$p" "$NOJQ_PATH/$b"; done
run() {
local name="$1" input="$2"; shift 2
local s=$(date +%s%N)
out=$(printf '%s' "$input" | "$@" 2>/tmp/hk_err); code=$?
local ms=$(( ($(date +%s%N)-s)/1000000 ))
printf '%-40s exit=%-3s %4sms\n' "$name" "$code" "$ms"
}
run "doc version / rm -rf / jq present" "$IN_RM" ./block-rm-doc.sh
run "doc version / rm -rf / jq missing" "$IN_RM" env PATH="$NOJQ_PATH" ./block-rm-doc.sh
run "strict version / rm -rf / jq missing" "$IN_RM" env PATH="$NOJQ_PATH" ./block-rm-strict.sh
run "missing .js file" "$IN_OK" node ./nothing.js
run "hangs 2s (cut off at 1s)" "$IN_OK" timeout 1 sleep 2Measured results (milliseconds are single runs, so read them for shape, not precision):
| Condition | Exit | Time | Without onFailure | With block |
|---|---|---|---|---|
| doc version / rm -rf / jq present | 0 (deny JSON) | 54 ms | Denied | Denied |
| doc version / ls / jq present | 0 | 27 ms | Allowed | Allowed |
| doc version / rm -rf / jq missing | 0 (no output) | 7 ms | Allowed | Allowed |
| strict version / rm -rf / jq present | 2 | 27 ms | Denied | Denied |
| strict version / ls / jq present | 0 | 27 ms | Allowed | Allowed |
| strict version / rm -rf / jq missing | 3 | 5 ms | Allowed (non-blocking error) | Blocked |
| missing .sh (shell form) | 127 | 3 ms | Allowed | Blocked |
| missing .js (node) | 1 | 43 ms | Allowed | Blocked |
| .js that throws | 1 | 37 ms | Allowed | Blocked |
| hangs 2s (cut off at 1s) | 124 | 1,005 ms | Allowed | Blocked |
The two right-hand columns apply the docs' definitions to the exit codes I measured. The bold row is the one to look at: with jq missing, the doc version returns exit 0 in 7 ms. Fast, quiet, and it lets everything through. Adding block changes nothing.
Why the docs' example fails open
Three steps.
COMMAND=$(jq ...)can't find jq. The assignment fails, but a failed assignment doesn't stop the script because there is noset -e.COMMANDstays empty, soecho "" | grep -q 'rm -rf'is false.- The false branch is
exit 0, which means "no opinion, continue with the normal permission flow." It does so during an outage as well.
What bothers me is that a healthy exit 0 and a broken exit 0 look identical. The docs' example is a minimal illustration, not a hardened guard, and that's fair. I just copied it without noticing.
When a guard stumbles, whether the door swings open or shut is something I decide before it stumbles. That is the decision this article is about.
Fix 1: make the guard fail loudly
Fix the script first. In bash, add set -euo pipefail and check that dependencies exist. The strict version above does that: no jq gives exit 3, a deliberate deny gives exit 2.
In Node, let validation errors throw. An uncaught exception exits 1, which onFailure: "block" will catch.
// .claude/hooks/check-command.js — every failure exits non-zero so onFailure can catch it
let raw = "";
process.stdin.on("data", (c) => (raw += c));
process.stdin.on("end", () => {
const cmd = JSON.parse(raw).tool_input?.command; // malformed input throws → exit 1
if (typeof cmd !== "string") {
throw new Error("tool_input.command is not a string"); // unexpected shape → exit 1
}
if (/\brm\s+-rf\b/.test(cmd)) {
process.stderr.write("Destructive command blocked by hook\n");
process.exit(2); // an intentional deny is 2
}
process.exit(0);
});I fed it four inputs: rm -rf /tmp/x exits 2, ls exits 0, a non-JSON string exits 1, and an object with an empty tool_input exits 1. Deny is 2, unexpected is 1, fine is 0. Three meanings, three codes.
With that in place, onFailure becomes a real setting: add block and unexpected input stops, leave it off and it goes through. The decision lives in config, which is the difference from a script that swallows its own errors.
Fix 2: put onFailure and a short timeout in settings.json
On the settings side, give the guard onFailure and a short timeout.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"],
"timeout": 5,
"onFailure": "block"
}
]
}
]
}
}I chose 5 seconds because the 600-second default means waiting up to ten minutes on a stalled guard. Combined with block, it gives up after 5 seconds and stops. I pass the path through args with ${CLAUDE_PROJECT_DIR}; with args, each element becomes one argument with no quoting, so paths containing spaces don't break.
The decision: which hooks should fail closed
Should every hook get block? I don't think so. For each hook I weigh what I lose if it breaks open against what I lose if it shuts everything.
| Hook's job | On failure | Why |
|---|---|---|
| Deny destructive commands or secret files (PreToolUse) | Fail closed (block) | A silent pass-through costs more than a stop |
| Pre-deploy validation (PreToolUse) | Fail closed (block) | Proceeding without the check is the dangerous path |
| Auto format / lint (PostToolUse) | Fail open (continue) | The edit has already happened; nothing left to stop |
| Logging and notifications | Fail open (continue) | A failed log shouldn't halt work |
| Adding context to prompts (UserPromptSubmit) | Usually open | With block, the prompt itself never arrives |
| Stop, SubagentStop, TaskCompleted | No effect | The docs say the field does nothing there |
| async and asyncRewake hooks | No effect | They run in the background |
Closing has a clear cost: the moment you break a closed guard, every call its matcher covers stops. Break a guard that matches Bash and not even ls runs. So I only ask two things of the guards I close.
- Keep external dependencies to a minimum (drop jq and use Node's standard library, for example).
- Keep a way to fix the config from outside Claude Code. Opening settings.json in an editor recovers a session that has stopped itself.
The more firmly you decide to fail closed, the more you need a plan for when it gets stuck closed.
An audit script: count the guards that fail open
Once the config grows, it's hard to see by eye which hooks are guards and which have onFailure. This script scans settings.json and lists command and http hooks on PreToolUse and UserPromptSubmit that don't have onFailure: "block".
#!/usr/bin/env node
// Usage: node audit-hooks.js .claude/settings.json [...]
const fs = require("fs");
const GATES = ["PreToolUse", "UserPromptSubmit"]; // events where failure can mean "stop"
let open = 0;
for (const file of process.argv.slice(2)) {
let cfg;
try { cfg = JSON.parse(fs.readFileSync(file, "utf8")); }
catch (e) { console.error(`ERROR ${file}: ${e.message}`); process.exitCode = 2; continue; }
for (const ev of GATES) {
for (const group of cfg.hooks?.[ev] ?? []) {
for (const h of group.hooks ?? []) {
if (!["command", "http"].includes(h.type)) continue;
if (h.async || h.asyncRewake) continue; // onFailure has no effect here
if (h.onFailure === "block") continue;
open++;
const target = h.command ? [h.command, ...(h.args ?? [])].join(" ") : h.url;
console.log(`OPEN ${ev}[${group.matcher ?? "*"}] ${target} (timeout=${h.timeout ?? "default"})`);
}
}
}
}
console.log(open ? `\nguards that fail open: ${open}` : "\nno unguarded gates");
process.exitCode = open ? 1 : (process.exitCode ?? 0);I ran it on a sample config with a PreToolUse Bash group, an Edit|Write group and a PostToolUse formatter. It printed two OPEN lines, block-rm-doc.sh and http://localhost:8080/validate, and exited 1. The node guard with block, the async logging hook and the PostToolUse formatter were correctly left out. Pointing it at a file that doesn't exist prints ERROR and exits 2.
It finds guards where you forgot onFailure. It can't find guards that have it but swallow their own errors and exit 0; for those you have to read the script, or add the "jq removed" row to the runner above.
The terminal log (re-run, same exit codes)
The table above came from a single run, so I ran the same runner again later and kept the terminal output verbatim. All ten exit codes matched; only the milliseconds moved by a few to a dozen. That's enough to read the shape.
$ uname -sr; jq --version; node -v
Linux 6.8.0-138-generic
jq-1.6
v22.23.2
$ ./run_matrix.sh
doc版 / rm -rf を含む / jq あり exit=0 48ms stdout={ "hookSpecificOutput": { "hookEventNa
doc版 / ls / jq あり exit=0 29ms stdout= stderr=
doc版 / rm -rf を含む / jq なし exit=0 7ms stdout= stderr=./block-rm-doc.sh: line 3: jq: com
strict版 / rm -rf を含む / jq あり exit=2 27ms stdout= stderr=Destructive command blocked by hoo
strict版 / ls / jq あり exit=0 27ms stdout= stderr=
strict版 / rm -rf を含む / jq なし exit=3 5ms stdout= stderr=jq not found
存在しない .sh(シェル形式) exit=127 4ms stdout= stderr=sh: 1: ./nothing.sh: not found
存在しない .js(node) exit=1 35ms stdout= stderr=node:internal/modules/cjs/loader:1
例外で落ちる .js exit=1 38ms stdout= stderr=[eval]:1throw new Error("boom")^Er
2 秒止まる .sh(timeout 1 秒想定) exit=124 1004ms stdout= stderr=The third line, exit=0 ... 7ms, is the pass-through with jq missing. stderr says command not found, and the exit code is still 0.
Pitfalls
- Exit 1 doesn't stop anything. Without JSON, exit 1 is a non-blocking error. Use exit 2 to deny.
- JSON can't override exit 2. If you exit 2, an allow printed to stdout doesn't change the block.
- Allow JSON plus exit 1 is a failure under block. If you return a decision as JSON, finish with exit 0.
- Watch background processes. If you print a decision and then start a child process, redirect its stdout and stderr. Otherwise the call can be blocked.
onFailureneeds v2.1.295 or later. The docs say so; if versions are mixed on your machines, checkclaude --versionfirst.- Chatty hooks cause a different problem. I wrote about guard output volume in Don't let your verification script's full output flow back through a hook.
Checking it in a real session
Everything above compares script exit codes with the docs' definitions. The session side can be checked with the docs' own procedure.
- Use the settings.json above, but leave
check-command.jsmissing, and start Claude Code. - Ask it to run
lsin Bash. - If the call is stopped and the error contains
failed; blocking because onFailure is "block", block is working. - Remove the
onFailureline and repeat. This timelsruns and only a non-blocking error notice remains.
Once per new guard is enough. I've made it a rule to run a guard in its broken state before trusting it in its working one.
Closing
The smallest thing you can do today is run node audit-hooks.js .claude/settings.json once. For the guards that deny or validate, add onFailure: "block" and a timeout, then read each script to check it doesn't swallow its own failures.
A guard tells you nothing while it works. Which way it falls when it stumbles is the one thing I'd rather decide myself, ahead of time.