[BUG] PermissionRequest hook 'allow' for ExitPlanMode is ignored since v2.1.199 — plan-approval chooser still blocks

Status Open
Reported on v2.1.201
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Jul 4, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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?

Since v2.1.199, a PermissionRequest hook that returns decision.behavior: "allow" for ExitPlanMode is executed (its stdin payload is delivered and its stdout response is read) but the decision is discarded. The native plan-approval chooser ("Claude has written up a plan and is ready to execute. Would you like to proceed?") is still displayed and blocks the session until answered manually.

This breaks tools that implement out-of-band plan approval via hooks (e.g. browser-based plan review UIs): the human approves in the external UI, but the terminal still hangs on the interactive chooser.

Variants tested on 2.1.201 — all ignored:

  • PermissionRequest allow with updatedPermissions ({"type": "setMode", "mode": "acceptEdits", "destination": "session"})
  • PermissionRequest bare allow (no updatedPermissions)
  • PreToolUse hook with permissionDecision: "allow"

Notably, the deny path still works on 2.1.201: decision.behavior: "deny" with a message round-trips correctly — the model receives the feedback, revises the plan, and calls ExitPlanMode again. Only the allow path is broken, which narrows the regression to how an allow decision is applied to the plan-approval dialog.

What Should Happen?

v2.1.198 behavior: when the hook returns allow, the plan-approval chooser is skipped, any updatedPermissions (e.g. setMode: acceptEdits) are applied, and the session proceeds directly into implementation. This matches the documented "Auto-approve specific permission prompts" flow in the hooks guide, and @anthropic-ai/claude-agent-sdk 0.3.201 still exports the same PermissionRequestHookSpecificOutput contract, so the current behavior appears to be an unintentional regression. Nothing in the 2.1.199–2.1.201 changelog mentions a behavior change for plan approval via hooks.

Error Messages/Logs

# Hook stdin payload captured on 2.1.201 (hook demonstrably fires; shape identical to 2.1.198):
{
    "session_id": "7942a346-27fc-4d06-b627-84708db96ae9",
    "cwd": "/tmp/repro",
    "permission_mode": "plan",
    "hook_event_name": "PermissionRequest",
    "tool_name": "ExitPlanMode",
    "tool_input": {
        "plan": "# Context\n\nUser requested a simple file be created...",
        "planFilePath": "~/.claude/plans/write-a-file-named-noble-crescent.md"
    }
}
# Hook stdout response (read by Claude Code, then ignored):
{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","updatedPermissions":[{"type":"setMode","mode":"acceptEdits","destination":"session"}]}}}

Steps to Reproduce

  1. Create approve-hook.sh (chmod +x):

``bash
#!/bin/bash
cat > /dev/null
echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","updatedPermissions":[{"type":"setMode","mode":"acceptEdits","destination":"session"}]}}}'
``

  1. Create settings.json:

``json
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{ "type": "command", "command": "/path/to/approve-hook.sh", "timeout": 600 }
]
}
]
}
}
``

  1. In an empty directory, run claude --permission-mode plan --settings settings.json
  2. Prompt: Write a file named hello.txt containing exactly: hi
  3. Claude writes the plan and calls ExitPlanMode

Expected (observed on 2.1.198): chooser is skipped, session switches to accept-edits, hello.txt is created.

Actual (observed on 2.1.199 and 2.1.201): the chooser is displayed and blocks; hello.txt is never created. Capturing the hook's stdin confirms the hook ran and answered allow.

Version bracketing (same repro, driven programmatically against the TUI):

| Version | Result |
|---|---|
| 2.1.198 | allow honored — chooser skipped, plan implemented |
| 2.1.199 | allow ignored — chooser blocks |
| 2.1.201 | allow ignored — chooser blocks |

Claude Model

Other: reproduced with Haiku 4.5; the behavior is model-independent (also observed with Opus-class models).

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.198

Claude Code Version

2.1.201 (Claude Code)

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Other: reproduced both in the desktop app and headlessly (expect/xterm-256color driving the TUI), so terminal-independent.

Additional Information

Related but distinct existing issues: #71061 (showClearContextOnPlanAccept not honored on hook approval — the hook-allow bypass itself still worked there), #50660 (PreToolUse deny ignored on ExitPlanMode, older). This report is specifically about the allow path regressing between 2.1.198 → 2.1.199 while deny continues to work.

