A Claude Code Hook That Times Out Lets the Command Through. Make It Fail Closed A developer demonstrated that Claude Code's PreToolUse command hooks fail open by default: a hook that times out or exits with an unexpected code does not block the tool call, so a guard meant to stop pushes to main can silently let them through. The walkthrough shows how to build a small Python guard, break it deliberately to observe the bypass, then set the new onFailure: "block" option added in Claude Code 2.1.295 (October 8, 2026) so that a hook which can't start, times out, or exits unexpectedly blocks the action instead. The Claude Code hooks reference https://code.claude.com/docs/en/hooks 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 https://code.claude.com/docs/en/permissions extend-permissions-with-hooks 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 https://code.claude.com/docs/en/changelog , 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 https://git-scm.com 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 https://www.python.org script as .claude/hooks/guard.py : python /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 https://cli.github.com/manual/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 : python 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 https://code.claude.com/docs/en/hooks 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 https://hermes-ide.com , 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 https://github.com/hermes-hq/hermes-ide/releases 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 https://code.claude.com/docs/en/hooks 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 https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches 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 https://code.claude.com/docs/en/changelog 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 https://www.amazon.com/dp/B0GYMFPTWV . 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 https://www.amazon.com/dp/B0GYLH38X4 . If you'd like to run the lab with Claude Code inside Hermes, download it https://hermes-ide.com/download or star it on GitHub https://github.com/hermes-hq/hermes-ide .