◉CLAUDE LABJP
●2.1.296 — Subagents can set their own autoCompactWindow, and an env var pins the model used by workflow agents●HAIKU 5.5 — Since 2.1.293 (Oct 7), claude-haiku-5-5 is the default Haiku model on the Anthropic API●SONNET 4.5 — Retires on the Claude API on 11/30, 47 days left. Migrate to Sonnet 5.5●Q&A — A write-up shows three places to look when a Mod draws nothing and reports no error●AGENTS.MD — One write-up explains how to split the entry points when Codex and Claude Code share a folder: AGENTS.md and CLAUDE.md●NEW — Approval prompts for "cd, git add, commit" every time: not a missing setting, but how it works●2.1.296 — Subagents can set their own autoCompactWindow, and an env var pins the model used by workflow agents●HAIKU 5.5 — Since 2.1.293 (Oct 7), claude-haiku-5-5 is the default Haiku model on the Anthropic API●SONNET 4.5 — Retires on the Claude API on 11/30, 47 days left. Migrate to Sonnet 5.5●Q&A — A write-up shows three places to look when a Mod draws nothing and reports no error●AGENTS.MD — One write-up explains how to split the entry points when Codex and Claude Code share a folder: AGENTS.md and CLAUDE.md●NEW — Approval prompts for "cd, git add, commit" every time: not a missing setting, but how it works
Articles/Claude Code
⟐ Claude Code/2026-10-14Advanced

I Set onFailure to block, and the Docs' rm -rf Guard Still Let Everything Through Without jq

When a PreToolUse guard hook fails, does the action go ahead or stop? The docs' own rm -rf example exits 0 and waves everything through when jq is missing. Ten failure modes run for real, the fix, and a script that finds fail-open guards in settings.json.

Claude Code263hooks21onFailurePreToolUse4security19

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.

FailureExampleStopped by onFailure: block?
Can't startThe script or executable doesn't existYes
Exit code other than 0 or 2An uncaught exception (1), shell "not found" (127)Yes (even if it printed an allow JSON)
TimeoutThe hook reached its timeoutYes
Invalid outputUnparseable JSON, schema violationYes
HTTP hook: connection error or non-2xxYour validation server is downYes
Exit 0 with no complaintA value failed to load and the empty string flows into a branchNo (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.

StdoutExit 0Exit 2Any other exit
JSON that passes validationSuccess (the fields decide)Blocking error (JSON can't override it)Success. With block set, counts as a failure
Unparseable JSONNon-blocking errorBlocking error (stderr is the reason)Non-blocking error
Plain text, or nothingSuccessBlocking 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 0

And 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 2

Measured results (milliseconds are single runs, so read them for shape, not precision):

ConditionExitTimeWithout onFailureWith block
doc version / rm -rf / jq present0 (deny JSON)54 msDeniedDenied
doc version / ls / jq present027 msAllowedAllowed
doc version / rm -rf / jq missing0 (no output)7 msAllowedAllowed
strict version / rm -rf / jq present227 msDeniedDenied
strict version / ls / jq present027 msAllowedAllowed
strict version / rm -rf / jq missing35 msAllowed (non-blocking error)Blocked
missing .sh (shell form)1273 msAllowedBlocked
missing .js (node)143 msAllowedBlocked
.js that throws137 msAllowedBlocked
hangs 2s (cut off at 1s)1241,005 msAllowedBlocked

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.

  1. COMMAND=$(jq ...) can't find jq. The assignment fails, but a failed assignment doesn't stop the script because there is no set -e.
  2. COMMAND stays empty, so echo "" | grep -q 'rm -rf' is false.
  3. 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 jobOn failureWhy
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 notificationsFail open (continue)A failed log shouldn't halt work
Adding context to prompts (UserPromptSubmit)Usually openWith block, the prompt itself never arrives
Stop, SubagentStop, TaskCompletedNo effectThe docs say the field does nothing there
async and asyncRewake hooksNo effectThey 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.
  • onFailure needs v2.1.295 or later. The docs say so; if versions are mixed on your machines, check claude --version first.
  • 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.

  1. Use the settings.json above, but leave check-command.js missing, and start Claude Code.
  2. Ask it to run ls in Bash.
  3. If the call is stopped and the error contains failed; blocking because onFailure is "block", block is working.
  4. Remove the onFailure line and repeat. This time ls runs 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Claude Code2026-10-05
I Wrote "I'll Do the Commits" in CLAUDE.md. The Next Morning git log Had One More Line.
When Claude Code commits despite being told not to: undo with git reset --soft, then stop it physically with permissions.deny plus a PreToolUse hook. Covers the git -c form that deny rules let through, and how to test the hook without starting Claude Code.
⟐ Claude Code2026-09-02
Two hooks that record every model switch and stop the ones you never agreed to
Build a PostModelSwitch hook that logs every model change and a PreModelSwitch hook that blocks unapproved switches during unattended runs. Complete scripts, measured timings, and the reason the two jobs must stay separate.
⟐ Claude Code2026-08-28
Don't let your verification script's full output flow back through a hook
The check was working the whole time. The conversation was what ran out of room. Measured side by side: 58 bytes from one scan of 833 files, 42KB from another. Which paths actually reach the model, and how to enforce a budget on them.