[Docs] Hook exit codes: exit(1) silently non-blocking - needs prominent warning
Summary
When writing PreToolUse hooks intended to block tool execution on policy violations, using sys.exit(1) (the conventional Unix error exit code) does not block the tool call. Only exit(2) produces a blocking error.
The current documentation states:
"Any other exit code is a non-blocking error. stderr is shown in verbose mode (Ctrl+O) and execution continues."
This is accurate -- but it buries the trap. Developers writing enforcement hooks will instinctively reach for exit(1). Nothing signals that they've made a mistake: the hook runs, it exits non-zero, stderr output appears if they happen to be in verbose mode. The violation silently proceeds. The hooks appear to enforce -- they don't.
A targeted warning adjacent to the exit code table would eliminate this failure mode entirely without changing any behavior.
Expected Behavior (Common Developer Assumption)
A PreToolUse hook that detects a policy violation and calls sys.exit(1) blocks the tool call, since non-zero conventionally means failure.
Actual Behavior
exit(1) is treated as a non-blocking hook execution error. The tool call proceeds. Stderr output is only visible in verbose mode (Ctrl+O). The hook's enforcement is silently bypassed.
Only exit(2) blocks the tool call and feeds stderr back to Claude as context.
First-Party Evidence
Discovered while operating an autonomous knowledge pipeline with 10 PreToolUse hooks on Claude Code v2.1.92 (Windows 11). Three enforcement hooks intended to enforce build-order rules were using sys.exit(1):
| Hook Script | Intended Enforcement | Exit Code Used |
|-------------|---------------------|----------------|
| check-readme-before-code.py | Block code writes before README exists | sys.exit(1) |
| check-test-before-stack.py | Block stack writes before tests pass | sys.exit(1) |
| check-test-before-next.py | Block advancing without test coverage | sys.exit(1) |
All three correctly detected violations and printed error messages to stderr. But violations proceeded because exit(1) is non-blocking. A fourth hook (check-class-a-gate.py) correctly used sys.exit(2) and successfully blocked write-chain violations.
The behavioral difference is invisible without --debug: the hooks run, they produce output, they exit non-zero. Everything looks correct except the tool call is not blocked.
Fix applied: sys.exit(1) to sys.exit(2) in all three enforcement paths. Enforcement now works as intended.
Note: The exit(2) blocking behavior itself was broken in v2.1.86-v2.1.89 (see #43407) and fixed in v2.1.90. These tests were confirmed on v2.1.92 where exit(2) blocking is working correctly -- the issue here is purely documentation.
Why This Is Easy to Get Wrong
exit(1)is the universal Unix convention for "error." Developers writing enforcement hooks will naturally reach for it.- The hook still runs and produces output -- there is no obvious failure signal.
- The stderr output goes to verbose mode only, hidden behind
Ctrl+O, so the developer may never see the hook fired but did not block. - The current docs state "any other exit code is a non-blocking error" -- accurate, but
exit(1)reads as "other" only if you already know to look for it.
Documentation Suggestion
The hooks reference currently states:
- Exit code 0: Success - Exit code 2: Blocking error (stderr fed back to Claude as context) - Other exit codes: Non-blocking error (shown in verbose mode only)
Two additions would prevent this mistake:
1. Warning callout near the exit code section
Warning: Only exit code 2 blocks tool execution. Exit code 1 and all other non-zero codes are treated as non-blocking errors -- the tool call will proceed. If your hook is intended to prevent an action, you must useexit(2), notexit(1).
2. Exit code behavior table (prominent placement)
| Exit Code | Hook Behavior | Stderr Handling | Use Case |
|-----------|--------------|-----------------|----------|
| 0 | Allow -- tool proceeds | Not displayed | Hook passes validation |
| 2 | Block -- tool call prevented | Fed to Claude as context | Enforcement hooks |
| 1, 3-255 | Non-blocking error -- tool proceeds | Verbose mode only (Ctrl+O) | Diagnostics, logging |
Environment
- Claude Code v2.1.92 (behavior confirmed through v2.1.92)
- Windows 11 Home (10.0.26200)
- Shell: bash (Git Bash / MINGW64)
- Hook event: PreToolUse (Write and Edit matchers)
- Hook language: Python 3.14.3, using
sys.exit()
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