Distinguish hook denial from hook error in PreToolUse output
Problem
When a PreToolUse hook intentionally blocks a command (exit code 2), the output is labeled:
PreToolUse:Bash hook error: [message]
This is misleading — the hook didn't error, it intentionally denied the operation. There's no way to distinguish between "the hook crashed" and "the hook blocked this command by design."
Use case
We use PreToolUse hooks as a CLI governance layer — enforcing read-only AWS access, blocking destructive git operations, preventing production writes, etc. These are intentional policy denials, not errors. The "hook error" label confuses users into thinking something is broken.
Proposed solution
Differentiate the label based on exit code or hook output:
- Exit 2 (blocking denial):
PreToolUse:Bash hook denied: [stderr message] - Non-zero other (actual error):
PreToolUse:Bash hook error: [stderr message]
Alternatively, allow hooks to set a custom label via structured JSON output (e.g., {"label": "BLOCKED", "message": "..."}).
Current workaround
We prefix our stderr messages with BLOCKED: to make intent clear, but the outer "hook error" label still appears and causes confusion.
7 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
+1 on the exit code differentiation.
Two things I'd add from experience:
The label affects model behavior. When the output says "hook error", the model treats it as a malfunction and tries to work around the block. When it sees "denied" or "blocked", it adjusts its approach instead. The rename isn't just cosmetic — it changes how the model responds to the constraint.
Consider exit 3 for "ask user". Beyond block (exit 2) and error (exit 1), there's a useful middle ground: the hook flags an operation as risky but lets the user confirm rather than hard-blocking. Useful for things like force-push to non-main branches — dangerous but sometimes intentional.
Hitting this exact problem. I have a PreToolUse hook that auto-applies edits to harness-protected
~/.claude/files — the hook completes the edit in 27ms, but because the result displays asError:, Claude spends an additional ~8-10 seconds on unnecessary re-verification tool calls it wouldn't make if the message looked like success.Benchmarked it: identical edit takes ~9s when Claude uses Python directly (no hook involved) vs ~17s through the auto-apply hook path. The entire delta is Claude not trusting the "Error:" result. That's extra turn latency and extra compute on both the user's machine and Anthropic's servers for every hook-intercepted tool call — regardless of any other side effects like Claude abandoning tasks.
Would love exit code 3 or any mechanism that says "here's an informational result" without the error framing. It's obvious that it triggers claude to think unnecessarily.
Claude Code 2.1.80 · Linux · bypassPermissions · ~25 custom hooks (3 PreToolUse)Also commented same here: https://github.com/anthropics/claude-code/issues/34713
Related: #37210 — we're seeing the same class of issue where a PreToolUse hook returns a well-formed deny (correct JSON + exit 2) but the tool executes anyway. In our case it's the Edit tool writing to a governance config file despite the deny. Audit log confirms the hook fired and returned deny, but only one evaluation occurred — Claude didn't retry, it just proceeded.
Closing for now — inactive for too long. Please open a new issue if this is still relevant.
That's one way to sort out of eh?
This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.