DEV Community

Cover image for A Claude Code Hook That Times Out Lets the Command Through. Make It Fail Closed
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

A Claude Code Hook That Times Out Lets the Command Through. Make It Fail Closed


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.

Set up a lab you can break

onFailure needs Claude Code 2.1.295 or later, so check first:

claude --version
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 security checkpoint gate swung wide open while its guard sleeps on a stool, an orange arrow passing straight through
A guard that times out leaves the gate open.

The guard: deny pushes to main, ask before a public repo

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)
Enter fullscreen mode Exit fullscreen mode

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
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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=$?"
Enter fullscreen mode Exit fullscreen mode

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.

Break it and watch it fail open

First break: a hang. Add this line right after import sys:

import time; time.sleep(15)  # BREAK 1: past the timeout
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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."

A robot security guard slumped asleep at a turnstile while a stream of small orange boxes rolls past it unchecked
Timeout or crash, the default is the same: the call goes through.

Flip onFailure and break it again

Leave BREAK 2 in the script and add one field to the handler:

"timeout": 5,
"onFailure": "block"
Enter fullscreen mode Exit fullscreen mode

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.

Run the guarded agent in Hermes IDE

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 Hermes ⌘N New task sheet with a refund-endpoint task typed, chips for Claude Code, the orders-api project, a new hermes/ worktree, Accept edits, model and effort, and the Hermes will run preview of the claude command
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.

What fail closed costs you

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"
}
Enter fullscreen mode Exit fullscreen mode

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.


If this was useful

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.

System Design Pocket Guide: Fundamentals — Core Building Blocks for Scalable Systems

Top comments (3)

Collapse
 
stefan_07d23f966225156b50 profile image
Stefan •

Checking the remote commit hash is the best part of this lab. I'd want that in the regression test too: deliberately crash the guard, then check whether the push actually landed. A transcript saying the hook failed doesn't answer the question that matters.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The part I would keep is the distinction between a guard that lives on my laptop and a lock that lives where the work actually lands. I have relied on a local check before and watched it behave differently on another machine, which is why your branch protection point lands for me: the hook is a useful early warning, but it cannot be the only owner of a rule about main. If the hook fails, I want the failure to stop me, not quietly pass me through. Where do you draw the line in your own setup between a local guard that is allowed to be best-effort and a server-side rule that must never bend?

Collapse
 
galagent profile image
Qingfeng •

Open item for your lab: the plain git push gap. Your PUSH_MAIN regex needs the literal main or master in the command string, but git push with no refspec pushes the current branch, and git push origin HEAD pushes to whatever origin maps HEAD to. Run the lab from a branch called main and neither matches: the denied action goes through with the hook exiting 0, no timeout required.

That is the general trap with command-text guards. They match the shapes you imagined, and the shell has more ways to say the same thing than a regex has branches. Where I can, I check the effect instead of the text: resolve what the command will actually touch (target ref, destination, path) and decide from that. Same reason I gate on reversibility rather than on action count -- a push to a scratch remote is cheap to undo, a push to the repo everyone pulls from is not, so the approval tier should follow the blast radius, not the verb.

onFailure: "block" is the right primitive though, and I would extend it to unparseable output, not just crash and timeout. A guard that exits 0 with malformed JSON is the one that fails open quietly, and it is the easiest of the three to ship by accident.

I wrote up the permission-tier side of this (sandbox by default, stage-and-promote for shared state, one diff per write) if it is useful context: dev.to/galagent/give-your-agent-pe...