[BUG] Hook error messages shown on every tool call even when hooks exit 0
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
- Create
.claude/settings.jsonwith hook configuration:
``json``
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash .claude/hooks/validate-commit.sh" }
]
}
]
}
}
- 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
- Run any Bash command via Claude Code (e.g., ask it to run
git status) - 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 0for non-matching commands,exit 2to block jqis installed and functional (tested), thoughjq --versionprints justjq-(no version number) — possibly relevant- Removing
set -euo pipefailfrom hooks did not fix the issue - Adding explicit
exit 0at end of all scripts did not fix the issue
12 Comments
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.
jqstderr leakingYour
jq --versionprinting justjq-(no version number) suggests a broken or unusual jq build. It may emit warnings to stderr during normal operation. Fix:2.
grep -qPstderr on WSLgrep -qP(Perl regex) can emit warnings on some WSL distributions where PCRE support is incomplete. Fix:Quick diagnostic
Run your hook manually and check if anything goes to 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:
This silences all stderr. Not ideal for debugging, but confirms whether stderr is the cause.
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
\rcarriage return causes subtle failures. The shell interpretsexit 0\rasexit "0\r"which is non-zero. This can be invisible in manual testing because WSL terminals handle\rsilently in some contexts.Check:
Fix:
Or prevent it permanently in
.gitattributes:Systematic diagnostic
Rather than guessing which hook leaks stderr, capture the exact output Claude Code sees:
The
xxdoutput 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/nullto all hooks first to confirm stderr is the cause, then re-enable them one at a time to find the specific culprit.Thanks for the suggestions. Ran the full diagnostic:
Stderr check — all hooks clean:
Line endings:
jq 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.
@yurukusa
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:
.claude/settings.jsonhook 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.
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 onsession-audit.sh, but the symptom is unchanged.Format: Schema looks correct —
type,command, and optionaltimeoutfields match the documented spec. No unknown fields.One additional finding worth noting:
Both PreToolUse hooks (
validate-commit.shandvalidate-issue.sh) are registered in the samehooksarray under a single matcher entry: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.
Good catch on the
hooksarray 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:
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.
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.
@voronerd Thanks for confirming on native Linux. That's a useful data point.
To summarize what we now know:
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).
https://github.com/anthropics/claude-code/issues/43894
👍
Closing for now — inactive for too long. Please open a new issue if this is still relevant.
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.