exit2fix
v2.0.0
Published
Static + live-canary doctor for Claude Code enforcement hooks that fail open on deny paths
Maintainers
Readme
exit2fix
Your Claude Code security hook probably isn't blocking anything. Here's how to prove it and fix it in one command.
npx exit2fix
npx exit2fix --fix --yesClaude Code's hook exit-code contract is counter-intuitive. Developers ship PreToolUse "security gates" that exit 1 (or crash) on the deny path — and Claude Code lets the action through. The only exit code that blocks on its own is 2.
Claude Code is a product of Anthropic. This project is independent and unaffiliated. Hook behavior can change between Claude Code versions — see version boundaries below and docs/architecture.md.
Docs: CHANGELOG · Contributing · Security · Architecture · Demo recording notes
The exit-code contract
| Exit code | Meaning for most events (PreToolUse, etc.) |
| --- | --- |
| 0 | Success. Proceed. Stdout may be parsed as JSON. |
| 2 | Block. Stderr (or JSON decision reason) is the message. Overrides even a JSON "allow". |
| Anything else (including 1, uncaught exceptions, missing shebang / not executable / exit 127) | Non-blocking error. Action proceeds. A hook-error notice appears in the transcript (Ctrl+O), but nothing is stopped. |
Nuances exit2fix encodes:
- Valid deny JSON on stdout (
permissionDecision: "deny") still blocks even if you alsoexit 1. WorktreeCreateis inverted: any nonzero exit fails creation (soexit 1is fine there).PermissionRequestignores exit 2 — deny via thedecisionJSON object.- Events like
PostToolUse,Setup,SessionStart,Notificationcan never block; deny logic on them is a different bug. - HTTP hooks cannot block via status code — need 2xx + deny JSON.
- Timed-out
PreToolUsecommand hooks do not block. - Version boundaries: as of Claude Code v2.1.214, exit 2 + invalid stdout JSON still blocks. v2.1.248 changed unparseable-stdout handling. Live mode notes when your install is older.
Source: Claude Code hooks docs.
Why Anthropic's hook-linter.sh misses this
The official Check 9 in anthropics/claude-code plugin-dev skill is:
if ! grep -q "exit 0\|exit 2" "$script"; then
echo "⚠️ No explicit exit codes (should exit 0 or 2)"
fiThat is a whole-file substring search. It passes as soon as the string exit 2 appears anywhere — in a comment, an unreachable branch, or next to an exit 1 on the actual deny path. It will not catch the exact bug exit2fix targets, and it is bash-only.
What exit2fix does
- Discovery —
.claude/settings.json,settings.local.json,~/.claude/settings.json, pluginhooks/hooks.json, skill/agent frontmatter, and managed policy settings. - Static analysis — tree-sitter for bash, Python, Node/TS, PowerShell (deny-path exit codes, uncaught crashes, shebang mismatch, mixed allow-JSON, event misuse).
- Registration checks — missing/high timeout on blocking events, HTTP hooks that can't block via status, PermissionRequest exit-2 misuse.
- Tier-1 harness probe (default) — pipes documented stdin JSON; confirms fail-open without an API key.
- Tier-2
--live— scratch project +claude -p --output-format stream-json --verbose; parseshook_responseexit codes. --fix— precise deny-path rewrites + shebang / bashERRtrap; re-probes and prints block confirmed.
Pairs well with claude-hooks-check for hooks.json schema / matcher linting — exit2fix does not duplicate that.
Install
npm install -g exit2fix
# or
npx exit2fixUsage
# Scan current project (static + harness probe)
exit2fix
# JSON / SARIF for CI
exit2fix --json --fail-on CRITICAL
exit2fix --sarif > exit2fix.sarif
# Autofix + re-probe
exit2fix --fix --yes
# Optional live Claude session canary
exit2fix --live
# Static only
exit2fix --no-probeGitHub Action
- uses: Onur45500/exit2fix@v2
with:
path: .
fail-on: CRITICAL
sarif: "true"See action.yml. Pre-commit examples live in examples/.
CI example
- name: exit2fix
run: npx exit2fix --json --fail-on CRITICALConfig (optional) .exit2fix.json
{
"denyKeywords": ["deny", "blocked", "forbidden", "not allowed"],
"failOn": ["CRITICAL"],
"triggers": [
{
"id": "custom-rm",
"events": ["PreToolUse"],
"matchers": ["Bash"],
"stdin": {
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /" }
}
}
]
}Demo
Before: canary deny input → exit 1 → action would proceed.
After: same input → exit 2 → block confirmed.
Want a real terminal GIF later? See docs/demo.md.
Finding severities
| Class | Meaning |
| --- | --- |
| CRITICAL confirmed | Static finding + harness (or live) probe proved fail-open |
| CRITICAL static-only | Suspicious deny path / HTTP policy hook; probe skipped or inconclusive |
| WARN | Timeout, shebang, PermissionRequest, non-blocking event, mixed allow-JSON, set -e risk |
| INFO | Informational / fixed-and-confirmed / version notes |
Development
npm install --legacy-peer-deps
npm test
npm run build
# Optional live integration (needs claude CLI + auth)
EXIT2FIX_LIVE=1 npm testSee CONTRIBUTING.md.
License
MIT — see LICENSE.
