Hook bug: PreToolUse permissionDecision:deny is silently ignored on ExitPlanMode
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?
Summary
A PreToolUse hook matching ExitPlanMode that emits {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}} has no effect — Claude Code proceeds to the "Accept this plan?" approval UI as if no hook existed. The hook script runs (confirmed via side effects like state-file writes), but the harness does not honor the deny.
The same output shape on a Write|Edit PreToolUse hook does enforce correctly and surfaces the reason as a permission prompt. The bug is scoped to the ExitPlanMode tool specifically.
Minimal reproduction
- Create
minimal-repro.sh:
#!/usr/bin/env bash
set -u
cat >/dev/null
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"TEST DENY"}}\n'
exit 0
chmod +x minimal-repro.sh.
- Wire in a plugin's
hooks.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "ExitPlanMode",
"hooks": [
{ "type": "command", "command": "/absolute/path/to/minimal-repro.sh", "timeout": 5 }
]
}
]
}
}
- Restart Claude Code (hooks load at session start).
- Enter plan mode (Shift+Tab), write any trivial plan, call ExitPlanMode.
Expected: tool call blocked, "TEST DENY" surfaced as a permission prompt.
Actual: tool call proceeds, normal "Accept this plan?" UI appears, TEST DENY never shown to the user, model's tool-use result contains no deny/error.
Control: same shape works on Write|Edit
Change the matcher from ExitPlanMode to Write|Edit in the same hooks.json. Restart. Attempt to edit any non-trivial file. The same script blocks the edit and surfaces "TEST DENY" as expected. This rules out:
- JSON shape issues (identical shape enforces on Write|Edit)
- Missing
hookEventNamefield (already present) - General hook infrastructure problems
Isolation experiment
Three progressively minimal hooks, all on PreToolUse ExitPlanMode:
| # | Hook | Output shape | Observed |
|---|------|--------------|----------|
| 1 | Full production hook with transcript parsing, state file, bypass logic | {"hookSpecificOutput":{"permissionDecision":"deny",...}} (no hookEventName) | ignored |
| 2 | 8-line minimal, same shape as #1 | same | ignored |
| 3 | 8-line minimal with "hookEventName":"PreToolUse" added (see script above) | identical to known-working Write\|Edit hook | still ignored |
Narrowed to: bug is in the ExitPlanMode-specific harness integration, not output validation.
Environment
- Platform: macOS (Darwin 25.3.0)
- Claude Code: VS Code native extension
- Plugin system: sampo-marketplace plugins (user-authored), hooks loaded via
hooks.json - Date observed: 2026-04-19 (bug first surfaced 2026-04-18)
Impact
Hook-based gating of ExitPlanMode (plan auditors, pre-approval compliance checks) is not enforceable via PreToolUse. Workaround: post-hoc enforcement via a Stop hook using decision:"block" — different harness code path, reliably enforced — but this fires after the approval UI, not before.
What Should Happen?
The tool call should be blocked by the harness before ExitPlanMode fires. The permissionDecisionReason string should surface to the user as a permission prompt (allow/deny dialog), matching (a) the documented behavior in the hooks output schema, and (b) the actual observed behavior when the same hook output is emitted against other PreToolUse matchers like Write|Edit. The model's subsequent turn should receive the deny as a tool-use error with the reason string attached.
Error Messages/Logs
None — the failure is silent. No error surfaces in the UI, no error appears in the model's tool-use result, nothing in the session transcript indicates the hook's deny was rejected. The only observable side effect is that the hook script runs to completion (verified via state-file writes performed by the script). The script's stdout JSON is apparently discarded by the harness when the matcher is ExitPlanMode. By contrast, emitting the identical JSON from a Write|Edit PreToolUse hook produces the expected permission prompt.
Steps to Reproduce
Create minimal-repro.sh:
#!/usr/bin/env bash
set -u
cat >/dev/null
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"TEST DENY"}}\n'
exit 0
chmod +x minimal-repro.sh.
Wire it in a plugin's hooks.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "ExitPlanMode",
"hooks": [
{ "type": "command", "command": "/absolute/path/to/minimal-repro.sh", "timeout": 5 }
]
}
]
}
}
Restart Claude Code (hooks load at session start).
Enter plan mode (Shift+Tab), write any trivial plan, call ExitPlanMode.
Expected: tool call blocked, TEST DENY surfaced as permission prompt.
Actual: tool call proceeds, normal "Accept this plan?" UI appears, TEST DENY never shown, no tool-use error delivered to the model.
Control: change the matcher from ExitPlanMode to Write|Edit, restart, attempt to edit any non-trivial file — same script enforces correctly. Confirms the bug is scoped to the ExitPlanMode matcher specifically, not output shape or general hook infrastructure.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.114 (VS Code native extension, anthropic.claude-code-2.1.114-darwin-arm64)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
VS Code integrated terminal
Additional Information
_No response_
This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