Skip to content

AUTO mode: user approvals never reach the classifier (blocks are unoverridable); approval mode also reverts to AUTO on session rebuild #11019

Description

@chiga0

What happened?

A production data change was being carried out through an API-driven host harness (not the interactive TUI). The agent asked the user to confirm the change with ask_user_question, the user answered affirmatively three separate times, and each time the subsequent tool call was still refused with reason: classifier_blocked. Zero statements were executed, and the user waited ~10 minutes across three confirmation round trips.

Two defects in AUTO mode compose into that outcome. Both were re-verified against current main (05b8ee06a261) by reading the code, so the mechanism is not fork-specific.

What did you expect to happen?

Two things, corresponding to the two defects below:

  • When AUTO mode's classifier refuses an action because it wants explicit user confirmation, a confirmation the user actually gave through the product's own first-party confirmation tool should be able to satisfy that requirement — or at minimum the gate should not report "no confirmation was obtained" when one is in the transcript.
  • When a caller sets a session's approval mode through the documented daemon API, that value should either survive a rebuild of the session's runtime, or the change away from it should be visible to the caller. A silent convergence on AUTO flips the permission model under a running integration without any signal.

Neither expectation is about making AUTO mode more permissive. Defect 1 asks the gate to be able to see evidence of authorization that is already committed; Defect 2 asks the runtime to stop changing an operator-owned invariant by itself.


Defect 1 (root cause): the classifier is asked to require a user confirmation it is structurally unable to see

buildClassifierContents projects the conversation into the transcript the LLM classifier reads (packages/core/src/permissions/classifier-transcript.ts:98-153, main@05b8ee06a261):

  • role === 'user' → text parts retained
  • role === 'model' → only functionCall parts, re-rendered as user-role text turns of the form `Prior action: ${toolName}(${JSON.stringify(projected)})` (:209-217)
  • role === 'function' (tool results) → fully stripped, with a comment that says so explicitly (:143)

A tool result is the only representation a tool call's answer can have. So when the agent calls ask_user_question and the user answers, the classifier receives:

Prior action: ask_user_question({"questions":[…]})   ← it can see that the question was asked
## Pending tool call to classify
Tool: <the action that question was about>            ← and the action now under review

It never sees the answer. The ask_user_question tool itself is in SAFE_TOOL_ALLOWLIST (packages/core/src/permissions/autoMode.ts:62-86), so the question is never blocked — the confirmation is collected by the host UI, committed as an authoritative permission vote, and then discarded on its way to the only component that asks for it.

The three refusals in this incident each said, in effect, "this requires explicit user confirmation, which was not obtained" — with three different wordings, i.e. LLM-generated, not a deterministic rule. Given a projection that omits the confirmation by construction, a refusal is the only self-consistent output.

Why the user cannot release it

applyAutoModeDecision (autoMode.ts:544-580) has a genuinely asymmetric pair of branches:

verdict what the user gets
classifier_unavailable {kind:'fallback'} → falls through to the manual approval dialog, decorated with "Switching to Default Mode is recommended" (formatClassifierUnavailableFallbackMessage, autoMode.ts:629-635, decorateClassifierUnavailableConfirmation, :637-653)
classifier_blocked {kind:'blocked'} → a tool error to the model (EXECUTION_DENIED, not_started), coreToolScheduler.ts:3072-3092

'blocked' creates no permission request, so there is nothing for the user to answer. The block is visible to the model and to autoModeDebugLogger only; from the operator's side the turn just says "blocked by safety policy" and repeats.

The escape hatch is disarmed by the guidance that ships with the block

shouldFallback (packages/core/src/permissions/denialTracking.ts:127-143) does provide a way out: once consecutiveBlock >= AUTO_MODE_DENIAL_LIMITS.maxConsecutiveBlock (= 3, :37-40), the next classifier-requiring call is passed as skipClassifierReason (coreToolScheduler.ts:3002, :3020-3022) and resolves to {via:'fallback'} → a real manual dialog.

Three properties make it unreachable in exactly the situation where it matters:

  1. It is consulted on the next call, before the classifier runs, so it never releases the call that is blocked now. The user needs a fourth attempt at the same action.
  2. Every block appends AUTO_MODE_DENIAL_GUIDANCE (autoMode.ts:627, attached at :558 and :664), which instructs the model to "stop and ask the user for explicit approval" and not to attempt the denied action again. An agent that follows this instruction — ours did, all three times — never produces the fourth attempt. Obedience to the denial guidance is what prevents the recovery path from arming.
  3. Nothing else clears the streak in between. ask_user_question and other allowlisted tools return through the L5.2 fast path before evaluateAutoMode runs, so they call neither recordAllow nor recordBlock. Only a classifier-approved call clears it (autoMode.ts:552, denialTracking.ts:82-90) — but the agent has no other reason to be making classifier calls while it is waiting for the user.

So the loop closes: the gate demands a confirmation → the confirmation is stripped from the gate's input → the remedy the gate itself advertises ("ask the user") cannot satisfy it → the fallback that could release it requires retrying the action the guidance forbids retrying.

Note this is not a bypass hole in the intended direction: AUTO mode is supposed to be more conservative than the user, and #4538 hardened it against exactly that. The problem here is the opposite — the mode cannot accept a stronger authorization than the model's own estimate, even when a human has supplied one through the product's own first-party confirmation tool.

Reproduction

