You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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.
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:
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.
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 falsytools.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:
Persist the last explicitly-set per-session mode with the session record and restore it on rebuild, or
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
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
Observed in production on a downstream fork based on the 0.22.x line, in qwen serve / daemon mode driven by an API harness, on Linux containers. The deployment is not a stock upstream release, so I am not claiming a stock v0.23.0 reproduction, and I have not pasted /about from one.
Auth is not implicated here — the model calls, the confirmation votes, and the tool executions all succeeded or failed on permission policy, not credentials.
I re-verified both mechanisms against main@05b8ee06a261 by reading the cited files at that commit, and every line reference above resolves in it. The code paths involved (classifier-transcript.ts, autoMode.ts, denialTracking.ts, coreToolScheduler.ts's L5 gate, config.ts:2588, the bridge reaper defaults) are upstream-owned, not fork modifications, to the best of my diff review.
Defect 1 is the reason the incident was unresolvable; Defect 2 is the reason it happened at all. Neither is sufficient alone: in the caller's original mode there is no classifier, and in AUTO with a persistent mode the harness would have noticed the drift on turn 7 rather than after three failed confirmations.
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.
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 withreason: 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:
AUTOflips 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
buildClassifierContentsprojects the conversation into the transcript the LLM classifier reads (packages/core/src/permissions/classifier-transcript.ts:98-153,main@05b8ee06a261):role === 'user'→ text parts retainedrole === 'model'→ onlyfunctionCallparts, 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_questionand the user answers, the classifier receives:It never sees the answer. The
ask_user_questiontool itself is inSAFE_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: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 toautoModeDebugLoggeronly; 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: onceconsecutiveBlock >= AUTO_MODE_DENIAL_LIMITS.maxConsecutiveBlock(= 3,:37-40), the next classifier-requiring call is passed asskipClassifierReason(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:
AUTO_MODE_DENIAL_GUIDANCE(autoMode.ts:627, attached at:558and: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.ask_user_questionand other allowlisted tools return through the L5.2 fast path beforeevaluateAutoModeruns, so they call neitherrecordAllownorrecordBlock. 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 modeis 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:
Content[]transcript containing amodelmessage with afunctionCallpart forask_user_question, followed by afunctionresponse part whose text is the user's affirmative answer, followed by a pending action.buildClassifierContents(messages, toolRegistry, pendingAction).Prior action: ask_user_question({...})and the pending action, and contains no trace of the answer — asserted by therole === 'function'branch atclassifier-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_questionis 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: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.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
Configas runtime state (packages/core/src/config/config.ts:2265) and is re-derived at construction asparams.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 withif (approvalMode),packages/acp-bridge/src/bridge.ts:5558;applyApprovalModeis at:5747), so asession/resumethat 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 byqwen-code.configreportingauto, and every subsequent turn in the session isauto. The caller's mode-setting request count for the whole session is 1. The idle gap is not unusual;DEFAULT_SESSION_IDLE_TIMEOUT_MSis30 * 60_000with a 60 s sweep (bridge.ts:2668-2669), i.e. shorter than a lunch break, andDEFAULT_SESSION_PROMPT_SETTLED_CLOSE_GRACE_MSis0(: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:
foldReloadApprovalModefolds a missing or falsytools.approvalModetoAUTOand 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:
session/loadandsession/resumeresponses includemodes.currentModeId, built atacpAgent.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.Configalready tracksapprovalModeRevision(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:
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), orexpectedApprovalMode/ compare-and-set parameter onsession/resumeso 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.currentModeIdon 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
qwen serve/ daemon mode driven by an API harness, on Linux containers. The deployment is not a stock upstream release, so I am not claiming a stockv0.23.0reproduction, and I have not pasted/aboutfrom one.main@05b8ee06a261by reading the cited files at that commit, and every line reference above resolves in it. The code paths involved (classifier-transcript.ts,autoMode.ts,denialTracking.ts,coreToolScheduler.ts's L5 gate,config.ts:2588, the bridge reaper defaults) are upstream-owned, not fork modifications, to the best of my diff review.qwen serve: built-in shell guard ignores the session approval mode, denies read-only Git and non-Git commands outside the session directory, and cannot be configured, audited, or surfaced to the operator #10860 (shell guard ignoring the session mode), Track AUTO mode telemetry and classifier parity #4475 (AUTO telemetry/classifier parity), Harden AUTO mode against self-modification and denial bypass #4538 (hardening AUTO against bypass).Edge already ruled out on our side
The confirmation votes were genuine and committed (HTTP
200on 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 ofask_user_questionbeing 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.