Your Claude Code Guard Hook Fails Open. Here Is How to Make It Fail Closed
EndOfCoding 작성
Most Claude Code safety hooks have a quiet flaw: if the hook itself breaks, the action it was supposed to guard goes ahead. A missing script, a typo in a path, a crash, a timeout. By default each of these is a non-blocking error, which means a guard you wrote to stop dangerous commands silently stops guarding. Claude Code v2.1.295, released October 8, 2026, adds an option that fixes this: onFailure: "block". This tutorial shows what it does, how to set it up, and how to test that your guardrails now fail closed. The same week's reading on prompt-injection attacks against coding agents is why this matters: a guard you can't trust to run is not a guard.
What You'll Learn
How hook exit codes work, what counts as a hook failure, how to add onFailure: "block" to a PreToolUse command hook, how to prove it works with a deliberately broken hook, and where the option does not apply. You will end with a fail-closed guard for Bash commands. The Vibe Coding Ebook covers the wider security playbook for AI-assisted work, and the Academy newsletter tracks releases like this one.
Step 1: Understand the default (fail-open)
A hook is a handler attached to a lifecycle event in a settings file such as .claude/settings.json. For a PreToolUse hook, which runs before a tool call, the exit code decides what happens, according to the Claude Code hooks documentation:
- Exit 0: success. Stdout may carry a JSON decision.
- Exit 2: a blocking error. The tool call is stopped and stderr is shown to Claude as the reason.
- Any other exit code: a non-blocking error. The action proceeds, and a hook error notice appears in the transcript.
That last row is the trap. If your script crashes with exit 1, or the interpreter isn't found, the guarded command runs anyway. Your guard failed, and the only trace is a line in the transcript that is easy to miss.
Step 2: Write a simple guard
Here is a small guard that blocks rm -rf on Bash. Save it as .claude/hooks/check-command.sh and make it executable:
#!/bin/bash
input=$(cat)
command=$(jq -r '.tool_input.command' <<<"$input")
if [[ "$command" == *"rm -rf"* ]]; then
echo "Blocked: rm -rf is not allowed" >&2
exit 2
fi
exit 0
This guard does its job when it runs. Note its dependency on jq: on a machine without jq, the script fails with a non-zero code that is not 2, and under the default behavior the command goes through. That is exactly the situation onFailure is for.
Step 3: Register it with onFailure: "block"
Add the hook to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.sh",
"onFailure": "block"
}
]
}
]
}
}
onFailure accepts "continue" (the default) or "block", and applies to command and http hooks. It requires Claude Code v2.1.295 or later. With "block", each of these now behaves like exit 2 on a PreToolUse event, so the tool call is stopped:
- the script or executable can't be started
- the exit code is anything other than 0 or 2, even if the hook printed an allow decision
- an HTTP hook gets a connection failure or a non-2xx response
- the hook times out
- the output is JSON that can't be parsed or fails schema validation
Step 4: Prove it fails closed
Never trust a guardrail you haven't broken on purpose. Temporarily rename the script, then ask Claude to run a harmless Bash command such as echo hello:
mv .claude/hooks/check-command.sh .claude/hooks/check-command.sh.off
With onFailure: "block", the Bash call should be refused with an error saying the hook failed and the call was blocked because onFailure is "block". Remove the setting and repeat: now echo hello runs, and only a non-blocking error notice appears. Restore the script afterward.
Do the same for a timeout (add sleep 5 with a very short timeout) and for malformed JSON output. If all three block, your guard fails closed.
Step 5: Know where it does not apply
The documentation says onFailure has no effect on Stop, SubagentStop, TaskCompleted and TeammateIdle events, or on async command hooks. A fail-closed setting on those would do nothing, so don't rely on it there. For a PermissionRequest hook, a failure under "block" denies the request.
Step 6: Decide where fail-closed is worth the friction
Blocking on failure means a broken hook stops your work. That is the right trade for guards on destructive commands, secret reads and outbound network calls. It is the wrong trade for convenience hooks such as formatters or notifications, where a failure should not halt a session. Keep "continue" for those.
A sensible split: security-relevant PreToolUse guards get "block"; everything else keeps the default. Also commit the hook scripts and settings to the repo, and make sure every machine that runs the project has the dependencies (jq here) so a fail-closed guard doesn't become a fail-always annoyance.
Conclusion
Fail-open was a reasonable default when hooks were conveniences. Now that teams use them as guardrails against destructive commands and secret exposure, a broken hook needs to stop the action, not wave it through. Upgrade to v2.1.295 or later, add onFailure: "block" to your security guards, and break each one on purpose once to prove it. Five minutes of testing turns a guard you hope works into one you have seen refuse. For the wider picture on hardening AI-assisted workflows, the Vibe Coding Ebook is the place to continue, and the Academy newsletter will flag the next release worth acting on. Related reading: A Malicious Repo Picked Its Own Code Reviewer.
SECOND OPINION · BY VIBE CODING ACADEMY
Your agent says it’s done. What needs checking?
Paste your coding-agent conversation for supported claims, visible problems, and useful next steps. Reviews only the material you provide; no account needed to start.
Review a session