Distinguish hook denial from hook error in PreToolUse output

Status Closed — not planned
Maintainer reply None cached
Activity 7 comments · opened Mar 6, 2026 · closed Apr 18, 2026

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.

View original on GitHub ↗

7 Comments

github-actions[bot] · 7 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/12667
  2. https://github.com/anthropics/claude-code/issues/20157
  3. https://github.com/anthropics/claude-code/issues/17088

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

OZmasterAI · 7 months ago

+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.

DrAlexHarrison · 6 months ago

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 as Error:, 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

joseph-holland · 6 months ago

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.

github-actions[bot] · 6 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

joseph-holland · 5 months ago

That's one way to sort out of eh?

github-actions[bot] · 5 months ago

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.