[BUG] PreToolUse hook exit code 2 causes Claude to stop instead of acting on error feedback

Status Fixed / completed
Reported on v2.1.37
Maintainer reply None cached
Activity 3 comments · opened Feb 9, 2026 · closed Feb 22, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code

What's Wrong?

When a PreToolUse hook blocks a tool call by exiting with code 2, Claude stops responding and waits for user input instead of acting on the error feedback provided via stderr.

Per the hooks reference, exit code 2 on PreToolUse "blocks the tool call" and "stderr text is fed back to Claude as an error message." The expected behavior is that Claude receives the error message and can reason about it — for example, fixing lint errors and retrying a commit. Instead, Claude treats the blocked tool call as a full stop and goes idle, requiring the user to manually type "continue" or similar to resume.

This behavior is intermittent — sometimes Claude does act on the feedback correctly, but increasingly it stops. The inconsistency suggests the model is interpreting the "blocked tool call" signal as a reason to defer to the user, rather than as actionable feedback.

Context that should prevent stopping

The stderr output includes explicit directives telling Claude to continue:

<claude-directive>
This is an AUTOMATED QUALITY GATE, not a user denial or permission rejection.
You MUST continue your current workflow autonomously:
1. Fix every error listed above in the source files
2. Run: git add -v .
3. Retry the exact same commit command
Do NOT stop, do NOT end your turn, do NOT ask the user to continue.
</claude-directive>

Additionally, the workflow is injected via a UserPromptSubmit hook with an <error-recovery> section containing:

**PreToolUse hook blocks are automated quality checks, NOT user denials.**

Critical directives:
- Do NOT ask the user for permission to fix lint/format errors — fix them autonomously
- Do NOT stop or end your turn when a commit is blocked by a hook
- Do NOT treat a hook block as a user denial — it is a machine-generated quality gate

Despite both of these, Claude frequently stops and waits for user input.

What Should Happen?

When a PreToolUse hook exits with code 2 and provides error details via stderr, Claude should:

  1. Receive the stderr content as actionable feedback (this part works)
  2. Continue its turn and act on that feedback — e.g., fix the reported errors and retry the tool call
  3. NOT treat the hook block as equivalent to a user denial or permission rejection

The key distinction: a user denial (clicking "deny" on a permission prompt) means "don't do this." A hook exit code 2 means "this action was blocked for a specific reason" — Claude should read the reason and adapt, not stop.

Error Messages/Logs

PreToolUse:Bash hook error: [bash "$HOME/.claude/hooks/validate-code-before-commit/commit-quality-check.sh"]: Running code quality checks before commit...
=== Python Checks ===
Running Ruff linter...
Running Ruff formatter...
Running Mypy type check...
Mypy type check failed:
Summary: 1 errors across 1 file(s)
  Types: 1 code
  → 8 share root cause: [annotation-unchecked]
[CODE] tests/test_fix_verify.py:367: error: Need type annotation for "state"  [var-annotated]
  Context:
     366│     # Create a staged state with fix and testing blocks populated
     367│     state = {
     368│         "schema_version": "1.0.0",
Found 1 error in 1 file (checked 56 source files)
Environment:
  Python: 3.14.1
  mypy: 1.19.0

═══════════════════════════════════════════════════════════════════
COMMIT BLOCKED by commit-quality: Mypy type check failed
═══════════════════════════════════════════════════════════════════

───────────────────────────────────────────────────────────────────
WHAT TO DO: Fix ALL errors shown above, then retry the commit.
───────────────────────────────────────────────────────────────────

<claude-directive>
This is an AUTOMATED QUALITY GATE, not a user denial or permission rejection.
You MUST continue your current workflow autonomously:
1. Fix every error listed above in the source files
2. Run: git add -v .
3. Retry the exact same commit command
Do NOT stop, do NOT end your turn, do NOT ask the user to continue.
Do NOT treat this as a blocked tool call — it is a fixable quality check.
Retry up to 3 times. Only report to the user if errors persist after 3 attempts.
</claude-directive>

After this output, Claude stops and waits for user input. The user must type "continue" or similar to get Claude to act on the errors.

Steps to Reproduce

  1. Create a PreToolUse hook on Bash that runs quality checks before git commit:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/hooks/my-quality-check.sh",
            "timeout": 300
          }
        ]
      }
    ]
  }
}
  1. The hook script detects git commit commands, runs linting/type-checking, and exits with code 2 when errors are found:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')

# Only check git commit commands
if ! echo "$COMMAND" | grep -qE '\bgit\s+commit'; then
  exit 0
fi

# Run type checker
OUTPUT=$(mypy . 2>&1)
if [[ $? -ne 0 ]]; then
  echo "Type check failed. Fix these errors and retry:" >&2
  echo "$OUTPUT" >&2
  exit 2  # Block the commit
