fix(core): allow manual retry after auto mode blocks - #11025
Conversation
E2E test reportBaseline
Fixed behavior
Evidence
Results
|
|
Thanks for the PR — this one is well scoped and the motivation is solid. Template looks good ✓ Problem: observed, not theoretical. #11019 is a real production incident (three affirmative Direction: aligned, and specifically endorsed. The issue's own triage called out "a smaller half of Defect 1 worth separating" — letting a block arm the manual-approval dialog directly instead of requiring a fourth attempt — as the piece that breaks the loop "with a much smaller safety delta than any projection change" and "can move faster". This PR is exactly that half, and it correctly leaves the projection change (the trusted-source allowlist) and Defect 2 (session-rebuild approval mode) out. CHANGELOG: no direct reference to a retry-after-block mechanism, but the area is clearly relevant — the reference agent ships one-time prompts inside auto mode ("Added a one-time prompt in auto mode before the first file read outside the working directories"), so auto mode is not a hard no-prompt mode and a one-shot confirmation here is consistent with where the ecosystem landed. Size: core paths are touched ( Approach: the scope feels right, and I could not find a materially simpler path. Both halves are load-bearing — fixing the off-by-one alone still leaves a first block with no user-visible recovery, and the retry token alone still leaves thresholds needing a fourth attempt. Two things I particularly like: the fingerprint reuses the existing One question worth answering before merge, not a blocker: passing the real Risk: Stage 1e matched a high-risk path — Moving on to code review. 🔍 中文说明感谢贡献 —— 这个 PR 范围清晰,动机扎实。 模板完整 ✓ 问题:已观测到的真实缺陷,不是理论加固。#11019 是一次真实的生产事故(用户三次通过 方向:一致,而且是被明确建议过的方向。该 issue 的 triage 专门指出"缺陷 1 中值得单独拆出的较小一半"——让 block 直接启动人工确认弹窗,而不是要求第四次尝试——认为它"能以远小于投影改动的安全代价"打破闭环,且"可以更快推进"。本 PR 正是这一半,并正确地把投影改动(可信来源白名单)和缺陷 2(会话重建后审批模式)排除在外。CHANGELOG:没有与"拦截后重试"机制直接对应的条目,但该区域明显相关——参考实现同样在 auto mode 内部提供一次性确认("Added a one-time prompt in auto mode before the first file read outside the working directories"),说明 auto mode 并非绝对无弹窗模式,这里的一次性人工复核与生态走向一致。 规模:触及核心路径( 方案:范围合理,我没有找到明显更简的路径。两半改动都是必需的——只修 off-by-one,首次拦截仍然没有用户可见的恢复入口;只加重试标记,达到阈值时仍需要第四次尝试。有两点做得好:指纹复用了已有的 一个建议在合并前回答的问题(非阻塞):把真实的 风险:Stage 1e 命中高风险路径 —— 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI wrote down my own approach before opening the diff: re-check One finding worth fixing before merge.
Verified correct (naming these so you know what was actually checked, not just what passed):
One unmentioned behaviour change (question, not a blocker): labelling the decorator reason accurately means sequenceDiagram
participant P1 as Model
participant P2 as CoreToolScheduler
participant P3 as evaluateAutoMode
participant P4 as denialTracking state
participant P5 as Confirmation UI
participant P6 as User
P1->>P2: tool call A
P2->>P4: prepareAutoModeFallback, digest A
P4-->>P2: no armed retry
P2->>P3: evaluateAutoMode
P3-->>P2: classifier policy block
P2->>P4: recordBlock with digest A
Note over P4: token A armed, streak below limit
P2-->>P1: denied, with exact retry guidance
P1->>P2: retry identical call A
P2->>P4: prepareAutoModeFallback, digest A
P4-->>P2: classifier_blocked_retry, token consumed
P2->>P3: evaluateAutoMode, classifier skipped
P3-->>P2: fallback, no classifier request
P2->>P5: dialog, allow once or cancel only
P5->>P6: one time manual review
P6-->>P2: approve once
P2->>P4: recordFallbackApprove clears streak
P2-->>P1: executed
Files changed (14 of 14 shown)
Test evidenceEvidence carried here: the PR's own CI, read through the API. This is an unattended What has landed green is relevant but not decisive: The author posted an E2E report claiming 525/525 core, 813/813 ACP Session, and 55/55 permission-option and TUI regressions, plus build, lint, typecheck and format on macOS with Node 24.14.1. That is the author's self-reported result on a single platform, not evidence this review re-ran or can confirm, and the tool execution and classifier responses in it were mocked. Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Sandboxed verification would settle this, and the author has write access so both lanes are available. 中文说明本节证据:仅通过 API 读取该 PR 自身的 CI 结果。 这是无人值守的 代码审查:我在看 diff 之前先写下了自己的方案(在 一处建议在合并前修复的问题: 已核实正确的部分:标记无法授予执行(在跳过分类器前同步消费,仅选择 fallback 路径);摘要特性(sha256 规范化,字段重排无法规避匹配、旧别名无法洗白、cwd 或参数变化即重新分类);审计链路保留(阈值拦截转为 fallback 后 hook 仍以 一处未在描述中提及的行为变化(是问题,不是阻塞):如实标注 decorator 的 reason 之后, 测试证据的诚实结论:对本次 diff 最关键的两个 check —— 作者发布的 E2E 报告称 core 525/525、ACP Session 813/813、权限选项与 TUI 回归 55/55,以及 macOS + Node 24.14.1 上的构建、lint、typecheck、format 通过。这是作者在单一平台上的自述结果,不是本次 review 复跑或能够确认的证据,且其中的工具执行与分类器响应均为 mock。 沙箱验证可以定案,且作者具有 write 权限,两条通道均可用。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 3/5 — the design is sound and I could not find a safety regression, but it ships one false instruction to the model on the destructive-command path, and the CI that would cover it has not reported. Stepping back: this is a good PR. It fixes a closed loop that a real user hit — three explicit confirmations, three refusals, ten minutes, nothing executed — and it fixes the half of #11019 that the issue's own triage singled out as the one that "can move faster" with "a much smaller safety delta". I wrote my own design before reading the diff and the PR arrived at the same one, including the PermissionDenied hook subtlety I would have expected to be missed. The token is genuinely single-use, genuinely bound to a digest of tool + args + cwd, and genuinely incapable of executing anything on its own. Two thirds of the diff is tests, and they pin the right things — exact match, mismatch, pre-confirmation consumption, both threshold boundaries, the hook, ACP parity, the reduced option set. If I inherited this in six months I would thank the author, not curse them. So why not approve. Three things, in descending order of weight. The guidance defect is real and it is a regression, not a nit. The decisive CI has not landed. This is a security boundary in a revert-correlated path, arriving in volume. The change rewrites how AUTO mode can be overridden, in the area #4538 specifically hardened, and it touches Two smaller things for whoever picks this up: the ⏸️ Deferring rather than approving or rejecting. I could not resolve an accountable maintainer deterministically to hand this to: 中文说明置信度:3/5 —— 设计是可靠的,我没有找到安全性回退,但它在破坏性命令路径上向模型下发了一条假指令,而能覆盖这一点的 CI 还没有出结果。 退一步看:这是一个好 PR。它修复了真实用户撞上的死循环 —— 三次明确同意、三次拒绝、十分钟、零条语句执行 —— 而且修的正是 #11019 自身 triage 挑出的"可以更快推进、安全代价远小于投影改动"的那一半。我在看 diff 前先写下了自己的方案,PR 与之一致,包括我本以为会被漏掉的 PermissionDenied hook 细节。恢复标记确实是一次性的、确实绑定"工具+参数+cwd"摘要、也确实本身无法执行任何东西。diff 的三分之二是测试,且固定了正确的点:精确匹配、不匹配、确认前消费、两个阈值边界、hook、ACP 一致性、收敛后的选项集合。如果半年后接手这份代码,我会感谢作者。 那么为什么不批准。三点,按权重递减。 指引缺陷是真实的回退,不是小毛病。 决定性的 CI 还没落地。 审查时 这是位于 revert 高相关路径上的安全边界,而且是批量到达的。 该改动重写了 AUTO 模式可被覆盖的方式,正处在 #4538 专门加固过的区域,并且触及本仓库 revert 历史标记为高风险的 两个较小的点留给接手者: ⏸️ 选择转交,而不是批准或拒绝。 我无法确定性地解析出一位可负责的 maintainer 来接手:本环境未设置 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 4)": none — but I did not open chunks 1-4 (other agents' territory); my cross-file reads of scheduler/Session/autoMode were limited to verifying chunk-5 interactions….
中文说明
仅完成部分审查,审查缺口已披露。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 4)":none — but I did not open chunks 1-4 (other agents' territory); my cross-file reads of scheduler/Session/autoMode were limited to verifying chunk-5 interactions…。
— qwen3.8-max via Qwen Code /review (v0.23.0)
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed. Suggestions are inline.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
packages/core/src/permissions/autoMode.ts:688 — [review] system prompt 'Denied Tool Calls' rule contradicts the new retry guidance while the doc comment claims they stay in syncpackages/core/src/permissions/denialTracking.ts:10 — [review] user docs (auto-mode.md, approval-mode.md) still describe the removed next-call fallback semantics and omit the exact-retry pathpackages/core/src/permissions/denialTracking.ts:39 — [review] formatDenialStateLog omits pendingManualRetryFingerprint — no log can show whether the retry token is armed, replaced, or consumedpackages/core/src/permissions/denialTracking.ts:97 — [review] recordAllow's !clearsPendingRetry zero-counter token-clear term has no witnessing test (mutant survives 533 tests)packages/core/src/core/coreToolScheduler.ts:6562 — [review] pending-path fallback debug line bypasses formatDenialStateLog and drops the totals — two log shapes for one state machine
中文说明
仅完成部分审查,审查缺口已披露。 建议见行内评论。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 5 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| if (hasReachedTotalCap(state)) { | ||
| return createDenialState(); | ||
| return { | ||
| ...state, |
There was a problem hiding this comment.
[Suggestion] R1-3: (fix-induced) the fix for R1-2/R1-3 in efe3f9d removed the fingerprint disarm from recordFallbackApprove — the right call for unrelated approvals, but it also means approving the exact blocked action through any other fallback path no longer clears the armed retry token, so the next exact issuance skips the classifier and is re-prompted as "previously blocked" for an action the user just approved. Concretely: a parallel batch issues two identical calls X1, X2 (same tool/args/cwd, same fingerprint); the classifier flaps — X2 is classified unavailable, X1 is policy-blocked and arms pendingManualRetryFingerprint=X; the user approves X2, and recordFallbackApprove (called from recordAutoModeFallbackResolution in coreToolScheduler.ts and mirrored in Session.ts) resets the counters but has no fingerprint parameter, so the token survives. When the model then follows the denial guidance and retries X exactly, the stale token matches, the classifier is skipped (0 calls), and the user sees "Auto mode previously blocked this exact action. Review it manually." for the action they just approved. It fails safe — one extra prompt, never a bypass, and it self-heals on re-approval — but the retry message is factually wrong and muddies the approval loop.
Witness:
probe (end-to-end through the real scheduler, unmodified PR code):
state-after-approve={"consecutiveBlock":0,"consecutiveUnavailable":0,"totalBlock":1,
"totalUnavailable":1,"pendingManualRetryFingerprint":"48904b2c..."}
retry-awaiting fallback={"reason":"classifier_blocked_retry",
"message":"Auto mode previously blocked this exact action. Review it manually."} classifierCalls=0
FIXED arm (optional actionFingerprint + exact-match disarm): state-after-approve has no token;
retry-executed classifierCalls=1 — probe flipped both directions; all 43 denialTracking tests still pass.
Suggested fix — give recordFallbackApprove an optional fingerprint and disarm only on an exact match, then thread the approved call's fingerprint through from recordAutoModeFallbackResolution (store it alongside autoModeFallbackCallIds, or recompute from the stored args + config.getCwd()) and from the ACP Session outcome handler:
export function recordFallbackApprove(
state: AutoModeDenialState,
actionFingerprint?: string,
): AutoModeDenialState {
const disarm =
actionFingerprint !== undefined &&
state.pendingManualRetryFingerprint === actionFingerprint
? { pendingManualRetryFingerprint: undefined }
: {};
if (hasReachedTotalCap(state)) {
return {
...state,
consecutiveBlock: 0,
consecutiveUnavailable: 0,
totalBlock: 0,
totalUnavailable: 0,
...disarm,
};
}
// ...same disarm applied to the other two branches
}Note the disarm must be conditional on an exact fingerprint match, never unconditional: this PR's own tests pin preservation for unrelated approvals (it.each(['an unrelated fallback approval', recordFallbackApprove]) and 'preserves an unrelated retry when resetting the total denial cap' in denialTracking.test.ts). If you add the disarm, please also add a denialTracking.test.ts case asserting recordFallbackApprove(state, 'blocked-action') clears a pendingManualRetryFingerprint of 'blocked-action' while recordFallbackApprove(state, 'other-action') preserves it, and confirm removing the disarm branch makes that test fail.
中文说明
R1-3(由修复引入):efe3f9d968 对 R1-2/R1-3 的修复从 recordFallbackApprove 中移除了指纹解除逻辑——对无关批准而言是正确的,但这也意味着通过其他 fallback 路径批准恰好被拦截的那个动作时,不再清除已武装的重试令牌,于是下一次完全相同的调用会跳过分类器,并对用户刚刚批准过的动作再次弹出"此前已拦截"的提示。具体场景:并行批次发出两个完全相同的调用 X1、X2(相同工具/参数/工作目录,指纹相同);分类器抖动——X2 被判为不可分类,X1 被策略拦截并武装 pendingManualRetryFingerprint=X;用户批准 X2,recordFallbackApprove(由 coreToolScheduler.ts 中的 recordAutoModeFallbackResolution 调用,Session.ts 中有对应实现)重置计数器,但没有指纹参数,令牌因此残留。当模型按拒绝指引精确重试 X 时,残留令牌命中,分类器被跳过(0 次调用),用户会对刚刚批准过的动作看到"Auto mode previously blocked this exact action. Review it manually."。该问题失败时是安全的——只多一次提示、绝不绕过审批,且再次批准即可自愈——但重试提示文案与事实不符,会干扰审批流程。
建议修复:给 recordFallbackApprove 增加可选指纹参数,仅在完全匹配时解除令牌,并把被批准调用的指纹从 recordAutoModeFallbackResolution(与 autoModeFallbackCallIds 一并存储,或用已存参数 + config.getCwd() 重新计算)以及 ACP Session 的结果处理处传入。解除逻辑必须以指纹精确匹配为条件、绝不能无条件清除:本 PR 自身的测试已钉住无关批准的保留行为(denialTracking.test.ts 中的 it.each(['an unrelated fallback approval', recordFallbackApprove]) 与 'preserves an unrelated retry when resetting the total denial cap')。若添加解除逻辑,请补充 denialTracking.test.ts 用例:断言 recordFallbackApprove(state, 'blocked-action') 会清除值为 'blocked-action' 的 pendingManualRetryFingerprint,而 recordFallbackApprove(state, 'other-action') 保留它,并验证移除解除分支会使该测试变红。
— qwen3.8-max via Qwen Code /review (v0.23.0)
qwen-code-dev-bot
left a comment
There was a problem hiding this comment.
Reviewed at head 21f88f25.
- Security spine verified line by line: the manual-retry token is a digest of tool+args+cwd (
getAutoModeActionFingerprintover the existing repeat-key helper), armed only by a classifier policy block, checked before skipping the classifier, and consumed on both the allow and the reject turn — and the scheduler'sfallbackcase never executes: it drops into the existing human confirmation flow, so the fallback relaxes classification, never approval. Destructive guidance keeps the old no-workaround wording and gains no retry suggestion; the threshold off-by-one fix routes the threshold-reaching call into the same human-gated path immediately; the hook emission excludes classifier-unavailable while the exhaustiveneverin the message formatter keeps the reason set closed. - The shared-merge detail on the settings side and the Session/pending-call re-evaluation paths mirror the same transition, per the pinning tests (197 new Session tests, 207 autoMode, 78 denialTracking).
- One open thread (parallel-identical-calls re-prompting an action just approved through the sibling's dialog) is a duplicate-prompt UX edge in a classifier-flap scenario — the human gate holds throughout; Suggestion, worth the author's follow-up note, not a blocker.
- CI facts: 24 checks pass; the single
Test (ubuntu)failure is the main-residual #10988 source guard (acpAgent.ts:9200still namesrunWithAcpRuntimeOutputDirdirectly on currentmain— identical bytes here and there, and I flagged it on #11033), not this PR's doing. The page's CHANGES_REQUESTED is ci-bot's round-1 ticket at the first head; its re-reviews since then at the current head were COMMENTED without new blockers — a@qwen-code /triagere-run clears the stale ticket.
yiliang114
left a comment
There was a problem hiding this comment.
Reviewed the retry-token mechanism at head 21f88f2: the lifecycle is closed and fail-safe — only classifier policy blocks arm the fingerprint token (destructive blocks deliberately don't, since their guard runs before the manual-review promise can be honored); the token is consumed before the fallback dialog is shown; all four recordAllow call sites clear it on exact-fingerprint match only; state is per-Config-instance memory with no cross-session persistence. The token can only ever downgrade a call to manual confirmation, never auto-approve it, so a stale token costs one extra prompt at worst — no bypass path. Concurrency-safe: identical parallel calls each snapshot independently and get their own dialogs; prior-round fixes (R1-1 destructive guidance split, consumption timing, reject-then-reclassify) are all present and test-pinned.
R1-3 (open Suggestion) verified as still standing but bounded: approving the exact blocked action through the unavailable-verdict fallback path no longer disarms the token, so the next identical issuance is re-prompted once — an extra prompt, not a gating or safety issue. Fine to land as a follow-up.
Note on CI: the red Test (ubuntu) is inherited from main — the acpAgent runtime-root-pinning chokepoint test flags a direct runWithAcpRuntimeOutputDir mention at acpAgent.ts:9200, and both that test and that line exist on main (b0a463c); this PR touches neither file.
|
Post-merge review of head Verified in the code at this head
Executed: core permissions suites 533/533 · cli suites 922 tests: 920 pass plus 2 failures that are host-environment artifacts — this daemon session exports Cross-check: both existing approvals at this head match my read, including the one open bounded Suggestion (re-prompt once when the exact blocked action is approved through a sibling's unavailable-verdict fallback — an extra prompt, not a gating gap). The ubuntu CI red is the main-residual source-guard item both approvals attribute to No blockers found in what merged. Reviewed with AI assistance. |
What this PR does
AUTO mode now offers a one-time manual review path after a classifier policy block. Retrying the same tool call with unchanged arguments in the same working directory bypasses only the classifier and opens the existing confirmation UI; a changed tool, argument, or working directory is classified normally. Policy-block recovery offers only allow-once or cancel and keeps the session in AUTO, while classifier-unavailable recovery retains the existing recommendation to switch to Default. The call that reaches the consecutive-block or session-denial threshold also enters manual review immediately instead of requiring one extra attempt. Core scheduling, pending-call re-evaluation, and ACP sessions share the same state transition.
The denial result now tells the model how to request manual review without suggesting equivalent-path workarounds. Deterministic destructive-command guards remain authoritative and still run before any recovery fallback.
Why it's needed
Classifier false positives in AUTO mode were effectively unoverridable: the model was told not to retry, while no user confirmation was exposed. Denial thresholds also had an off-by-one behavior, so the third consecutive block and twentieth session denial still failed before a later call could fall back.
Reviewer Test Plan
How to verify
Trigger an AUTO classifier policy block, then repeat the same tool call. Confirm that the first call is denied, the exact retry opens a one-time manual confirmation without another classifier request, offers no mode-switch or persistent-approval choice, and executes only after approval. Change an argument or the working directory and confirm that classification runs again. Starting from two consecutive blocks or nineteen total denials, confirm that the next classifier block opens manual confirmation on that current call. Repeat the checks through an ACP session and through pending-call re-evaluation. Confirm that classifier-unavailable recovery still offers the recommended Default-mode switch, and that classifier-unavailable and unrelated safety fallbacks do not emit a policy-denial hook while a threshold reached by an actual policy block does.
Evidence (Before & After)
Before: an exact retry was classified and blocked again; the third consecutive block and twentieth total denial were denied, with fallback delayed until a later call.
After: only an exact tool/arguments/working-directory match receives one manual review opportunity, and threshold-reaching calls enter that review immediately. All executions remain gated on explicit approval.
Tested on
Environment (optional)
Node.js 24.14.1. Verified with the full affected core and ACP test files, permission-option and TUI regression tests, repository build, lint, and typecheck. Tool execution and classifier responses were mocked; no destructive command was run.
Risk & Scope
Linked Issues
Partially addresses #11019 (classifier-block recovery and denial-threshold behavior only).
中文说明
本 PR 做了什么
AUTO 模式现在会在分类器策略拦截后提供一次人工复核路径。在相同工作目录中,以完全不变的参数重试同一工具调用时,只跳过分类器并打开现有确认界面;工具、参数或工作目录任一变化都会正常重新分类。策略拦截后的恢复只提供允许一次或取消,并保持 AUTO 模式;分类器不可用时则保留现有的切换到 Default 建议。达到连续拦截阈值或会话拒绝总量阈值的当前调用,也会立即进入人工复核,不再需要额外再调用一次。核心调度、等待调用重新评估和 ACP 会话共用同一套状态迁移。
拒绝结果现在会告诉模型如何请求人工复核,同时仍禁止通过等价路径绕过。确定性的破坏性命令保护仍具有最高优先级,并继续在任何恢复 fallback 之前执行。
为什么需要
AUTO 模式下的分类器误判此前实际上无法覆盖:模型被告知不要重试,但用户又看不到确认入口。拒绝阈值还存在 off-by-one 行为,第 3 次连续拦截和会话第 20 次拒绝仍会失败,只有之后的调用才能 fallback。
Reviewer Test Plan
如何验证
触发一次 AUTO 分类器策略拦截,然后重复相同工具调用。确认首次调用被拒绝,精确重试在不再次请求分类器的情况下打开一次性人工确认,不提供模式切换或永久批准选项,并且只有批准后才会执行。修改参数或工作目录,确认会重新分类。从已有 2 次连续拦截或 19 次总拒绝开始,确认下一次分类器拦截会在当前调用直接打开人工确认。通过 ACP 会话和等待调用重新评估分别重复检查。确认分类器不可用时仍提供推荐的 Default 模式切换选项;分类器不可用及无关的安全 fallback 不会触发策略拒绝 hook,而真实策略拦截达到阈值时仍会触发。
证据(修复前后)
修复前:精确重试仍会重新分类并再次被拦;第 3 次连续拦截和第 20 次总拒绝仍被拒绝,fallback 推迟到后续调用。
修复后:只有工具、参数和工作目录完全匹配的调用获得一次人工复核机会,达到阈值的当前调用也会立即进入复核。所有执行仍必须经过明确批准。
测试平台
环境(可选)
Node.js 24.14.1。已运行受影响 core 与 ACP 测试文件的完整用例、权限选项与 TUI 回归测试、仓库构建、lint 和 typecheck。工具执行与分类器响应均为 mock,未运行任何破坏性命令。
风险与范围
关联 Issue
部分处理 #11019(仅分类器拦截恢复与拒绝阈值行为)。