[BUG] Hook error messages shown on every tool call even when hooks exit 0

Status Closed — not planned
Reported on v2.1.76
Maintainer reply None cached
Activity 12 comments · opened Mar 16, 2026 · closed May 16, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

All three hook types (PreToolUse, PostToolUse, UserPromptSubmit) display "hook error" messages in the transcript on every tool call, even when the hook scripts exit 0 for non-matching commands. The hooks function correctly — they format files, validate commits, and block bad inputs — but Claude Code reports them as errors anyway.

The message is just "PreToolUse:Bash hook error" with no further detail. It appears twice per Bash call (once per registered hook) and once per Edit call.

What Should Happen?

Hooks that exit 0 should be silent. "hook error" should only appear for non-zero, non-2 exit codes (per the documentation). Exit 0 = allow, exit 2 = block.

Error Messages/Logs

● Bash(git status -u)
  ⎿  PreToolUse:Bash hook error
  ⎿  PreToolUse:Bash hook error
  ⎿  On branch feat/playground-design-sandbox
     Your branch is up to date with 'origin/feat/playground-design-sandbox'.
     ...

● Update(~/Work/projects/workout/.claude/hooks/session-audit.sh)
  ⎿  Removed 1 line
  ⎿  PostToolUse:Edit hook error

❯ Did you check the files...
  ⎿  UserPromptSubmit hook error

Steps to Reproduce

  1. Create .claude/settings.json with hook configuration:

``json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/validate-commit.sh" }
]
}
]
}
}
``

  1. Create .claude/hooks/validate-commit.sh:

``bash
#!/usr/bin/env bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
echo "$COMMAND" | grep -qE '^git commit' || exit 0
if ! echo "$COMMAND" | grep -qP '#\d+'; then
echo "Commit message must include issue reference (#NN)." >&2
exit 2
fi
exit 0
``

  1. Run any Bash command via Claude Code (e.g., ask it to run git status)
  2. Observe "PreToolUse:Bash hook error" in transcript even though the hook exits 0

Note: The hook correctly blocks git commit without #NN (exit 2) and allows all other commands (exit 0). Manual testing confirms exit 0:

echo '{"tool_input":{"command":"git status"}}' | bash .claude/hooks/validate-commit.sh; echo $?
# outputs: 0

Claude Model

Opus

Is this a regression?

I don't know

Claude Code Version

2.1.76 (Claude Code)

Platform

Anthropic API

Operating System

Other Linux

Terminal/Shell

WSL (Windows Subsystem for Linux)

Additional Information

  • The error appears on every tool call, not just mismatched ones
  • Actions still proceed (hooks are non-blocking when they exit 0) — the error label is cosmetic but noisy
  • All hooks follow the same pattern: early exit 0 for non-matching commands, exit 2 to block
  • jq is installed and functional (tested), though jq --version prints just jq- (no version number) — possibly relevant
  • Removing set -euo pipefail from hooks did not fix the issue
  • Adding explicit exit 0 at end of all scripts did not fix the issue

View original on GitHub ↗

12 Comments

yurukusa · 5 months ago

I run 12+ hooks on WSL and hit this exact pattern. The "hook error" label appears even with exit 0 because Claude Code treats any stderr output from hooks as an error indicator, regardless of exit code.

Two likely culprits in your setup:

1. jq stderr leaking

Your jq --version printing just jq- (no version number) suggests a broken or unusual jq build. It may emit warnings to stderr during normal operation. Fix:

COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)

2. grep -qP stderr on WSL

grep -qP (Perl regex) can emit warnings on some WSL distributions where PCRE support is incomplete. Fix:

if \! echo "$COMMAND" | grep -qP '#\d+' 2>/dev/null; then

Quick diagnostic

Run your hook manually and check if anything goes to stderr:

echo '{"tool_input":{"command":"git status"}}' | bash .claude/hooks/validate-commit.sh 2>/tmp/hook-stderr; cat /tmp/hook-stderr

If that file has any content, that's your error source.

Nuclear option

If you can't find the stderr leak, add this as the first line of every hook:

exec 2>/dev/null

This silences all stderr. Not ideal for debugging, but confirms whether stderr is the cause.

yurukusa · 5 months ago

The stderr explanation above covers the most common cause. Adding one more WSL-specific pattern I've seen produce the same symptom:

CRLF line ending corruption

If your hook scripts were created or edited on Windows (VS Code with CRLF line endings), the \r carriage return causes subtle failures. The shell interprets exit 0\r as exit "0\r" which is non-zero. This can be invisible in manual testing because WSL terminals handle \r silently in some contexts.

Check:

file .claude/hooks/validate-commit.sh
# Should say "ASCII text" not "ASCII text, with CRLF line terminators"

Fix:

