The Claude Code hooks reference has a sentence worth reading twice: "A timed-out command, http, or mcp_tool hook doesn't block the tool call." A command hook that exits with any code other than 0 or 2 gets the same treatment. The docs call that a "non-blocking error", and the action goes ahead.
Picture the setup the permissions docs suggest for running Bash without prompts: add "Bash" to your allow list and register a PreToolUse hook that rejects the few commands you never want. Then the hook gets a bug. It throws on an input it didn't expect, or it hangs on a slow call. The command it was there to stop runs anyway, and the only trace is a line in the transcript.
Claude Code 2.1.295, released October 8, 2026, added the switch for this. In the changelog's words, onFailure: "block" means "a hook that can't start, times out, or exits with an unexpected code blocks the action instead of letting it through."
You'll build a small guard, break it twice on purpose, watch it fail open, then flip one field and break it again. It all happens in a throwaway repo whose remote sits on your own disk, so a push that gets through costs nothing.
onFailure needs Claude Code 2.1.295 or later, so check first:
claude --version
Then make a repo whose origin is a bare git repository next to it:
mkdir -p ~/code/hook-lab && cd ~/code/hook-lab
git init -q -b main
git init -q --bare ../hook-lab-remote.git
git remote add origin ../hook-lab-remote.git
git commit -q --allow-empty -m "init"
mkdir -p .claude/hooks
Before each attempt below, make a fresh commit. After it, ask the remote what it has:
git commit -q --allow-empty -m "try" # before
git ls-remote origin main # after
If ls-remote prints the same hash as git rev-parse HEAD, the push landed. An older hash, or no output at all, means it didn't.
A guard that times out leaves the gate open.
The hook gets the tool call as JSON on stdin, and for Bash the command sits in tool_input.command. Save this Python script as .claude/hooks/guard.py:
#!/usr/bin/env python3
"""PreToolUse guard for Bash calls."""
import json
import re
import sys
PUSH_MAIN = re.compile(r"\bgit\s+push\b.*\b(main|master)\b")
PUBLIC_REPO = re.compile(r"\bgh\s+repo\s+create\b.*--public\b")
def decide(decision, reason):
out = {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason,
}}
print(json.dumps(out))
sys.exit(0)
data = json.load(sys.stdin)
command = data["tool_input"]["command"]
if PUSH_MAIN.search(command):
decide("deny", "Pushing to main is blocked. Push a branch.")
if PUBLIC_REPO.search(command):
decide("ask", "This creates a PUBLIC GitHub repository.")
sys.exit(0)
A push to main or master gets deny, and Claude sees the reason. A gh repo create with --public gets ask, which "prompts the user to confirm", because sometimes a public repo is exactly what you meant. Anything else exits 0 with no output, so per the docs "the tool call continues through the normal permission flow." The decisions go out as JSON with exit 0, the exit code the docs intend for JSON output, and the only one that still counts as a success once you turn on onFailure below.
Wire it up in .claude/settings.json:
{
"permissions": {
"allow": ["Bash(git push *)"]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py"],
"timeout": 5
}
]
}
]
}
}
Three choices in there are on purpose. The allow rule is the lab's stand-in for "allow Bash, let the hook catch the bad ones." Without it, a guard that fails open would still drop you into Claude Code's normal permission prompt, and you'd catch the push there. The guard still wins over that rule when it works: per the permissions docs, "A blocking hook also takes precedence over allow rules."
args runs python3 directly with no shell, which the docs recommend "whenever the hook references a path placeholder". And timeout is in seconds. The default for a command hook is 600, so a hung guard would hold the call for ten minutes before letting it through. Five seconds is plenty for two regexes.
Test the script before Claude ever calls it:
echo '{"tool_input":{"command":"git push origin main"}}' \
| python3 .claude/hooks/guard.py; echo "exit=$?"
It should print the deny decision as one line of JSON, then exit=0. Swap in git status and it prints only exit=0.
Now start claude in hook-lab, type /hooks to see the guard listed with where it came from, make a commit, and ask Claude to run git push origin main. The guard denies it, Claude gets "Pushing to main is blocked. Push a branch.", and git ls-remote origin main prints nothing. The remote is still empty.
First break: a hang. Add this line right after import sys:
import time; time.sleep(15) # BREAK 1: past the timeout
Make a commit and ask Claude to push again. The guard runs into its 5-second timeout, Claude Code cancels it, and per the docs "The call continues through the normal permission flow." Your allow rule matches, the push runs, and ls-remote now prints the hash of your latest commit.
Second break: a bad exit code. Delete the sleep and put these two lines after import sys instead:
print("guard broke", file=sys.stderr) # BREAK 2
sys.exit(3)
Commit and ask again. Exit 3 is neither 0 nor 2, so it's a non-blocking error: the transcript shows Failed with non-blocking status code: with the first line of stderr, and the push lands again. Exit 3 is standing in for real crashes here. A Python traceback from an input shape you didn't plan for exits 1, and the docs warn that without valid JSON on stdout, "Claude Code treats exit code 1 as a non-blocking error."
Timeout or crash, the default is the same: the call goes through.
Leave BREAK 2 in the script and add one field to the handler:
"timeout": 5,
"onFailure": "block"
Commit and ask Claude to push. This time the call is blocked, and the error includes failed; blocking because onFailure is "block", the wording the docs show for a hook that fails. Swap BREAK 2 for BREAK 1 and try once more: still blocked, and the message says timed out instead of failed. Both times, ls-remote keeps printing the hash from the last push that got through.
The docs count five failures: the hook can't start, exits with a code other than 0 or 2, gets an HTTP error, times out, or prints JSON that can't be parsed or fails validation. Plain-text stdout isn't one. With "block" set, a failure "does what exit code 2 does on that event", which on PreToolUse blocks the tool call.
One of those bites guards that return JSON. A non-0, non-2 exit counts as a failure "even if it printed JSON that allows the action, such as permissionDecision: "allow"." So once you set "block", a guard that prints an allow and then exits 1 blocks everything it was meant to allow. Exit 0 after the JSON, like decide() does.
Delete both breaks, run the pipe test again, and keep onFailure in.
I build Hermes IDE, so weigh this section with that in mind. Hermes runs Claude Code in a real terminal, with its own branch and worktree per task, so nothing about the hook changes. What changes is where you find out the guard wants you.
Commit .claude/settings.json and .claude/hooks/guard.py. A new git worktree holds tracked files only, so a guard kept in .claude/settings.local.json, the project file meant to stay out of git, won't be in the worktree a new task starts in. Committed, it rides along with every task you start with ⌘N (Ctrl+Shift+N on Windows and Linux).
The ⌘N New task sheet in Hermes 2.3.4: Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and the preview of the claude command Hermes will run.
When the guard answers ask on gh repo create --public, Claude Code stops at a permission prompt. A session waiting at a permission prompt is what Hermes's "needs approval" status is for. The attention inbox counts the agents blocked on you, and ⌘I (Ctrl+Shift+I) jumps to the one that has waited longest. You answer in Claude Code's own terminal. Hermes never answers a permission prompt for you and never types into a terminal on its own, so you answer the guard's question yourself. In the lab, answer no: if gh is signed in, yes creates a real public repo on your account.
A deny or a fail-closed block needs nothing from you. Claude gets the reason and carries on, and there's no permission prompt for you to answer.
Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux; 2.3.4 shipped on October 15, 2026. It isn't affiliated with Anthropic or any other agent vendor.
A broken guard now blocks every call its matcher catches. With "matcher": "Bash", a typo in guard.py stops ls too. To shrink that, give each rule its own handler with an if, which takes permission rule syntax and "holds exactly one permission rule":
{
"type": "command",
"if": "Bash(git push *)",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py"],
"timeout": 5,
"onFailure": "block"
}
Add a second handler with "if": "Bash(gh repo create *)". When Claude Code can't tell which commands a Bash input runs, the docs say it runs your hook anyway.
The regexes only catch the obvious spellings. A bare git push while you're on main slips past, and so do git -C and shell aliases. The pattern also denies a branch called fix-main-nav, a false positive in the safe direction. The docs call the if filter best-effort and add: "use the permission system rather than a hook to enforce a hard allow or deny." For main, the lock that doesn't depend on anyone's laptop is a branch protection rule with branch restrictions turned on, where "only users, teams, or apps that have been given permission can push to the protected branch."
onFailure does nothing on Stop, SubagentStop, TaskCompleted and TeammateIdle hooks, or on hooks that set async or asyncRewake. Don't make a guard async. If you install mods, the permissions docs note that a mod handling tool.check can approve a call a PreToolUse hook blocked, unless that hook lives in managed settings. On Windows, 2.1.299 (October 14) fixed PreToolUse hooks with no shell set failing open when Git for Windows isn't installed, so update.
Fail open or fail closed is the question you end up asking about every dependency that can break: an auth check that times out, a rate limiter that's down, a guard hook with a bug. The right answer depends on what a wrong yes costs you, and that trade-off runs through the core building blocks in the System Design Pocket Guide: Fundamentals. It's one of 8 short books in the Pocket Guides series, on agents, RAG, prompts, observability, events, databases and system design: the whole series is here. If you'd like to run the lab with Claude Code inside Hermes, download it or star it on GitHub.