fi

exit 0
  1. Ask Claude to commit changes in a project with a type error
  2. The hook blocks the commit and feeds the error to Claude via stderr
  3. Observe: Claude stops and waits for user input instead of fixing the type error and retrying

Claude Model

  • Opus

Is this a regression?

Yes, this worked in a previous version

Last Working Version

Not certain of exact version. The behavior change correlates with the introduction of Claude Opus 4.6 (v2.1.32), first noticed around early February 2026. It may also be a model-level behavioral change rather than a Claude Code client change.

Claude Code Version

2.1.37 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

iTerm2

Additional Information

Observations

  • Intermittent: Claude sometimes does handle the hook block correctly and fixes/retries. Other times it stops. This inconsistency suggests a model interpretation issue rather than a deterministic client bug.
  • Timing: First noticed after Opus 4.6 became available (v2.1.32). The behavior was reliable with earlier Opus versions.
  • Bypass permissions mode: Running with --dangerously-skip-permissions does not change the behavior. The hook block is processed correctly by the client — the issue is how the model responds to the "blocked tool call" signal.

Analysis

The root cause appears to be how the model interprets the "tool call was blocked" feedback. When a PreToolUse hook exits with code 2, Claude Code presents this to the model as a blocked/denied action. The model's trained behavior treats blocked actions conservatively — it stops and defers to the user, similar to how it would respond to a user clicking "deny" on a permission prompt.

The distinction between "user denied this action" and "automated hook blocked this action with fixable feedback" is not apparent to the model from the signal it receives.

Possible improvements

  1. Differentiate hook blocks from user denials in the model's prompt: When a PreToolUse hook exits with code 2, the message to the model could explicitly indicate this is an automated hook (not a user denial) and that the model should act on the feedback.
  1. Add a hook output field like expectContinuation: true: Allow hooks to signal that the block is expected to be resolved by the model, not by the user.
  1. Surface the <claude-directive> or additionalContext from stderr: Currently exit code 2 causes stdout/JSON to be ignored. If the stderr contains structured directives (like <claude-directive>), Claude Code could parse and present them as system instructions rather than just error text.

Workaround attempted

Embedding <claude-directive> tags in stderr with explicit instructions to continue. This works sometimes but not reliably — the "blocked tool call" signal appears to override embedded directives.

View original on GitHub ↗

3 Comments

coygeek · 7 months ago

Follow-up: PreToolUse exit-2 investigation results (Issue #24327)

I ran a controlled investigation in an isolated harness and compared it with the original failing session (a294c6fc-2c82-4209-a0d3-967a4772a10b).

Outcome

I could not reproduce a model/client behavior where Claude silently stops after a PreToolUse exit-2 block.

In controlled runs, Claude consistently continued after the block signal and acted on it (fix + retry path).

What likely happened in the original session

In the original trace, the commit-quality hook took ~20.5s between git commit tool call and hook error delivery. The hook error arrived very near session end, and there were no subsequent assistant turns to respond. This is most consistent with turn/session budget exhaustion timing during a long-running hook execution, not dropped block feedback.

Signal shape observations

Both paths were observed as actionable error tool_results:

  • exit 2 + stderr path (PreToolUse hook error text)
  • JSON deny path (permissionDecision: "deny" + permissionDecisionReason)

Both delivered feedback that Claude acted on in controlled testing.

Documentation alignment

Current hooks docs indicate:

  • PreToolUse exit 2 blocks tool call and forwards stderr as error feedback
  • For PreToolUse deny, permissionDecisionReason is shown to Claude
  • additionalContext is supported in PreToolUse hookSpecificOutput

Local mitigations I applied

To reduce recurrence risk from long hook runs/context pressure, I hardened my local commit-quality hook:

  • corrected outdated comments about PreToolUse deny/context behavior
  • reduced block output size limits
  • changed Python check scope default to staged files (PYTHON_SCOPE=staged) for faster commit-time checks
  • changed pytest default to staged test files (PYTEST_SCOPE=staged) with optional all mode override
  • updated shared pre_tool_utils docs/helpers/tests to reflect current PreToolUse behavior (additionalContext + deny reason handling)

These changes keep quality gating active while reducing hook latency and oversized error payload risk.

coygeek · 7 months ago

Final update.

There's nothing that can be done about this bug report, since the its a race condition that happens sometimes when PARALLEL hooks fire, in this case PreToolUse.

While the most effective way is to simply remove conflicting PreToolUse hooks, it's not always possible.

The next workaround is to use a wrapper for these multiple hooks, so that it fires once.

My solution was a single Python dispatcher that runs all 6 hooks sequentially as subprocesses, returning merged output. Eliminates the race condition by ensuring only 1 hook is registered per matcher.

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