PreToolUse hook deny reason not surfaced to agent's tool_result (v2.1.143)

Resolved 💬 2 comments Opened May 16, 2026 by johannesdb Closed May 16, 2026

PreToolUse hook deny reason not surfaced to agent's tool_result (v2.1.143)

Summary

When a PreToolUse hook blocks a tool call, neither of the two documented channels for delivering a human-readable reason to the agent (hookSpecificOutput.permissionDecisionReason via JSON+exit-0, or stderr+exit-2) reaches the model. The agent only receives the generic string Hook PreToolUse:Bash denied this tool with no explanation, making it impossible for the agent to understand why it was blocked and adjust its behavior.

Docs at https://code.claude.com/docs/en/hooks state that permissionDecisionReason is surfaced to Claude, and that exit code 2 causes "stderr text [to be] fed back to Claude as an error message". Both appear non-functional for PreToolUse-deny in 2.1.143.

Environment

  • Claude Code: 2.1.143
  • Platform: macOS (Darwin 25.4.0)
  • Shell: zsh
  • Tested with both interactive CLI and via subagent dispatch

Repro A — JSON permissionDecisionReason + exit 0 (documented channel #1)

Minimal hook script (/tmp/deny-hook.py):

#!/usr/bin/env python3
import json, sys
input_data = json.load(sys.stdin)
if input_data.get('tool_name') == 'Bash' and 'echo-hookbug' in input_data.get('tool_input', {}).get('command', ''):
    print(json.dumps({
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": "MARKER-REASON-12345: this string should reach the agent"
        }
    }))
sys.exit(0)

Settings (~/.claude/settings.json or project .claude/settings.json):

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{"type": "command", "command": "python3 /tmp/deny-hook.py"}]
    }]
  }
}

Repro step: In a Claude Code session, ask the agent to run:

echo-hookbug-trigger

Expected (per docs): Agent's tool_result includes MARKER-REASON-12345, so the agent can read why it was blocked and adapt.

Actual: Agent's tool_result is exactly:

Hook PreToolUse:Bash denied this tool

MARKER-REASON-12345 is nowhere in the tool_result. Manual python3 /tmp/deny-hook.py < input.json confirms the hook emits the JSON correctly with exit 0.

Repro B — stderr + exit 2 (documented channel #2)

Same setup, but the hook script writes to stderr and exits 2:

#!/usr/bin/env python3
import json, sys
input_data = json.load(sys.stdin)
if input_data.get('tool_name') == 'Bash' and 'echo-hookbug' in input_data.get('tool_input', {}).get('command', ''):
    print("MARKER-REASON-67890: this stderr text should be fed back to Claude", file=sys.stderr)
    sys.exit(2)
sys.exit(0)

Docs quote (https://code.claude.com/docs/en/hooks, "Exit code output"):

Exit 2 means a blocking error. Claude Code ignores stdout and any JSON in it. Instead, stderr text is fed back to Claude as an error message.

Expected: Agent's tool_result contains MARKER-REASON-67890.

Actual: Same as A — only Hook PreToolUse:Bash denied this tool. The stderr content is not propagated.

Impact

Real-world consequence: a plugin like hookify (https://github.com/anthropics/claude-plugins-official/tree/main/plugins/hookify) is designed to deliver project-specific guidance ("you must run skill X before running bd create") via PreToolUse-deny. Because the reason never reaches the agent, the agent retries the same blocked command, gets the same generic deny, and either gives up or guesses — defeating the plugin's purpose.

The user must currently manually intervene and tell the agent what the rule was — exactly the friction hookify-style plugins exist to eliminate.

What I'd expect

Either:

  1. permissionDecisionReason is included in the tool_result string the agent sees on PreToolUse-deny (e.g. Tool denied by PreToolUse hook: <reason>), OR
  2. stderr-on-exit-2 is appended to the tool_result, as the docs state.

Either would let agents understand and adapt to hook denials without user intervention.

Related issues

  • #33106 — PreToolUse permissionDecision: "deny" not enforced for MCP server tools
  • #37210 — PreToolUse permissionDecision: "deny" ignored for Edit tool
  • #52822 — PreToolUse permissionDecision: "allow" doesn't suppress permission prompt (v2.1.119)

These show PreToolUse-deny handling has multiple known bugs across 2.1.x; this report adds the reason-propagation case.

Workaround currently in use

None. We've patched hookify locally to emit permissionDecisionReason correctly (it was missing), and verified the hook output is correct. The patch is dead-on-arrival until the harness side propagates the field.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