Defect 1 does not depend on the harness or on any fork change, and is reproducible deterministically at the projection level without an LLM:

  1. Build a Content[] transcript containing a model message with a functionCall part for ask_user_question, followed by a function response part whose text is the user's affirmative answer, followed by a pending action.
  2. Call buildClassifierContents(messages, toolRegistry, pendingAction).
  3. Observe: the returned projection contains Prior action: ask_user_question({...}) and the pending action, and contains no trace of the answer — asserted by the role === 'function' branch at classifier-transcript.ts:143.

End-to-end in interactive AUTO mode requires a prompt the classifier reliably blocks, so it is nondeterministic; the projection test above is the tight form. Happy to contribute it.

Suggested direction

The answer to ask_user_question is not untrusted tool output — it is a user selection collected and committed by the host through the permission-vote path, i.e. the highest-trust input the session has. Options, roughly in increasing cost:

  • Add a trusted-source allowlist to the projection so committed interactive answers (a bounded set: ask_user_question, plus explicit permission-vote results) survive as user-role text turns, while all other tool results stay stripped. This keeps the existing orphan-functionCall rationale intact for everything else.
  • Alternatively/additionally, make a classifier block on an action that is preceded by a committed interactive confirmation arm the manual-approval dialog directly, instead of requiring a fourth attempt.
  • At minimum, include a "the user has confirmed this via …" signal in the block payload so the block is auditable and the model is not told to gather evidence the gate will ignore.

Defect 2 (trigger): an approval mode set through the daemon session API is not durable across a session runtime rebuild

Same incident, the other half. AUTO was never chosen for this session. The harness set a non-AUTO mode once, at session creation, via the documented route; every turn after that ran in the mode it had set — until the session's runtime was reclaimed and rebuilt.

The mode lives on Config as runtime state (packages/core/src/config/config.ts:2265) and is re-derived at construction as params.approvalMode ?? ApprovalMode.AUTO (:2588). It is not carried in the persisted session record. The daemon applies a caller-supplied mode only when the request includes one (the resume path guards it with if (approvalMode), packages/acp-bridge/src/bridge.ts:5558; applyApprovalMode is at :5747), so a session/resume that merely reattaches rebuilds the runtime on the built-in default.

Observed, on a single unchanged daemon process, in the OTel trace: 6 turns run in the caller's mode → ~84 minutes idle → the next turn's trace contains qwen-code.daemon.session_restore (action=resume) followed by qwen-code.config reporting auto, and every subsequent turn in the session is auto. The caller's mode-setting request count for the whole session is 1. The idle gap is not unusual; DEFAULT_SESSION_IDLE_TIMEOUT_MS is 30 * 60_000 with a 60 s sweep (bridge.ts:2668-2669), i.e. shorter than a lunch break, and DEFAULT_SESSION_PROMPT_SETTLED_CLOSE_GRACE_MS is 0 (:2673) — the grace that exists so poll-based clients are not torn down between polls is off by default, so a poll-based client's session is a reaper candidate as soon as it has no SSE subscriber and no in-flight work (entryIsAutoCloseCandidate, :3119-3145).

This is the second silent AUTO-convergence path I found; the first is settings reload, and it is deliberate: foldReloadApprovalMode folds a missing or falsy tools.approvalMode to AUTO and pushes it to live sessions, with the comment "so a key deletion reaches live sessions on reload instead of pinning a stale privileged mode until daemon restart" (packages/cli/src/acp-integration/acpAgent.ts:1844-1871). I agree with that intent for an interactively-owned session. I don't think it extends to a mode a programmatic caller owns per-session through the API, whose value can now be revoked by an unrelated settings edit or a runtime rebuild.

What makes this hard to run safely:

  • The rebuilt mode is already reported to the caller: session/load and session/resume responses include modes.currentModeId, built at acpAgent.ts:14411-14421 (attached at :5116, :5270, :5594, :5683). Nothing in the response says the value differs from the one the caller set, and there is no event for the transition.
  • Config already tracks approvalModeRevision (config.ts:2267, :7061). A rebuilt runtime starting at revision 0 in a mode the caller never asked for is exactly what a comparison would catch — that signal is not surfaced to the API caller today.

Suggested direction

Any one of these would let a harness stop guessing:

  1. Persist the last explicitly-set per-session mode with the session record and restore it on rebuild, or
  2. Emit a currentModeId-vs-expected discrepancy in the load/resume response, or a session-update event when a rebuilt/reloaded runtime's mode differs from the last explicitly-set value (with the reason), or
  3. Accept a expectedApprovalMode / compare-and-set parameter on session/resume so the caller can state its invariant and get an error rather than a silent downgrade.

We will implement the client-side workaround regardless — re-applying the mode after a cold restore, and cross-checking modes.currentModeId on every resume, since the value is already in the response at no extra round-trip cost — but the durable semantics belong here, and a client cannot distinguish "drifted on rebuild" from "a human deliberately changed it mid-session", which is why option 2 or 3 is worth more to us than option 1.


Client information / verification status — please read before triaging

Edge already ruled out on our side

The confirmation votes were genuine and committed (HTTP 200 on the daemon permission route, three distinct request ids), and the denial was recorded against a session that had been idle long enough to be rebuilt. So this is not a lost-vote or transport bug, and not a case of ask_user_question being suppressed by the mode — that tool is on the allowlist and fired correctly every time.

I'd like to fix Defect 1 upstream if you agree with the trusted-source-projection framing; I'd start with the projection unit test above plus the allowlist carve-out, and keep everything else stripped.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions