[DOCS] PreToolUse hook contract: timeout and spawn-failure outcomes unstated; "JSON only on exit 0" contradicted by behaviour
Summary
While measuring PreToolUse command-hook behaviour on 2.1.223 I found three small gaps between https://code.claude.com/docs/en/hooks and observed behaviour. All are documentation issues — the underlying fail-open-on-non-2-exit behaviour is documented, intended, and I'm not disputing it.
1. Timeout outcome is not documented for command hooks
The docs describe the timeout behaviour for the HTTP hook analogue but never state what happens when a command hook exceeds its timeout.
Observed: with "timeout": 5 and a hook that sleeps 60s, the hook is killed at the timeout and the tool proceeds. That's consistent with the "non-blocking error" rule, but a reader configuring a policy hook has to infer it. Worth one sentence, because it's the case where someone assumes a stalled gate is a safe gate.
2. Spawn failure / non-executable hook outcome is not documented
If the configured command doesn't exist, or exists but isn't executable, the hook never runs. Observed: the tool proceeds, with a banner:
PreToolUse:Bash hook error
Failed with non-blocking status code: /bin/sh: /path/to/hook.sh: No such file or directory
Again consistent, again not stated. This is the most likely real-world failure (a bad path in settings.json), so it deserves an explicit line.
3. "Claude Code only processes JSON on exit 0" is contradicted by behaviour
The docs state that JSON output is only processed on exit code 0. Observed on 2.1.223: a hook that prints a valid deny payload to stdout and exits 1 still blocks the tool.
#!/bin/sh
printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"policy"}}'
exit 1
→ tool blocked; Claude reports the command was blocked by policy.
Control, same exit code, empty stdout → tool runs. So the deny payload is honoured independently of the exit code, which is more forgiving than the docs promise — but the docs and the behaviour should agree either way.
Why this matters
Third-party policy tools plug into this contract, and the difference between "my gate exited non-zero" and "my gate produced no parseable stdout" determines whether a denial is honoured or silently dropped. Documenting §1 and §2, and reconciling §3, would let integrators reason about their failure paths without measuring them.
Environment
- Claude Code 2.1.223, macOS (Darwin 25.5.0)
- Reproduced in both an interactive TUI session and
-p --output-format stream-json - Method: a sentinel file written by the gated tool; sentinel present ⇒ the tool executed.
permissions.allowpre-approves the tool so the hook is the sole gate.