Claude Code hooks fail open on timeout — the escape hatch the official spec documents in the very mechanism meant to enforce
Introduction
I have written before about building, into an AI coding assistant, not “verbal advice” but “a gate that always stops things before they run”. That gate’s mechanism uses what Claude Code provides as “hooks” — a facility that inserts an inspection program of your own, without fail, before or after particular actions (editing a file, running a command, and so on).
This time I read the official primary source on hooks right through (3,409 lines). The aim was not to look for new features but to corroborate, in the official wording, the very design I had assumed “is a forcing layer, so it works 100% of the time”. The corroboration half succeeded, and half took an assumption of mine apart.
Multiple gates all run at once
First I confirmed the behaviour when several hooks are configured for the same situation.
All matching hooks run in parallel.
Hooks that match the condition run all at once, not in a queue. Precedence when their decisions differ is defined too.
When multiple PreToolUse hooks return different decisions, precedence is
deny>defer>ask>allow.
Deny is strongest, then defer, then ask, then allow. If even one of several gates returns “deny”, it stops regardless of how the others decided. So far, the premise I had designed to was stated officially as well.
The “only fires under this condition” setting fires more widely than expected
A hook can carry a condition (if) saying “only run for this command”. The official documentation shows the scope of that condition with concrete examples.
| Condition set | The actual command | Does it fire | Why |
|---|---|---|---|
Bash(git *) | npm test && git push | Yes | When several commands are chained, each is judged individually |
Bash(rm *) | echo $(rm -rf /) | Yes | Commands hidden inside $() or backticks are also in scope |
Even when you think you have narrowed a condition to “only for the git command”, another command chained with &&, or one hidden inside a variable expansion, comes into scope. The gap runs in this direction: you write the condition narrowly, and it fires on command strings you never anticipated.
The main finding: a slow response means the gate never existed
Here is the heart of it. A hook can carry a limit on execution time (a timeout). What happens on reaching that limit is defined officially as follows.
A
command,http, ormcp_toolhook that reaches its timeout is canceled: Claude Code discards the hook’s output, and the hook renders no decision.A timed-out
command,http, ormcp_toolhook doesn’t block the tool call. The call continues through the normal permission flow, so don’t count on a stalled hook to act as a gate.
If a hook cannot answer within the time limit, its verdict is treated not as “deny” but as “said nothing”, and the action goes through. For a design I had been calling a forcing layer, that demanded a fundamental revisiting of premises. I had thought a forcing layer “stops things 100% of the time regardless of what Claude judges”; the official text states plainly that “if the hook’s response does not make it in time, it passes straight through”. The enforcement mechanism itself had another variable entangled in it: response speed.
The official documentation names the alternative in the same place.
Because the
iffilter is best-effort, use the permission system rather than a hook to enforce a hard allow or deny.
A hook with a conditional filter is a best-effort mechanism, not reliable enforcement. If you want something reliably stopped, the positioning is that you use a deny rule in the permission system, not a hook.
Another quiet hole — a typo disables the gate with no notification
Beyond timeouts, there was a hole of the same “passes through unnoticed” shape by another route.
A hook that can’t start lands in the same non-blocking bucket. When the script path doesn’t exist or isn’t executable, the shell exits with a code like 127 and you see the same notice with the interpreter’s message … When you set up a policy hook, watch for this notice on its first run: a mistyped path in
settings.jsonleaves the gate silently disabled.
Mistype the hook’s path (where the program lives) by a single character in the settings file and an error appears, but the action itself keeps running unstopped. Whether you catch the typo hangs solely on whether you were watching for that notice on its first run.
Measured — is my own configuration in this hole
After reading the official text, I checked the actual configuration.
[実測] 実行前に発火するフック(PreToolUse)の設定一覧を確認
対象: 実行前チェックの全ハンドラ
type: すべて "command"(プログラムを直接起動する形式)
timeout: すべて 20 秒 に設定
⇒ 応答に20秒以上かかるフックが1つでもあれば、
そのフックだけ「何も言っていない」扱いになり、他の判定に委ねられる
What bites here is what the default for that limit is. Another official document (the hook definitions on the development-kit side) states the default when the value is omitted.
Timeout in seconds. When omitted, the per-event default applies: 600 for most events, 30 for
UserPromptSubmit
That is, specify nothing and it is 600 seconds; I had set it to 20. I had, with my own hands, moved the condition for opening the fail-open hole to a setting thirty times more likely to fire than the default. And this was not an accident but a deliberate choice — while waiting on a hook’s response, the reply to my own input is stalled. The value of 20 seconds was chosen to bound that wait. I had traded enforcement for waiting time, and only the side I paid was written down nowhere.
Whether 20 seconds has ever actually been reached I left unconfirmed at this point. I did not change the setting on the spot; I first settled the fact alone — that the forcing layer is not 100%, and carries another variable in response speed.
The route the finding took
start: corroborate the premise "the pre-execution gate (hook) works 100% of the time" against the full official text
│ (vertical = order read, horizontal = point checked, nesting = its breakdown)
├─▶ confirm parallel execution of multiple hooks and their precedence
│ └─ ✅ as premised (deny takes highest precedence) ──────┐
│ │ the premise
├─▶ confirm the firing scope of the conditional filter (if) │ holds only
│ └─ ⚠️ fires more widely than assumed │ halfway
│ (chained commands and variable expansions in scope) │
│ │
├─▶ confirm behaviour on reaching the timeout ★ the premise breaks │
│ └─ ⛔ not "deny" but "passes through in silence" │
│ ├─ the alternative the official docs name: │
│ │ a deny rule in the permission system │
│ └─ measure my own configuration ── all handlers 20 s│
│ │ │
│ └─▶ so what is the default? ── 600 s, in another document
│ │ │
│ └──(return edge)──▶ re-read it not as "a hole opened by accident"
│ but as "a hole traded for waiting time"
│
└─▶ another quiet hole — a mistyped path
└─ an error appears but the action does not stop (all you have is catching the notice)
│ │
└───────────────┬───────────────────────────┘
▼
both holes have the same shape: work proceeds
without your being told that nothing stopped
What it means to revisit a forcing layer’s design
I have written elsewhere about a different kind of defect actually hit — a gate of my own reacting mistakenly to my own input. What I found this time is of a different kind: even when each hook’s logic is correct, one factor outside the design — response time — can make the whole gate as though it never existed. That is a limit in the skeleton of the forcing layer itself.
A design document that says “always stops it” and an official implementation that says “passes through on timeout” are both correct. But the former is the ideal and the latter is the actual behaviour. Even after building a forcing layer, whether the gate is effective depends on whether the person who implemented it grasps the limits the enforcement mechanism itself carries.
A gate makes no sound when it is breached. I remembered writing the number 20, but not that it was a thirtieth of the default, nor what it was traded for — one line beside the setting saying “what I gave up to choose this value” would have meant not needing a full re-read to notice.