View original on GitHub ↗

3 Comments

blimmer · 1 month ago

Root cause and a verified workaround, from comparing notes with another affected tool.

Plannotator hit the same regression and traced it in the bundled CLI (backnotprop/plannotator#995): the PermissionRequest result handling now has a guard that throws away an allow for any non-MCP tool where requiresUserInteraction() is true, unless the decision includes updatedInput. It returns early as if the hook gave no decision at all, so updatedPermissions get dropped too. The deny branch isn't touched, which matches the allow/deny split I reported above.

The workaround is to echo the event's tool_input back verbatim as decision.updatedInput. I re-ran the repro from this issue on 2.1.201 with that envelope: the approval dialog is skipped, the setMode: acceptEdits permission update applies, and implementation proceeds exactly as it did on 2.1.198. The same envelope is a no-op on 2.1.198, so hook authors can ship it unconditionally:

{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","updatedInput":{"plan":"...","planFilePath":"..."},"updatedPermissions":[{"type":"setMode","mode":"acceptEdits","destination":"session"}]}}}

One correction to the Plannotator report: it says >=2.1.200, but they upgraded straight from 2.1.198 and never ran 2.1.199. My version bisection shows 2.1.199 is the first affected release.

Since the workaround leans on guard internals, a real fix would still be welcome. If the new behavior is intentional, documenting that an ExitPlanMode allow needs updatedInput would also close this out. Happy to re-verify against a fix.

rpelevin · 1 month ago

One regression test that may pin this down cleanly is to assert the native plan-gate outcome, not only the hook result.

For ExitPlanMode, I would make the allow path prove three joins:

  • the PermissionRequest payload that fired the hook is the same payload approved, or the same payload echoed as updatedInput;
  • updatedPermissions is applied to the same session before the chooser is rendered;
  • the native chooser emits a terminal skipped/applied state rather than remaining pending.

Then keep a paired negative case: allow without a digest-bound updatedInput on the affected path should be visible as native gate still pending, not as a successful approval. That mirrors the useful deny-path behavior isolated here and gives hook authors a stable contract for external approval UIs.

bcherny collaborator · 15 days ago

Reproduced on v2.1.233 (macOS), with the exact steps from the report.

  1. approve-hook.sh that reads stdin and prints {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","updatedPermissions":[{"type":"setMode","mode":"acceptEdits","destination":"session"}]}}}
  2. settings.json with a PermissionRequest hook, matcher ExitPlanMode, pointing at that script
  3. In an empty dir: claude --permission-mode plan --settings settings.json --model haiku
  4. Prompt: Write a file named hello.txt containing exactly: hi

Observed: the hook runs (its stdin shows "hook_event_name":"PermissionRequest","tool_name":"ExitPlanMode") and answers allow, but the plan chooser still renders and blocks:

Claude has written up a plan and is ready to execute. Would you like to proceed?
❯ 1. Yes, auto-accept edits
  2. Yes, manually approve edits
  3. Tell Claude what to change

hello.txt is never created. The updatedPermissions (set mode to accept-edits) is dropped along with the allow.

Expected (per the report): chooser skipped, mode switches to accept-edits, hello.txt written.

The workaround from the comments still works on 2.1.233: echoing the event's tool_input back as decision.updatedInput skips the chooser ("User approved Claude's plan" then "Allowed by PermissionRequest hook"), applies accept-edits, and hello.txt is written.

Assessment: This is intended behavior as of v2.1.199, but the expectation is reasonable and the docs don't fully cover it. ExitPlanMode and AskUserQuestion are tools whose approval card is the user interaction, so a hook can only skip the card if it also supplies the answer via updatedInput (for ExitPlanMode, echoing the tool's input back is enough). The hooks docs say this in the PreToolUse section (https://code.claude.com/docs/en/hooks#pretooluse-decision-control: "Returning \"allow\" alone is not sufficient for these tools"), but the PermissionRequest section (https://code.claude.com/docs/en/hooks#permissionrequest-decision-control) never mentions it, and a bare allow is silently discarded with no warning to the hook author. We should make this clearer by documenting the updatedInput requirement in the PermissionRequest section and surfacing a debug/warning when a hook allow for one of these tools is ignored (or letting an accompanying setMode count as the approval for ExitPlanMode).

🤖 Generated with Claude Code