Skip to content

fix(claude): auto-approve ExitPlanMode via canUseTool#312

Merged
floodsung merged 1 commit into
mainfrom
fix/exitplanmode-auto-approve
May 24, 2026
Merged

fix(claude): auto-approve ExitPlanMode via canUseTool#312
floodsung merged 1 commit into
mainfrom
fix/exitplanmode-auto-approve

Conversation

@floodsung

Copy link
Copy Markdown
Contributor

Problem

When the Claude agent enters plan mode and tries to exit it (via the ExitPlanMode tool), the Feishu bridge gets stuck — the user sees a perpetual "Exit plan mode?" prompt with no way to answer it, and the only escape is Ctrl+C on the bridge process.

Root cause: ExitPlanMode's native checkPermissions returns {behavior: "ask", message: "Exit plan mode?"} outside sub-agent contexts, even under permissionMode: 'bypassPermissions'. That "ask" is routed by the SDK through the can_use_tool control_request — not through PreToolUse hooks. Without a canUseTool handler, the bridge has nothing to reply with, the SDK gives back an is_error tool_result, and the plan-mode gate never lifts.

Extracted from the SDK binary:

async checkPermissions(H, $) {
  if ($A()) return { behavior: "allow", updatedInput: H };
  return { behavior: "ask", message: "Exit plan mode?", updatedInput: H };
}

Fix

Wire a canUseTool callback on the SDK query options for both ClaudeExecutor and PersistentClaudeExecutor that auto-allows ExitPlanMode (and any other unexpected "ask" as a safety net). The user still sees the plan body — the bridge already ships it as a separate card via StreamProcessor + sendPlanContent before this gate is hit.

Two SDK gotchas worth keeping in mind

  1. PreToolUse hook allow does NOT bypass canUseTool's ask. They're two independent gates. PreToolUse: deny short-circuits canUseTool, but allow doesn't. So { matcher: 'ExitPlanMode', hooks: [hook that returns allow] } would log "auto-approving" but never unblock anything.

  2. The allow branch of canUseTool MUST include updatedInput: Record<string, unknown>. The SDK Zod-validates the response and rejects {behavior: 'allow'} alone with ZodError: invalid_type expected record, received undefined at updatedInput. Echo input back when there's no mutation to apply.

Tests

tests/exit-plan-mode-auto-approve.test.ts asserts the full return shape (not just behavior) so a regression on (2) would fail the suite — an earlier iteration passed toEqual({behavior:'allow'}) against the broken shape because toEqual is structural and missing keys are silently OK.

 ✓ tests/exit-plan-mode-auto-approve.test.ts (5 tests) 4ms

Also verified end-to-end on the internal bridge: EnterPlanMode → ExitPlanMode now succeeds silently, with no Tool permission request failed: ZodError and no perpetual "Exit plan mode?" loop.

🤖 Generated with Claude Code

ExitPlanMode's native checkPermissions returns `{behavior: "ask",
message: "Exit plan mode?"}` outside sub-agent contexts — even under
`permissionMode: 'bypassPermissions'`. That "ask" routes through the
`can_use_tool` control_request, NOT through PreToolUse hooks. Without a
canUseTool handler the Feishu bridge gets back an is_error tool_result,
the agent stays in plan mode, and the user sees a perpetual "Exit plan
mode?" loop with no way out.

Fix: wire a `canUseTool` callback on the SDK query options for both
`ClaudeExecutor` and `PersistentClaudeExecutor` that auto-allows
ExitPlanMode (and any other unexpected "ask" as a safety net). The
bridge already ships the plan body to the user as a separate card via
StreamProcessor + sendPlanContent, so the user still sees what was
decided before implementation continues.

Two SDK gotchas worth keeping in mind for future canUseTool work:

  1. PreToolUse hook `allow` does NOT bypass canUseTool's `ask` —
     they're two independent gates. PreToolUse `deny` short-circuits
     canUseTool, but allow doesn't.

  2. The `allow` branch of the canUseTool return value MUST include
     `updatedInput: Record<string, unknown>` — the SDK Zod-validates
     the response and rejects `{behavior: 'allow'}` alone with
     `ZodError: invalid_type expected record, received undefined at
     updatedInput`. Echo `input` back when there's no mutation to apply.

The accompanying tests assert the full return shape (not just
`behavior`) so a regression on (2) fails the suite — a previous
iteration of this fix passed `toEqual({behavior:'allow'})` against the
broken shape because `toEqual` is structural and missing keys are OK.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@floodsung floodsung merged commit d76b06d into main May 24, 2026
3 checks passed
@floodsung floodsung deleted the fix/exitplanmode-auto-approve branch May 24, 2026 11:40
SimonYeyi pushed a commit to SimonYeyi/metabot that referenced this pull request May 26, 2026
ExitPlanMode's native checkPermissions returns `{behavior: "ask",
message: "Exit plan mode?"}` outside sub-agent contexts — even under
`permissionMode: 'bypassPermissions'`. That "ask" routes through the
`can_use_tool` control_request, NOT through PreToolUse hooks. Without a
canUseTool handler the Feishu bridge gets back an is_error tool_result,
the agent stays in plan mode, and the user sees a perpetual "Exit plan
mode?" loop with no way out.

Fix: wire a `canUseTool` callback on the SDK query options for both
`ClaudeExecutor` and `PersistentClaudeExecutor` that auto-allows
ExitPlanMode (and any other unexpected "ask" as a safety net). The
bridge already ships the plan body to the user as a separate card via
StreamProcessor + sendPlanContent, so the user still sees what was
decided before implementation continues.

Two SDK gotchas worth keeping in mind for future canUseTool work:

  1. PreToolUse hook `allow` does NOT bypass canUseTool's `ask` —
     they're two independent gates. PreToolUse `deny` short-circuits
     canUseTool, but allow doesn't.

  2. The `allow` branch of the canUseTool return value MUST include
     `updatedInput: Record<string, unknown>` — the SDK Zod-validates
     the response and rejects `{behavior: 'allow'}` alone with
     `ZodError: invalid_type expected record, received undefined at
     updatedInput`. Echo `input` back when there's no mutation to apply.

The accompanying tests assert the full return shape (not just
`behavior`) so a regression on (2) fails the suite — a previous
iteration of this fix passed `toEqual({behavior:'allow'})` against the
broken shape because `toEqual` is structural and missing keys are OK.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Flood Sung <floodsung@xvirobotics.ai>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
floodsung pushed a commit that referenced this pull request Jun 15, 2026
GitHub-only commits #312 (ExitPlanMode auto-approve) and #314 (full agent
activity content) are already present in this branch via their internal
equivalents (exit-plan-mode.ts is byte-identical; #314 = internal 8316195).
GitLab is the source of truth, so this branch's tree is kept verbatim (-s ours);
this merge only makes GitHub main an ancestor so the cutover is a clean
fast-forward.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant