Hook bug: PreToolUse permissionDecision:deny is silently ignored on ExitPlanMode

Resolved 💬 5 comments Opened Apr 19, 2026 by sampokal Closed May 26, 2026

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

  1. 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.

  1. Wire in a plugin's hooks.json:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "ExitPlanMode",
        "hooks": [
          { "type": "command", "command": "/absolute/path/to/minimal-repro.sh", "timeout": 5 }
        ]
      }
    ]
  }
}
  1. Restart Claude Code (hooks load at session start).
  2. 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 hookEventName field (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_

View original on GitHub ↗

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