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

Figure: Editorial context image from the source page (preview only; rights remain with the publisher) · Credit / further reading: dev.to
A hook that silently approves what it was built to block is worse than no hook at all. No hook means you know you're unguarded. A fail-open hook means you believe you're guarded, you write it into the threat model, you tell the auditors, and then on the one request that matters — the one where the filesystem is slow, or the payload is 40 MB, or npx decides to hit the registry — the guard evaporates and the command runs anyway.
This is the bug class Gabriel Anhaia wrote up: a Claude Code PreToolUse hook that exceeds its time budget gets killed, and the tool call proceeds. The hook didn't say yes. It just didn't say no in time. And in a permission system, silence is consent.
The fix is not "make the hook faster." The fix is to make the timeout itself a denial. Below: the mechanism, why the default is what it is, the threat model that makes it unacceptable, five patterns for fail-closed enforcement, and what you pay for them.
The mechanism: where the decision actually gets made
The contract
Claude Code hooks are ordinary processes. For PreToolUse, the runtime serializes the pending tool call to JSON on the hook's stdin — session_id, transcript_path, cwd, tool_name, tool_input — and reads back an exit code, plus optionally a JSON object on stdout. The hook is a pure function in the worst possible environment: cold process, unknown PATH, unknown working directory, no warm caches, and a hard wall-clock budget enforced by the parent.
That last part is the whole problem. The parent has to bound the hook, because a hanging guard would hang the agent. So there's a timer. When the timer expires, the process is killed.
The decision table
Roughly, the semantics are: exit 0 means "no objection, continue" (stdout may be surfaced in the transcript). Exit 2 means "block," and stderr is fed back to the model as the reason. Any other non-zero exit is a non-blocking error — stderr shown to the user, execution continues. Structured output via hookSpecificOutput.permissionDecision (allow / deny / ask) gives you the same three outcomes with a machine-readable reason.
Timeouts are handled on the "non-blocking error" branch. The process didn't produce a decision, so the runtime treats it like a hook that errored out — and a hook that errors out is, by default, a hook you continue past.
The gap
Notice what the decision table is really keyed on: did the hook produce a verdict? Not did the hook run? A crashed hook and an absent hook and a killed hook all collapse into the same bucket. For a hook that adds context — a linter, a formatter, a "here's the git blame for this file" injection — that's correct. For a hook that is the enforcement point for a security policy, it's a hole you can drive a truck through.
Why the default is fail-open

Figure: HEXOPS original illustration inspired by the topic (not a copy of the source article imagery) · Credit / further reading: dev.to
It's a defensible default, and it's worth being honest about why before overriding it. Hooks are user-authored shell scripts. They call npx. They call APIs. They have typos. If every malformed hook blocked every tool call, a single chmod -x would wedge every session in the repo, and the first thing a new user experiences would be Claude Code refusing to do anything with no obvious diagnosis. Fail-open keeps the agent usable and pushes hook bugs into the "annoying warning" category.
The trade-off is: availability of the agent versus authority of the policy. The default picks availability. That's right for augmentation hooks and wrong for authorization hooks, and most teams never make the distinction consciously — they just write a hook called block-dangerous-bash.sh and assume it blocks dangerous bash.
The threat model you didn't write down
Accidental timeouts
The boring case, and the common one. Your hook shells out to npx some-scanner. The registry is slow. Cold cache. 3 seconds becomes 70. Exit 124. Command runs. Nobody notices because the deny path is never exercised in tests — you tested that the hook blocks rm -rf /, not that it blocks rm -rf / when the scanner is offline.
Induced timeouts
The interesting case. Anything that can make your hook slow can make your policy disappear, and the agent itself controls a lot of that input. A Write hook that scans the file content for secrets runs in time proportional to file size — so the bypass is "write a very large file containing the secret at the end." A hook that shells out to git runs in a directory the agent chose. A hook that fetches a policy file over the network has a network dependency the agent's own Bash calls can degrade.
This is the general shape of a resource-exhaustion bypass against a timeout-bounded check, and it's older than any of this: you don't defeat the guard, you starve it.
The audit-hook variant
Worse than the block-bypass: a PreToolUse hook that logs every tool call to an audit sink, but only after appending. Timeout → no log entry → the action happened and the audit trail says it didn't. Now you have a compliance problem on top of a security one.
Note also that PostToolUse cannot save you. By the time it fires, the command has run. There is no rollback for Bash.
Making it fail closed
Rule 1: two nested timeouts, and the inner one always fires first
You cannot control the outer timer. You can guarantee your own process returns before it. Pick an inner budget comfortably below the configured outer timeout, and enforce it yourself:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Write|Edit",
"hooks": [
{ "type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh",
"timeout": 10 }
]
}
]
}
}
Outer budget: 10s. Inner budget: 5s. The 5s of headroom absorbs process spawn, shell startup, and interpreter import — which on a cold cache with antivirus scanning is not nothing. Measure your p99 startup; if it's 400ms, budget for 2s, not 400ms.
Rule 2: catch everything, and deny in the catch
The inner deadline should be enforced inside the hook, so you return a verdict rather than a corpse:
import json, signal, sys
class Deadline(Exception): pass
def _bail(signum, frame): raise Deadline()
signal.signal(signal.SIGALRM, _bail)
signal.alarm(4) # < inner budget from Rule 1
def deny(reason: str) -> None:
print(reason, file=sys.stderr)
sys.exit(2) # see Rule 3
try:
payload = json.load(sys.stdin)
decision = evaluate(payload) # pure, no network, no subprocess
except Deadline:
deny("policy evaluation exceeded 4s budget; failing closed")
except Exception as e:
deny(f"policy check crashed: {e!r}; failing closed")
Three things are doing the work here: the SIGALRM gives you a reasoned denial instead of a kill; the bare except Exception turns every bug into a block; and evaluate() is deliberately pure. A policy hook should never make a network call. If your policy lives remotely, replicate it locally and refresh it out-of-band from a SessionStart hook or CI — never from inside the enforcement path.
Rule 3: use exit 2, not JSON, for the deny path
permissionDecision: deny in a JSON blob works, but it adds a dependency: the wrapper must produce valid JSON on stdout, and the runtime must parse it. A shell wrapper that gets OOM-killed mid



