qwen-code/docs/design/explicit-plan-exit-approval.md
qwen-code-dev-bot 2cc7da4cef
fix(core): exit_plan_mode returns guidance error from execute() instead of permission deny (#7673)
* fix(core): exit_plan_mode returns guidance error from execute() (#7671)

When the model calls exit_plan_mode outside plan mode (e.g. after the
user manually switched modes via Shift+Tab), it previously got a
generic permission deny error that gave zero guidance.

Changed getDefaultPermission() to return 'allow' instead of 'deny'
when not in plan mode — this is a state issue, not a security issue,
so the permission layer should not block it. The execute() method now
checks approvalMode !== PLAN (with no approval snapshot) and returns
a context-aware errorResult telling the model what happened and what
to do instead.

This also fixes the YOLO mode issue where a permission deny error
was semantically wrong.

* fix(core): cover getConfirmationDetails() outside-Plan guidance path (#7671)

The guidance error for outside-Plan exit_plan_mode calls was only
reachable through execute(). A PM ask rule or a Plan-to-non-Plan
mode switch between permission evaluation and confirmation
construction routes through getConfirmationDetails() instead, which
still threw a generic error.

Extract outsidePlanGuidanceMessage() and reuse it in both
getConfirmationDetails() (throw) and execute() (errorResult) so
the model receives actionable guidance on every reachable path.

Add scheduler-level regression tests for both bypass paths and
update the design doc failure-behavior section.

* fix(core): use StructuredToolError and ToolErrorType for exit_plan_mode guidance (#7671)

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
2026-07-25 02:24:20 +00:00

4.3 KiB

Explicit Plan Exit Approval

Problem

exit_plan_mode previously mixed approval and execution. Its confirmation callback changed ApprovalMode before hooks and execution completed, and AUTO/YOLO sessions could bypass the user through an LLM Plan Approval Gate. Permission-manager allow rules, permission hooks, and sibling auto-approval could also satisfy an ask decision without an actual host/user response. This made a model-originated tool call capable of attempting to leave Plan mode without a user decision and created misleading mode notifications when later execution failed.

Design

Tool invocations may declare requiresUserInteraction(). This is an intrinsic interaction requirement, not another permission level: intrinsic or permission-manager denies still win, while allow rules and automatic approval modes cannot satisfy it. Main-session exit_plan_mode declares the requirement. Plan-required teammates retain their leader-approval path, and ordinary subagents retain the existing lifecycle-tool rejection.

The plan confirmation callback records only one of four decisions: restore the pre-plan mode, switch to auto-edit, switch to default, or cancel. It never changes mode. Creating the confirmation freezes the plan text, pre-plan mode, and the current approval-mode revision. execute() checks that approval exists, the signal is active, the session is still in Plan mode, and the revision still matches before applying the mode transition synchronously. This makes stale, re-entered, and concurrent exits fail closed. Plan persistence happens best-effort only after the transition succeeds.

Config owns a monotonic approval-mode revision that increments only when the mode actually changes. Approval-mode overrides own independent revisions. The existing optional enteredByModel setter argument remains temporarily as an ignored compatibility parameter; model origin has no effect on approval.

The LLM Plan Approval Gate and its AskUserQuestion metadata coupling are removed. prePlanMode remains because it is a user-visible exit choice. originalRequest and researchSummary remain for plan-required teammate leader review. resolutionSummary remains only as a deprecated TypeScript input property for source compatibility and is no longer accepted by the runtime schema.

Host behavior

CLI and IDE confirmation, ACP requestPermission, and stream-json can_use_tool allow responses count as explicit interaction. PermissionRequest allow hooks, PM allow rules, YOLO/AUTO/AUTO_EDIT, and sibling auto-approval do not. Hook deny decisions remain authoritative. Non-interactive callers without an approval-capable host fail closed.

ACP sends no mode update when permission is pending or when confirmation, hooks, execution, or the transition fails. After successful plan lifecycle execution and an actual mode change, it sends one update using the mode read from Config. Legacy notification failure is advisory and the extension side-channel is still attempted with an accurate legacyFrameSent value.

Failure behavior

  • Calls outside Plan mode fail safely with actionable state guidance at whichever boundary observes the mode change. execute() returns a guidance error when the session is outside Plan mode and there is no approval snapshot. getConfirmationDetails() throws the same guidance when called outside Plan mode (e.g. via a PM ask rule or a Plan-to-non-Plan switch between permission evaluation and confirmation construction). The default permission is allow — this is a state issue, not a security issue.
  • Invalid confirmation outcomes, cancellation, aborts, stale revisions, and transition failures leave Plan mode active.
  • Two exits approved against the same revision cannot both succeed.
  • If an ACP host cannot present switch_mode, Plan mode remains active and the error directs the user to the host mode selector or /plan exit.
  • Saving an already approved plan is best-effort and does not roll back a successful mode transition.

Compatibility and scope

This change intentionally does not broaden general shell execution in Plan mode and does not add DataWorks-specific read tools. Those are separate permission/tooling changes. The public invocation method is optional with a default false, so existing tools and external implementations remain compatible.