sed -i 's/\r$//' .claude/hooks/*.sh

Or prevent it permanently in .gitattributes:

*.sh text eol=lf

Systematic diagnostic

Rather than guessing which hook leaks stderr, capture the exact output Claude Code sees:

echo '{"tool_input":{"command":"git status"}}' | bash .claude/hooks/validate-commit.sh 2>/tmp/hook-stderr
echo "exit=$? stderr=$(xxd /tmp/hook-stderr | head -3)"

The xxd output reveals hidden characters (NUL bytes, \r, BOM markers) that are invisible in normal terminal output.

Since you have hooks on all three event types, any one of them leaking stderr multiplies the visible errors. I'd suggest adding 2>/dev/null to all hooks first to confirm stderr is the cause, then re-enable them one at a time to find the specific culprit.

noether-current · 5 months ago

Thanks for the suggestions. Ran the full diagnostic:

Stderr check — all hooks clean:

echo '{"tool_input":{"command":"git status"}}' | bash .claude/hooks/validate-commit.sh 2>/tmp/stderr
# exit=0, stderr bytes: 0

echo '{"tool_input":{"command":"git status"}}' | bash .claude/hooks/validate-issue.sh 2>/tmp/stderr
# exit=0, stderr bytes: 0

echo '{"is_first_prompt":"false","cwd":"/..."}' | bash .claude/hooks/session-audit.sh 2>/tmp/stderr
# exit=0, stderr bytes: 0

Line endings:

file .claude/hooks/validate-commit.sh
# Bourne-Again shell script, ASCII text executable  ← no CRLF

jq stderr:

COMMAND=$(echo '{"tool_input":{"command":"git status"}}' | jq -r '.tool_input.command // empty' 2>/dev/null)
# Works fine, no stderr

All three hooks produce zero stderr and exit 0 when tested manually. The "hook error" messages appear to originate in Claude Code's hook runner, not in the scripts themselves. This may be a display/reporting bug on the Claude Code side.

noether-current · 5 months ago

@yurukusa

yurukusa · 5 months ago

Great diagnostic work — you've cleanly ruled out stderr leakage, CRLF corruption, and jq issues. With all hooks producing exit 0 and zero stderr bytes when tested manually, this does look like a bug in Claude Code's hook runner itself (misreporting success as error).

Two last things worth checking before concluding it's purely internal:

  1. Timing/timeout: Does Claude Code impose a hook execution timeout? If your hooks take longer under real conditions (network calls, larger stdin payloads) than in manual testing, they might be getting killed and reported as errors.
  2. Hook registration format: Double-check your .claude/settings.json hook definitions match the exact schema — a missing or extra field can cause the runner to flag errors even when the script itself succeeds.

If neither of those applies, this is likely a Claude Code internal issue worth escalating to the Anthropic team with your diagnostic output as evidence.

noether-current · 5 months ago

Followed up on both suggestions @yurukusa:

Timeout: Not the issue. All three hooks execute in ≤6ms (measured via time). Added explicit "timeout" to the PreToolUse hooks to match the pattern on session-audit.sh, but the symptom is unchanged.

Format: Schema looks correct — type, command, and optional timeout fields match the documented spec. No unknown fields.

One additional finding worth noting:

Both PreToolUse hooks (validate-commit.sh and validate-issue.sh) are registered in the same hooks array under a single matcher entry:

"PreToolUse": [
  {
    "matcher": "Bash",
    "hooks": [
      { "type": "command", "command": "bash .claude/hooks/validate-commit.sh" },
      { "type": "command", "command": "bash .claude/hooks/validate-issue.sh" }
    ]
  }
]

Both scripts do INPUT=$(cat) — they read all of stdin. If the hook runner pipes a single stdin stream to all hooks in the array sequentially (rather than replaying the input for each hook), the second hook would get empty stdin. However, I tested both hooks with empty stdin (< /dev/null) and both exit 0 silently — jq -r '... // empty' returns empty string without error when input is empty, and the guard exits 0 cleanly. So this isn't the cause, but worth confirming how the runner handles it.

At this point I'm fairly confident this is a Claude Code runner issue rather than a hook script issue. Happy to provide any additional diagnostic output (raw stdin JSON, exact error message text, Claude Code version) if that would help the Anthropic team reproduce.

yurukusa · 5 months ago

Good catch on the hooks array stdin behavior — you're right that if the runner pipes stdin sequentially rather than replaying it per hook, the second hook would receive empty input. That's worth filing as a separate clarification request since the docs don't specify this.

Given your thorough elimination of timeout, format, and script-side issues, I agree this looks like a runner-side bug. The hook scripts are doing everything correctly.

One workaround in the meantime: you could split the two hooks into separate matcher entries so each gets its own stdin pipe:

"PreToolUse": [
  {
    "matcher": "Bash",
    "hooks": [{ "type": "command", "command": "bash .claude/hooks/validate-commit.sh" }]
  },
  {
    "matcher": "Bash",
    "hooks": [{ "type": "command", "command": "bash .claude/hooks/validate-issue.sh" }]
  }
]

This ensures each hook gets independent stdin. Not a fix for the root cause, but it might unblock you while the runner issue gets addressed.

voronerd · 5 months ago

I get the same behaviour on Linux. I also saw that this issue has been open since Nov 2025 in various filed issues that timed out due to no response.

yurukusa · 5 months ago

@voronerd Thanks for confirming on native Linux. That's a useful data point.

To summarize what we now know:

  • Reproduced on WSL2 (my environment), macOS (OP), and native Linux — this is not OS-specific.
  • Multiple users, different hook configurations — the common factor is Claude Code's hook runner, not the scripts themselves.
  • @noether-current's diagnostic work (zero stderr, exit 0, sub-6ms execution, correct schema) effectively rules out all user-side causes.

My current theory on the root cause: the hook runner likely treats any stderr file descriptor activity (including an empty read or a closed pipe) as an error condition, bypassing the exit code check. This would explain why hooks that are completely clean in manual testing still trigger "hook error" in Claude Code — the runner's error detection path isn't gated on exit != 0.

An alternative possibility is that the runner has a separate code path for "hook completed but produced no stdout" that incorrectly maps to the error display. Since hooks that silently exit 0 (no stdout, no stderr) still show the error, the runner may be interpreting "no output" as a failure.

Either way, the fix would be in the hook runner's result-handling logic: only display "hook error" when the exit code is non-zero (and not 2, which is the documented "block" code).

github-actions[bot] · 3 months ago

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

github-actions[bot] · 1 month 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.