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

> Source: <https://dev.to/gabrielanhaia/a-claude-code-hook-that-times-out-lets-the-command-through-make-it-fail-closed-1g5k>
> Published: 2026-10-25 01:03:09+00:00

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