PreToolUse hook deny reason not surfaced to agent's tool_result (v2.1.143)
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:
permissionDecisionReasonis included in the tool_result string the agent sees on PreToolUse-deny (e.g.Tool denied by PreToolUse hook: <reason>), OR- 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.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