Skip to content

fix(permissions): escalate repeated destructive-command denials to manual approval - #13636

Open
yiliang114 wants to merge 5 commits into
mainfrom
fix/issue-13570-destructive-denial-escalation
Open

yiliang114 wants to merge 5 commits into
mainfrom
fix/issue-13570-destructive-denial-escalation

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Repeated destructive-command denials in AUTO mode can now escalate to manual approval, the same way classifier denials already do. Previously the blocked:destructive-command branch of applyAutoModeDecision recorded the denial and returned kind: 'blocked' unconditionally, so the counters climbed forever without ever reaching a fallback. It now passes the action fingerprint into recordBlock and consults shouldFallback, mirroring case 'classifier': at the consecutive-block cap (3) or the session total-denial cap (20) the call degrades to manual approval instead of hard-blocking again, and the one-shot pending manual retry is consumed in that same call.

The first destructive denial still hard-blocks, and the denial message text is unchanged.

Why it's needed

The destructive-command branch was the only denial producer in applyAutoModeDecision that could not reach either escalation cap. That is a dead end for the user: the guidance appended to the denial (AUTO_MODE_DESTRUCTIVE_DENIAL_GUIDANCE) tells the model to stop and ask the user for explicit approval, but on this path pendingManualRetryFingerprint was never set — recordBlock only sets it when given a truthy fingerprint — so there was no exact-action manual-review route to fall back to, and no number of repetitions would ever surface one. The session stayed pinned to an unconditional block until ApprovalMode was toggled.

Reported in #13570 (defect 4 of that report). Root cause independently confirmed in-thread by @doudouOUC, and the fix direction matches item 3 of the review bot's stage-2 fix list.

Reviewer Test Plan

How to verify

The behaviour is asserted directly against applyAutoModeDecision, so no shell/TUI reproduction is needed:

./node_modules/.bin/vitest run packages/core/src/permissions/autoMode.test.ts packages/core/src/permissions/destructive-commands.test.ts --coverage.enabled=false

Before this change the new applyAutoModeDecision — blocked:destructive-command escalation block fails 4 of 5 tests. The most direct one drives a denial state already at consecutiveBlock: 2 and asserts the next destructive denial returns kind: 'fallback'; on main it returns kind: 'blocked' while the recorded state shows consecutiveBlock: 3, totalBlock: 3 — the counters increment, the escalation never arms. After the change all 5 pass, and the 102 pre-existing autoMode.test.ts tests are untouched and still pass (138 total across the two files).

Two pre-existing tests specifically pin the sibling behaviour and both stay green: applyAutoModeDecision handles blocked:destructive-command (first denial still hard-blocks, message text unchanged) and preserves an armed retry when the destructive guard preempts it. The latter is why shouldFallback is called with the post-increment state only, without the fingerprint argument — passing the fingerprint there would make classifier_blocked_retry fire immediately and flip that test's expected kind: 'blocked' to fallback.

Non-vacuity was checked by single-line mutation, each reverting one line of the fix:

  • recordBlock(denialState, actionFingerprint) back to recordBlock(denialState) reddens exactly 1 test (arms the exact-action manual retry on a destructive denial).
  • shouldFallback(blockedState) to shouldFallback(denialState) reddens exactly 3 (both cap tests and consumes the pending retry once it escalates).

Also verified locally: packages/core tsc --noEmit clean (exit 0, and --listFiles confirms both changed files were loaded), eslint clean on both changed files, prettier --check clean on both.

Evidence (Before & After)

N/A — no user-visible/TUI change. The denial message string is byte-identical; only the outcome kind at the escalation caps changes. Test output is in the section above.

Tested on

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux ✅

Environment (optional)

Unit tests only (vitest run), no runtime sandbox involved.

Risk & Scope

  • Main risk or tradeoff: a destructive-command denial at a cap now surfaces a manual-approval prompt instead of a second unconditional block. That is the intended behaviour and matches the classifier path, but it does mean a user who has accumulated 3 consecutive blocks (or 20 session denials) will be asked to review a destructive command rather than have it refused outright. The destructive guard itself is unchanged — it still fires before the classifier and still blocks on the first attempt.
  • Not validated / out of scope: this PR addresses only defect 4 of Auto mode blocks inert text that merely mentions the amend phrase, ahead of the user's own ask rule and with no escape hatch #13570. Deliberately deferred, with reasons:
    • Defect 3 (moving the input.pmForcedAsk early return above the L5.2.5 destructive guard) and defect 4c (the input.skipClassifierReason check sitting after that guard) both live in evaluateAutoMode, in the exact region that open, non-draft PR fix(shell): use bash word separators for shell parsing #12339 rewrites (@@ -819,16 +820,16 @@ export async function evaluateAutoMode(). Landing a second writer there would conflict, so evaluateAutoMode is not touched at all here.
    • Defect 1 (replacing the raw substring GIT_AMEND_PATTERN match with a parsed-segment match) needs tokeniser work in the shell-semantics/shell-tool area, which fix(shell): use bash word separators for shell parsing #12339 is also active in, and did not fit this change's budget.
    • Defect 2 (a keyword escape hatch so an inert mention of the amend phrase is not blocked) is a maintainer call per the triage, and the reporter explicitly asked that it not be generalised to all of DESTRUCTIVE_GIT_PATTERNS. Not implemented; destructive-commands.ts is untouched.
  • Breaking changes / migration notes: none. No new exports, options, or abstractions; the change reuses the existing recordBlock / shouldFallback / consumePendingManualRetry / formatDenialFallbackMessage helpers. AutoModeDecision and AutoModeOutcome shapes are unchanged.

Linked Issues

Refs #13570 — deliberately not a closing keyword: that report describes four defects and this PR fixes only the fourth, so the issue must stay open for the deferred items.

中文说明

这个 PR 做了什么

AUTO 模式下重复的「破坏性命令拒绝」现在可以像分类器拒绝一样升级为人工审批。此前 applyAutoModeDecision 的 blocked:destructive-command 分支只记录拒绝并无条件返回 kind: 'blocked',计数器会一直累加却永远触发不了 fallback。现在它把 action fingerprint 传入 recordBlock 并调用 shouldFallback,与 case 'classifier' 保持一致:达到连续拒绝上限(3 次)或会话总拒绝上限(20 次)时,该次调用降级为人工审批而不是再次硬拒绝,并在同一次调用中消费掉一次性的 pending manual retry。

首次破坏性命令拒绝仍然是硬拒绝,拒绝提示文案一字未改。

为什么需要

blocked:destructive-command 是 applyAutoModeDecision 中唯一一个无法触达任何升级上限的拒绝来源。这对用户是个死胡同:附加在拒绝信息里的 AUTO_MODE_DESTRUCTIVE_DENIAL_GUIDANCE 告诉模型停下来向用户请求明确批准,但这条路径上 pendingManualRetryFingerprint 从未被设置——recordBlock 只在传入真值 fingerprint 时才设置它——因此根本不存在可供回退的「针对该动作的人工复核」路径,重复多少次也不会出现。会话会被钉死在无条件拒绝上,直到切换 ApprovalMode。

由 #13570 报告(该报告中的第 4 个缺陷)。根因已由 @doudouOUC 在 issue 中独立确认,修复方向与 review bot stage-2 修复清单的第 3 项一致。

评审测试计划

如何验证

行为直接针对 applyAutoModeDecision 断言,无需 shell/TUI 复现:

./node_modules/.bin/vitest run packages/core/src/permissions/autoMode.test.ts packages/core/src/permissions/destructive-commands.test.ts --coverage.enabled=false

改动前,新增的 applyAutoModeDecision — blocked:destructive-command escalation 测试块 5 个用例中有 4 个失败。最直观的一个把拒绝状态预置为 consecutiveBlock: 2,断言下一次破坏性拒绝返回 kind: 'fallback';在 main 上它返回 kind: 'blocked',而记录下来的状态显示 consecutiveBlock: 3, totalBlock: 3——计数器在涨,升级始终没有武装。改动后 5 个全部通过,且 autoMode.test.ts 原有的 102 个测试未被触碰、依然通过(两个文件合计 138 个)。

有两个既有测试专门钉住了兄弟路径的行为,改动后都仍然是绿的:applyAutoModeDecision handles blocked:destructive-command(首次拒绝仍硬拒绝、文案不变)和 preserves an armed retry when the destructive guard preempts it。后者正是 shouldFallback 只传入自增后的状态、不传 fingerprint 参数的原因——在那儿传 fingerprint 会让 classifier_blocked_retry 立即触发,把该测试期望的 kind: 'blocked' 翻成 fallback。

非空断言用单行变异验证,每次只回退修复中的一行:

  • 把 recordBlock(denialState, actionFingerprint) 改回 recordBlock(denialState),恰好 1 个测试变红(arms the exact-action manual retry on a destructive denial)。
  • 把 shouldFallback(blockedState) 改成 shouldFallback(denialState),恰好 3 个变红(两个上限测试和 consumes the pending retry once it escalates)。

本地还验证了:packages/core 的 tsc --noEmit 干净(exit 0,且 --listFiles 确认两个改动文件都被加载)、两个改动文件的 eslint 干净、prettier --check 干净。

证据(改动前 / 改动后)

N/A——无用户可见 / TUI 变化。拒绝信息字符串逐字节相同,只有在上限处的 outcome kind 发生变化。测试输出见上一节。

测试环境

OS Status
🍏 macOS ⚠️
🪟 Windows ⚠️
🐧 Linux ✅

环境(可选)

仅单元测试(vitest run),不涉及运行时沙箱。

风险与范围

  • 主要风险 / 权衡:达到上限时的破坏性命令拒绝,现在会弹出人工审批提示,而不是第二次无条件拒绝。这是预期行为、也与分类器路径一致,但确实意味着累计了 3 次连续拒绝(或 20 次会话拒绝)的用户会被要求复核一条破坏性命令,而不是被直接拒绝。破坏性命令守卫本身未变——它仍然在分类器之前触发,首次尝试仍然拒绝。
  • 未验证 / 范围之外:本 PR 只处理 Auto mode blocks inert text that merely mentions the amend phrase, ahead of the user's own ask rule and with no escape hatch #13570 的第 4 个缺陷。以下刻意推迟,理由如下:
    • 缺陷 3(把 input.pmForcedAsk 的提前返回移到 L5.2.5 破坏性命令守卫之上)与缺陷 4c(input.skipClassifierReason 检查位于该守卫之后)都位于 evaluateAutoMode 中,正是未合并、非 draft 的 PR fix(shell): use bash word separators for shell parsing #12339 所重写的区域(@@ -819,16 +820,16 @@ export async function evaluateAutoMode()。在那里再放一个写入方会产生冲突,因此本 PR 完全没有触碰 evaluateAutoMode。
    • 缺陷 1(把裸子串 GIT_AMEND_PATTERN 匹配换成基于解析片段的匹配)需要在 shell-semantics / shell-tool 的分词层做改动,而 fix(shell): use bash word separators for shell parsing #12339 同样在该区域活动,且超出本次改动的预算。
    • 缺陷 2(增加关键字逃生口,使对 amend 短语的无害提及不被拦截)按 triage 结论属于维护者决策,且报告者明确要求不要把它推广到整个 DESTRUCTIVE_GIT_PATTERNS。未实现,destructive-commands.ts 未被触碰。
  • 破坏性变更 / 迁移说明:无。没有新增导出、配置项或抽象;改动复用了既有的 recordBlock / shouldFallback / consumePendingManualRetry / formatDenialFallbackMessage 辅助函数。AutoModeDecision 与 AutoModeOutcome 的形状未变。

关联 Issue

Refs #13570 —— 刻意不使用自动关闭关键字:该报告描述了四个缺陷,本 PR 只修复第四个,因此 issue 必须为其余推迟项保持开启。

…nual approval

The `blocked:destructive-command` case in `applyAutoModeDecision` recorded the
denial without the action fingerprint and never consulted `shouldFallback`, so
it returned `kind: 'blocked'` unconditionally. Repeated destructive-command
denials therefore incremented `consecutiveBlock` / `totalBlock` forever without
ever degrading to the manual-approval fallback, even though the denial guidance
appended to the same message tells the model to stop and ask the user for
explicit approval. The sibling `case 'classifier'` path already escalates at
`maxConsecutiveBlock` (3) and `maxTotalDenials` (20); this path was the only
denial producer that could not reach either cap.

Pass `actionFingerprint` into `recordBlock` and call `shouldFallback`, mirroring
`case 'classifier'`: on escalation, consume the one-shot pending manual retry
and return `kind: 'fallback'` with `formatDenialFallbackMessage`. `shouldFallback`
is deliberately called with the post-increment state only (no fingerprint
argument), matching the classifier path so an armed retry is preserved rather
than consumed by the guard that preempts it.

The first destructive denial still hard-blocks; nothing about the denial
message text changes.

Refs #13570 (defect 4 only - the other three reported defects are deferred, see
the pull request description)

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmuyryq633z
@qwen-code-review-bot

qwen-code-review-bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Qwen Triage finished — CI landed green on da26d9d and the deferred approval was posted. finalize run

✅ Qwen Triage 已完成 —— da26d9d 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed, not theoretical. Linked issue #13570 is open (type/bug, priority/P2, category/security) with a concrete reproduction, and its "Suggested fix" section prescribes exactly this change — "pass actionFingerprint to recordBlock so repeated denials can escalate". I confirmed the defect against the code rather than taking the description's word: blocked:destructive-command was the only denial producer in applyAutoModeDecision that called recordBlock(denialState) and returned kind: 'blocked' unconditionally, so the counters climbed with no path to either cap. The denial message on that branch tells the model to "stop and ask the user for explicit approval", but pendingManualRetryFingerprint was never armed, so the message advertised a door that wasn't there.

Direction: aligned. Upstream has repeatedly fixed this exact class of bug — a denial message pointing at a route that doesn't work: 2.1.290 "Fixed auto mode denials suggesting a permission rule that would skip the classifier for a whole tool or that Claude Code would ignore", and 2.1.288 "Fixed auto mode denials pointing Claude at a Bash permission rule when the blocked tool was not Bash". Making the advertised route reachable is the same direction. Nothing here touches auth, sandbox, model selection, telemetry, release, or a public contract.

Size: core path (packages/core/src/permissions/**) — 18 production lines (autoMode.ts +16/−2), 70 test lines (autoMode.test.ts +70/−0), 0 generated/schema. Well under the 500-line threshold, so no maintainer size escalation and no large-PR advisory. Title is fix(...), not refactor, so the Tier 1 hard block doesn't apply. Tier 2 does — I read every downstream consumer; that's in Stage 2.

Approach: this is close to the minimal change, and it's the shape I'd have written independently. It reuses recordBlock / shouldFallback / consumePendingManualRetry / formatDenialFallbackMessage instead of adding anything new, and it mirrors the existing classifier branch nearly line for line — which removes a special case rather than adding one. No new exports, options, or abstractions, and the AutoModeDecision / AutoModeOutcome shapes are untouched. Deferring defects 1, 2, 3 and 4c is also the right call: they all sit in evaluateAutoMode, the region #12339 is actively rewriting, so a second writer there would just conflict. One genuine scope question I'll take up in the code review — whether the actionFingerprint argument to recordBlock is load-bearing for the cap fix, or a second, smaller behaviour change riding along with it.

Risk: no elevated risk signals — neither changed file matches the revert-correlated path list. One note for whoever reviews next: the central claim here is behavioural, and the new tests assert applyAutoModeDecision's return kind directly, so the unit layer is well pinned. The layer above it — a fallback outcome actually rendering as a manual-approval prompt — is not covered by the new tests, and I'll name the lane for that in Stage 2.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题: 是已观测到的缺陷,不是理论性加固。关联 issue #13570 仍处于开启状态(type/bug、priority/P2、category/security),带有具体复现步骤,其「建议修复」一节给出的方案与本 PR 完全一致——「把 actionFingerprint 传给 recordBlock,使重复拒绝能够升级」。我没有只信描述,而是对照代码确认了这个缺陷:blocked:destructive-command 是 applyAutoModeDecision 中唯一一个调用 recordBlock(denialState) 并且无条件返回 kind: 'blocked' 的拒绝来源,因此计数器会一直累加却永远触不到任何上限。该分支的拒绝提示告诉模型「停下来向用户请求明确批准」,但 pendingManualRetryFingerprint 从未被武装,也就是说提示指向的那扇门根本不存在。

方向: 对齐。上游反复修过这一类缺陷——拒绝提示指向一条走不通的路径:2.1.290「修复了 auto mode 拒绝提示建议了一条会跳过整个工具分类器、或 Claude Code 根本会忽略的权限规则」,以及 2.1.288「修复了被拦截工具不是 Bash 时,auto mode 拒绝提示仍指向 Bash 权限规则」。让提示所指向的路径真正可达,方向一致。本 PR 未触及 auth、sandbox、模型选择、telemetry、发布流程或公共契约。

规模: 触及核心路径(packages/core/src/permissions/**)——生产代码 18 行(autoMode.ts +16/−2)、测试 70 行(autoMode.test.ts +70/−0)、生成/schema 文件 0 行。远低于 500 行阈值,因此不需要维护者规模升级,也不触发大 PR 建议。标题是 fix(...) 而非 refactor,Tier 1 硬拦截不适用。Tier 2 适用——我读了每一个下游消费方,详见 Stage 2。

方案: 已经接近最小改动,也正是我独立会写出的形态。它复用了 recordBlock / shouldFallback / consumePendingManualRetry / formatDenialFallbackMessage,没有新增任何东西,并且几乎逐行对齐既有的 classifier 分支——这是减少了一个特例,而不是增加了一个。没有新增导出、配置项或抽象,AutoModeDecision / AutoModeOutcome 的形状也未改动。把缺陷 1、2、3 和 4c 推迟处理同样是正确的选择:它们都位于 evaluateAutoMode,正是 #12339 在重写的区域,在那里再放一个写入方只会产生冲突。有一个真实的范围问题我留到代码审查再说——传给 recordBlock 的 actionFingerprint 参数,对上限修复而言是否是必需的,还是顺带夹进来的第二个、更小的行为变更。

风险: 无升级风险信号——两个改动文件都不匹配与 revert 相关的路径清单。给后续 reviewer 的一点提示:本 PR 的核心主张是行为性的,新增测试直接断言了 applyAutoModeDecision 的返回 kind,因此单元层被钉得很牢。但它上面那一层——fallback 结果是否真的渲染成人工审批弹窗——新增测试没有覆盖,我会在 Stage 2 点名对应的验证通道。

进入代码审查 🔍

— Qwen Code · qwen3.8-max-2026-09-02

Reviewed at da26d9d77eb6c900eb9d7ef85faff284d46325a2 · re-run with @qwen-code /triage

@qwen-code-review-bot

qwen-code-review-bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Code review

I wrote down what I'd have done before opening the diff: make the blocked:destructive-command case compute the post-increment state, consult shouldFallback on it, and on a cap return kind: 'fallback' with formatDenialFallbackMessage(...) after consumePendingManualRetry — i.e. lift the escalation block straight out of the classifier case. That is what this PR does. No simpler path that I can see, and it removes an asymmetry rather than adding one.

The escalation itself is correct. I walked each cap against denialTracking.ts: counters(0,0,0,0) → consecutiveBlock=1, totalBlock=1, every threshold missed → still a hard block, so the first destructive denial is unchanged; consecutiveBlock reaching 3 → consecutive_block; totalBlock reaching 20 → total_denial, which shouldFallback checks first so it correctly wins over the consecutive cap. The two existing tests that pin the sibling behaviour (applyAutoModeDecision handles blocked:destructive-command, preserves an armed retry when the destructive guard preempts it) both still hold under the new code — I traced the second one by hand, since it's the one the change could plausibly have broken.

The subtlest line in the diff is right. shouldFallback(blockedState) is called without the fingerprint argument, and that's load-bearing: recordBlock has just set pendingManualRetryFingerprint = actionFingerprint, so passing the fingerprint too would satisfy shouldFallback's fourth condition on the very first destructive denial and route every destructive command straight to a manual prompt — collapsing the deterministic guard whose whole point is to run before the LLM classifier. The PR description calls this out explicitly, which is the kind of thing that usually gets missed.

Downstream consumers — all three read, none need changing. coreToolScheduler.ts at both call sites (pre-execution and pending-tool re-evaluation) and the ACP Session.ts already switch (outcome.kind) with a case 'fallback' that classifies consecutive_block / total_denial via isDenialFallbackReason, registers the call in autoModeFallbackCallIds, and decorates the confirmation. The new branch always supplies a message, so the decoration path is reached in all three. AutoModeFallbackConfirmation['reason'] in tools.ts already lists both reasons and AutoModeOutcome is unchanged, so nothing widens at the type level either.

One consequence worth stating rather than fixing here: shouldFirePermissionDeniedForAutoMode narrows to decision.via === 'classifier', so a destructive denial has never fired the PermissionDenied hook and still won't — including now that it can return fallback. That's consistent with today's hard-block behaviour, but hook consumers stay blind to a destructive denial escalating to manual approval. Pre-existing shape, not a regression from this diff.

Two non-blocking points, both about the actionFingerprint argument to recordBlock. It isn't needed for the caps — those read counters only — so it's a second, smaller behaviour change riding along with the fix:

  • recordBlock(state, fp) overwrites pendingManualRetryFingerprint, where recordBlock(state) preserved whatever was there. So a destructive denial now clobbers a pending retry that an earlier classifier block armed for a different action. Concretely: classifier blocks G and AUTO_MODE_DENIAL_GUIDANCE tells the model "retry the same tool call without changing its arguments"; the model then attempts destructive F, which replaces pending=G with pending=F; when the model retries G, prepareAutoModeFallback no longer matches and G goes back through the classifier instead of to the user. On main that interleaving kept G's route alive. It's narrow, and it's arguably intended — denialTracking.ts documents single-slot semantics and the classifier branch already clobbers identically, so this makes the destructive branch consistent. But no test covers the different-action case: preserves an armed retry... arms the retry for the same fingerprint, where overwriting-with-F and preserving-F are indistinguishable. Worth either a line of intent in the description or a test; dropping the argument is also fine, since the caps work without it.
  • Passing the fingerprint also doesn't, on its own, make the exact-action retry route reachable for destructive commands — the L5.2.5 guard sits above the skipClassifierReason check in evaluateAutoMode, so while a command stays destructive it returns blocked:destructive-command and classifier_blocked_retry can't fire. It isn't dead code either: the guard's verdict is prompt- and cwd-dependent (userMentionsDiscard, isAmendOfSessionCommit), so the same fingerprint can stop being destructive later, at which point the armed retry does surface a manual prompt instead of a classifier roll. That's a mild safety win. The ordering that limits it is defect 4c in the report and is deliberately left to fix(shell): use bash word separators for shell parsing #12339's region — just flagging that the "no exact-action manual-review route" half of the rationale is only partly addressed by this diff, and the cap escalation is the part that actually lands.

Small user-visible polish. The composed fallback string nests one piece of advice inside another. For the amend case at the consecutive cap the user sees: Auto mode reached its consecutive denial limit on this action (Blocked "git commit --amend": ... To proceed, use manual approval.). Review it manually. For the destructive-git case it's worse — the inner text says "explicitly mention discarding local work in your prompt" while the wrapper says "Review it manually", which are different instructions. Related: #13570 also asked for this message to be reworded, and the deferral list in the description names defects 1/2/3/4c but not the reword — worth recording so it doesn't get silently dropped.

Everything else is clean and matches house conventions: the block-scoped case is correctly braced for its new const, the tests reuse the file's existing apply() / counters() helpers instead of building new mocks, and the DestructiveVerdict alias mirrors the ClassifierVerdict pattern already at the top of the file.

Test evidence — the PR's own CI

This is an unattended CI run, so I did not build or execute anything from this PR; the signal below is the PR's own checks on the reviewed commit, fetched through the API. No failures at the time of writing, but the two checks that matter most for this diff are still running — Test (ubuntu-latest, Node 22.x) is the one that runs autoMode.test.ts, and Lint & Static covers the typecheck. Treat this table as provisional until they land.

Final CI results for da26d9d (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

The description reports 5 new tests (4 failing before the change), 138 passing across the two files, plus single-line mutation checks that reddened exactly the expected tests. That is good non-vacuity discipline and much stronger than the usual "tests pass" — but it is the author's claim, not independently re-run here, and I can't re-run it on this path.

Sandboxed verification would settle the part the new tests don't reach: @qwen-code /verify — the 5 tests stop at applyAutoModeDecision's return value, so nothing here proves a destructive-branch fallback outcome actually renders as a manual-approval prompt through the scheduler's case 'fallback' → decorateAutoModeFallbackConfirmation path, nor that the composed message above is what the user sees. @qwen-code /tmux would cover the same claim at the TUI surface. The author has write access, so both lanes are available without sponsorship.

中文说明

代码审查

我在看 diff 之前先写下了自己的方案:让 blocked:destructive-command 分支算出自增后的状态、对其调用 shouldFallback,命中上限时在 consumePendingManualRetry 之后返回带 formatDenialFallbackMessage(...) 的 kind: 'fallback'——也就是把 classifier 分支里的升级逻辑原样搬过来。这个 PR 做的正是这件事。我想不到更简的路径,而且它是消除了一个不对称,而不是新增了一个。

升级逻辑本身是正确的。 我对照 denialTracking.ts 逐个上限走了一遍:counters(0,0,0,0) → consecutiveBlock=1、totalBlock=1,所有阈值都未触及 → 仍然是硬拒绝,因此首次破坏性拒绝的行为未变;consecutiveBlock 到 3 → consecutive_block;totalBlock 到 20 → total_denial,而 shouldFallback 先判总上限,所以它会正确地压过连续上限。钉住兄弟路径行为的两个既有测试(applyAutoModeDecision handles blocked:destructive-command、preserves an armed retry when the destructive guard preempts it)在新代码下都仍然成立——第二个我手工推了一遍,因为它正是本次改动最有可能弄坏的那个。

diff 里最微妙的一行是对的。 shouldFallback(blockedState) 调用时没有传 fingerprint 参数,而这一点是有实际作用的:recordBlock 刚刚把 pendingManualRetryFingerprint 设成了 actionFingerprint,所以如果再把 fingerprint 传进去,第一次破坏性拒绝就会满足 shouldFallback 的第四个条件,把每一条破坏性命令都直接送去人工弹窗——那个确定性守卫存在的意义正是「在 LLM 分类器之前执行」,这样就把它架空了。PR 描述明确点出了这一点,而这类细节通常是会被漏掉的。

下游消费方——三个我都读了,都不需要改。 coreToolScheduler.ts 的两个调用点(执行前与 pending-tool 重评估)以及 ACP 的 Session.ts,都已经在 switch (outcome.kind) 里带了 case 'fallback',通过 isDenialFallbackReason 归类 consecutive_block / total_denial、把调用登记进 autoModeFallbackCallIds 并装饰确认框。新分支总会带上 message,所以三条路径都会走到装饰逻辑。tools.ts 里的 AutoModeFallbackConfirmation['reason'] 已经列出了这两个 reason,AutoModeOutcome 也未改动,因此类型层面同样没有任何扩张。

有一点值得说明而不是在这里修:shouldFirePermissionDeniedForAutoMode 把范围收窄到 decision.via === 'classifier',所以破坏性拒绝从来不会触发 PermissionDenied hook,现在也不会——即便它如今可以返回 fallback。这与今天的硬拒绝行为是一致的,但意味着 hook 消费方看不到「破坏性拒绝升级为人工审批」这件事。这是既有的形态,不是本 diff 引入的回归。

两个不阻塞的点,都与传给 recordBlock 的 actionFingerprint 参数有关。 上限逻辑并不需要它——上限只读计数器——所以它是随修复一起搭进来的第二个、更小的行为变更:

  • recordBlock(state, fp) 会覆盖 pendingManualRetryFingerprint,而 recordBlock(state) 会保留原值。因此破坏性拒绝现在会清掉此前由 classifier 拒绝为另一个动作武装的 pending retry。具体场景:classifier 拒绝了 G,AUTO_MODE_DENIAL_GUIDANCE 告诉模型「不改动参数重试同一次工具调用」;模型接着尝试破坏性命令 F,pending=G 被换成 pending=F;等模型重试 G 时,prepareAutoModeFallback 已不再匹配,G 会重新走分类器而不是交给用户。在 main 上,这种交错会保住 G 的那条路径。范围很窄,而且可以说是有意为之——denialTracking.ts 记录了单槽语义,classifier 分支本来就以同样方式覆盖,所以这只是让破坏性分支与之一致。但没有测试覆盖「不同动作」这一情形:preserves an armed retry... 是为同一个 fingerprint 武装的,在那里「用 F 覆盖」与「保留 F」无法区分。建议在描述里加一句意图说明,或者补一个测试;直接去掉这个参数也可以,因为没有它上限照样工作。
  • 传 fingerprint 本身也并没有让破坏性命令的「精确动作重试」路径变得可达——L5.2.5 守卫在 evaluateAutoMode 中位于 skipClassifierReason 检查之上,所以只要一条命令仍然是破坏性的,它就会返回 blocked:destructive-command,classifier_blocked_retry 无从触发。但它也不是死代码:守卫的判定依赖 prompt 与 cwd(userMentionsDiscard、isAmendOfSessionCommit),所以同一个 fingerprint 之后可能不再被判为破坏性,那时被武装的 retry 确实会弹出人工审批,而不是再掷一次分类器。这是一个轻微的安全性收益。限制它的顺序问题就是报告中的缺陷 4c,已刻意留给 fix(shell): use bash word separators for shell parsing #12339 所在区域处理——这里只是提醒:rationale 中「没有精确动作的人工复核路径」这一半,本 diff 只解决了一部分,真正落地的是上限升级那一部分。

一个用户可见的小打磨点。 组合出来的 fallback 文案把一条建议嵌进了另一条里。amend 这一支在连续上限处,用户看到的是:Auto mode reached its consecutive denial limit on this action (Blocked "git commit --amend": ... To proceed, use manual approval.). Review it manually.;破坏性 git 那一支更别扭——内层说「在 prompt 中明确提到丢弃本地改动」,外层却说「请人工复核」,这是两条不同的指示。另外相关的一点:#13570 也要求改写这条提示文案,而描述里的推迟清单点名了缺陷 1/2/3/4c,没有包含这个改写——建议记录一下,以免被静默丢掉。

其余部分干净且符合仓库约定:新增 const 后 case 正确地加了块级花括号;测试复用了文件里既有的 apply() / counters() 辅助函数,而不是新造 mock;DestructiveVerdict 别名也与文件顶部既有的 ClassifierVerdict 写法一致。

测试证据——PR 自身的 CI

这是一次无人值守的 CI 运行,因此我没有构建或执行本 PR 的任何代码;下面的信号取自被审 commit 上 PR 自身的 check,通过 API 获取。截至撰写时没有失败项,但对这份 diff 最关键的两个 check 仍在运行——Test (ubuntu-latest, Node 22.x) 才是跑 autoMode.test.ts 的那个,Lint & Static 覆盖 typecheck。在它们落地之前,请把这张表当作临时结果。

(CI 表格见上方英文部分,未重复粘贴。)

描述中报告了 5 个新增测试(改动前 4 个失败)、两个文件合计 138 个通过,以及逐行回退的变异验证——每次恰好让预期的测试变红。这是很好的非空断言验证,比常见的「测试通过」强得多——但那是作者的说法,本次未独立复跑,在这条路径上我也无法复跑。

沙箱验证可以补齐新增测试没有覆盖的那一层:@qwen-code /verify——这 5 个测试止步于 applyAutoModeDecision 的返回值,因此没有任何东西证明破坏性分支产生的 fallback 结果真的能经由 scheduler 的 case 'fallback' → decorateAutoModeFallbackConfirmation 渲染成人工审批弹窗,也无法证明上面那段组合文案就是用户看到的内容。@qwen-code /tmux 可以在 TUI 层面覆盖同一主张。作者具备写权限,两条通道都无需赞助即可触发。

— Qwen Code · qwen3.8-max-2026-09-02

Reviewed at da26d9d77eb6c900eb9d7ef85faff284d46325a2 · re-run with @qwen-code /triage

@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the fix is correct, minimal and mirrors an adjacent branch that already works; the two things I'd still want are a note of intent (or a test) for the fingerprint argument, and a look at the nested fallback message.

Stepping back: this is a real dead end being closed, not theoretical hardening. I confirmed the defect against main rather than the description — blocked:destructive-command was the one denial producer in applyAutoModeDecision that could never reach either cap, so a session hitting a destructive guard was pinned to unconditional blocking while its own denial text advised a manual-approval route that was never armed. The linked issue is open, labelled type/bug + priority/P2 + category/security, carries a reproduction, and its suggested fix names this exact change. That's about as well-sourced a fix: PR as the gate gets to see.

My independent proposal before reading the diff was to lift the escalation block out of the classifier case, and that's what landed — so there's no simpler path I'm holding back on. Eighteen production lines, no new exports or abstractions, and it removes an asymmetry instead of adding one. Six months from now this reads as one fewer special case, which is the direction I want.

Two things keep this at 4 rather than 5, neither blocking:

  1. The actionFingerprint argument to recordBlock isn't needed for the caps and quietly changes a second behaviour — a destructive denial now overwrites a pending retry that an earlier classifier block armed for a different action, where main preserved it. It's consistent with the documented single-slot semantics and with the classifier branch, so it may well be intended; it's just not stated, and no test distinguishes it. A sentence of intent or one more test would settle it.
  2. The composed fallback message nests one instruction inside another, and for the destructive-git case the two instructions disagree. That string is user-visible in the approval dialog. Related, Auto mode blocks inert text that merely mentions the amend phrase, ahead of the user's own ask rule and with no escape hatch #13570 also asked for the denial message to be reworded and the deferral list doesn't mention it — worth recording so it isn't lost.

The scope discipline here is worth calling out on its own: defects 1, 2, 3 and 4c all sit in evaluateAutoMode, the region #12339 is actively rewriting, and this PR deliberately leaves them alone rather than setting up a conflict. That's the right trade and it's the reason the diff stayed reviewable.

On evidence, to be plain about what this run did and did not establish: this is an unattended CI run, so I executed nothing from the PR. I verified the escalation logic by reading denialTracking.ts and walking each cap, I read all three downstream consumers and confirmed each already handles a fallback outcome with these reasons, and I confirmed the type surface doesn't widen. The author's 5 new tests, the 138-passing count and the single-line mutation results are their claim, not independently re-run — and CI on this commit is still in flight, with Test (ubuntu-latest, Node 22.x) (the leg that runs autoMode.test.ts) and Lint & Static both pending and nothing red so far.

So: approving on merit, with the approval deferred until CI lands green on da26d9d77eb6c900eb9d7ef85faff284d46325a2. If anything comes back red, or the head moves, the finalize job withholds it and flags the status instead. The two notes above are follow-ups, not merge blockers.

中文说明

Confidence: 4/5 —— 修复正确、最小,并且对齐了一个本就能正常工作的相邻分支;我仍然希望补上的两点是:为 fingerprint 参数加一句意图说明(或一个测试),以及看一下嵌套的 fallback 文案。

退一步看整体:这是在关闭一个真实存在的死胡同,不是理论性加固。我是对照 main 而非 PR 描述来确认缺陷的——blocked:destructive-command 是 applyAutoModeDecision 中唯一一个永远触不到任何上限的拒绝来源,因此一旦会话撞上破坏性命令守卫,就会被钉死在无条件拒绝上,而它自己的拒绝文案却建议走一条从未被武装的人工审批路径。关联 issue 处于开启状态,带有 type/bug + priority/P2 + category/security 标签,附有复现步骤,其建议修复方案点名的正是这个改动。就 gate 所能见到的 fix: PR 而言,这几乎是来源最扎实的一类。

我在看 diff 之前独立写下的方案就是把 classifier 分支里的升级逻辑搬过来,而落地的正是如此——所以我手里并没有藏着一条更简的路径。18 行生产代码,没有新增导出或抽象,而且它消除了一个不对称,而不是增加了一个。六个月后再读,这里是少了一个特例,正是我想要的方向。

有两点让它停在 4 分而不是 5 分,都不阻塞合并:

  1. 传给 recordBlock 的 actionFingerprint 参数对上限逻辑并非必需,却悄悄改变了第二个行为——破坏性拒绝现在会覆盖此前由 classifier 拒绝为另一个动作武装的 pending retry,而在 main 上它会保留原值。这与文档记录的单槽语义以及 classifier 分支的做法一致,所以很可能就是有意为之;只是没有写明,也没有测试能把它区分出来。补一句意图说明或一个测试就能定论。
  2. 组合出来的 fallback 文案把一条指示嵌进了另一条里,而破坏性 git 那一支的两条指示彼此矛盾。这个字符串是用户在审批弹窗里看得见的。另外相关的一点:Auto mode blocks inert text that merely mentions the amend phrase, ahead of the user's own ask rule and with no escape hatch #13570 也要求改写拒绝提示文案,而推迟清单里没有提到它——建议记录一下,以免丢失。

这里的范围克制值得单独点出来:缺陷 1、2、3 和 4c 都位于 evaluateAutoMode,正是 #12339 在重写的区域,本 PR 刻意不去碰它们,而不是制造一次冲突。这个取舍是对的,也正是 diff 能够保持可审查的原因。

关于证据,把本次运行做了什么、没做什么说清楚:这是无人值守的 CI 运行,因此我没有执行 PR 中的任何代码。我通过阅读 denialTracking.ts 并逐个走查上限来验证升级逻辑,读了全部三个下游消费方并确认每一个都已经能处理带这两个 reason 的 fallback 结果,也确认了类型面没有扩张。作者报告的 5 个新增测试、138 个通过与逐行变异结果,属于作者的说法,未经独立复跑——而该 commit 上的 CI 仍在进行,Test (ubuntu-latest, Node 22.x)(跑 autoMode.test.ts 的那一条)与 Lint & Static 都还未完成,目前没有任何红项。

因此:基于实质内容认可,但审批推迟到 CI 在 da26d9d77eb6c900eb9d7ef85faff284d46325a2 上全绿之后。若有任何一项返回红色,或 head 发生移动,finalize 作业会扣留审批并改为标记状态。上面两点是后续跟进项,不是合并阻塞项。

— Qwen Code · qwen3.8-max-2026-09-02

Reviewed at da26d9d77eb6c900eb9d7ef85faff284d46325a2 · re-run with @qwen-code /triage

@qwen-code-review-bot qwen-code-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship — CI landed green after the review. ✅

@qwen-code-review-bot qwen-code-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: reverse audit — stopped before round 3 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查:反向审计——评审时间预算不足,未能开始第 3 轮。

— qwen3.8-max via Qwen Code /review (v0.25.0)

config.setAutoModeDenialState(recordBlock(denialState));
case 'blocked:destructive-command': {
const blockedState = recordBlock(denialState, actionFingerprint);
const fallback = shouldFallback(blockedState);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-1: shouldFallback(blockedState) inherits the shared session cap, and hasReachedTotalCap sums totalBlock + totalUnavailable (denialTracking.ts:47-53). recordUnavailable is driven purely by classifier infrastructure failure (autoMode.ts:605-612 on decision.unavailable, set in classifier.ts:265 and :384), so a session with zero policy blocks and zero destructive commands can arm maxTotalDenials from outage traffic alone — and from that point the deterministic L5.2.5 guard's first denial of git reset --hard returns kind: 'fallback' instead of a hard error.

Concretely: a session suffers a classifier API outage, 20 recordUnavailable events accumulate and the user approves 9 outage prompts to keep working, so totalBlock stays 0 and nothing was ever blocked on policy. The model then issues its first git reset --hard. Before this change that is a hard error carrying the destructive reason and the anti-workaround guidance; after it, hasReachedTotalCap is already true, shouldFallback returns total_denial (which takes precedence over consecutive_block, denialTracking.ts:158-160), and the guard's verdict becomes an approvable prompt in exactly the prompt-fatigued session the deterministic guard exists to survive — with a banner that does not mention the command is destructive (see the comment on the message line below).

Three committed statements say the opposite: destructive-commands.ts:183-184 ("failures here are hard blocks regardless of classifier availability"), autoMode.ts:841-843 ("Regex-based hard blocks ... so API failures or classifier misjudgment cannot allow destructive git/IaC commands through"), and docs/design/auto-classifier-unavailable-fallback.md:70. Your Risk & Scope section does disclose the tradeoff for "3 consecutive blocks (or 20 session denials)", but the disclosed state is 20 denials; nothing argues for a cap armed by unavailability records, and the new test comments argue the sibling state instead.

Witness (A/B probe over the real compiled prepareAutoModeFallback -> evaluateAutoMode -> applyAutoModeDecision; BASE arm = this worktree's compiled autoMode.js with only the case 'blocked:destructive-command' block replaced by the verbatim merge-base blob git show 9cf2488f52:packages/core/src/permissions/autoMode.ts; arm proof grep -c "const blockedState = recordBlock(denialState, actionFingerprint);" = BASE 1 / PR 2):

BASE totals before the call {"unavailableEvents":20,"outagePromptsApproved":9,"totalBlock":0,"totalUnavailable":20}
BASE OUTCOME `git reset --hard` {"kind":"blocked","reason":"classifier_blocked"}
BASE what the model receives    {"errorMessage":"Blocked destructive git command: \"git reset --hard\". ... stop and ask the user for explicit approval. ..."}
PR   OUTCOME `git reset --hard` {"kind":"fallback","reason":"total_denial"}
PR   what the user receives     {"message":"Auto mode reached its session denial limit. Review this action manually."}

Either exempt this branch from the total_denial route and keep consecutive_block — the same-action anti-loop case the PR set out to fix — by honouring the escalation here only when fallback.reason === 'consecutive_block', or comparing blockedState.totalBlock alone against maxTotalDenials on this path; or, if the shared cap is intended for destructive denials too, update the three statements above and name the outage-armed case in Risk & Scope.

The caps are shared with the classifier path and must not move: AUTO_MODE_DENIAL_LIMITS = { maxConsecutiveBlock: 3, maxConsecutiveUnavailable: 2, maxTotalDenials: 20 } (denialTracking.ts:41-45), and hasReachedTotalCap deliberately sums both totals so "alternating denial modes cannot avoid fallback" (denialTracking.ts:19-21, :47-53) — a fix scoped to this branch must leave the classifier branch's totals semantics intact.

If you add the filter, please add the test that pins it: assert apply(destructive(), counters(0, 0, 0, 19), fingerprint) still returns kind: 'blocked' (19 unavailable records, zero policy blocks), then remove the filter and confirm that test goes red.

中文说明

shouldFallback(blockedState) 复用了会话级共享上限,而 hasReachedTotalCap 统计的是 totalBlock + totalUnavailable 之和(denialTracking.ts:47-53)。recordUnavailable 完全由分类器基础设施失败驱动(autoMode.ts:605-612 读取 decision.unavailable,由 classifier.ts:265 与 :384 设置),因此一个没有任何策略拒绝、也没有任何破坏性命令的会话,仅靠分类器不可用记录就能把 maxTotalDenials 顶满——从那一刻起,L5.2.5 确定性守卫对 git reset --hard 的首次拒绝就会返回 kind: 'fallback',而不是硬错误。

具体场景:会话遭遇分类器 API 故障,累计 20 条 recordUnavailable,用户为了继续工作批准了 9 次故障提示,于是 totalBlock 始终为 0、没有任何命令因策略被拒。此时模型第一次发出 git reset --hard。改动前这是一个硬错误,带有破坏性原因说明和「不要绕过」指引;改动后 hasReachedTotalCap 已经为真,shouldFallback 返回 total_denial(它优先于 consecutive_block,denialTracking.ts:158-160),守卫的判定变成了一个可批准的弹窗——而这恰恰发生在确定性守卫本该顶住的那种「用户已被提示疲劳」的会话里,并且弹窗文案完全没有提到该命令是破坏性的(见下方 message 那一行的评论)。

有三处已提交的文字与这一行为相反:destructive-commands.ts:183-184(「这里的失败是与分类器可用性无关的硬拒绝」)、autoMode.ts:841-843(「基于正则的硬拒绝……使得 API 故障或分类器误判都无法放行破坏性 git/IaC 命令」),以及 docs/design/auto-classifier-unavailable-fallback.md:70。PR 的 Risk & Scope 确实披露了「3 次连续拒绝(或 20 次会话拒绝)」这一权衡,但披露的是 20 次拒绝;没有任何地方论证过「由不可用记录顶满的上限」,而新增测试的注释论证的是另一种状态。

证据(对真实编译产物 prepareAutoModeFallback -> evaluateAutoMode -> applyAutoModeDecision 做 A/B 探针;BASE 分支 = 本 worktree 已编译的 autoMode.js,仅把 case 'blocked:destructive-command' 整块替换为 merge-base 原文 git show 9cf2488f52:packages/core/src/permissions/autoMode.ts;分支证明 grep -c "const blockedState = recordBlock(denialState, actionFingerprint);" = BASE 1 / PR 2):

BASE 调用前的计数 {"unavailableEvents":20,"outagePromptsApproved":9,"totalBlock":0,"totalUnavailable":20}
BASE `git reset --hard` 的结果 {"kind":"blocked","reason":"classifier_blocked"}
BASE 模型收到的内容           {"errorMessage":"Blocked destructive git command: \"git reset --hard\". ... stop and ask the user for explicit approval. ..."}
PR   `git reset --hard` 的结果 {"kind":"fallback","reason":"total_denial"}
PR   用户看到的内容            {"message":"Auto mode reached its session denial limit. Review this action manually."}

两种修法任选:其一,让本分支不走 total_denial 路径、只保留 consecutive_block(也就是本 PR 原本要修的「同一动作反复被拒」场景)——例如仅当 fallback.reason === 'consecutive_block' 时才升级,或在这条路径上只用 blockedState.totalBlock 与 maxTotalDenials 比较;其二,如果共享上限对破坏性拒绝同样适用是有意的,则更新上述三处文字,并在 Risk & Scope 中点明「由故障记录顶满上限」这一情形。

上限与分类器路径共享,不能改动:AUTO_MODE_DENIAL_LIMITS = { maxConsecutiveBlock: 3, maxConsecutiveUnavailable: 2, maxTotalDenials: 20 }(denialTracking.ts:41-45),并且 hasReachedTotalCap 有意把两项总数相加,以便「交替出现的拒绝模式无法绕过 fallback」(denialTracking.ts:19-21、:47-53)——限定在本分支的修法必须保持分类器分支的总数语义不变。

如果加上这个过滤,请同时补上钉住它的测试:断言 apply(destructive(), counters(0, 0, 0, 19), fingerprint) 仍返回 kind: 'blocked'(19 条不可用记录、0 次策略拒绝),然后移除过滤并确认该测试变红。

— qwen3.8-max via Qwen Code /review (v0.25.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mechanism confirmed, but not fixing it in this pass — this one moves a permission boundary on a deterministic safety guard and needs a maintainer's call. Leaving it unresolved.

Verified at fa1c3a9fc7: hasReachedTotalCap sums both totals (denialTracking.ts:47-51), recordUnavailable increments totalUnavailable (denialTracking.ts:143), total_denial takes precedence over consecutive_block (denialTracking.ts:157-161), and the destructive branch runs shouldFallback over that shared state (autoMode.ts:590). So a session whose 20 records are all classifier-outage traffic does arm the cap at totalBlock === 0, and the first git reset --hard then returns kind: 'fallback' instead of the hard error. The three committed statements you quote are accurate as written: destructive-commands.ts:183-184 ("failures here are hard blocks regardless of classifier availability"), autoMode.ts:841-843, and docs/design/auto-classifier-unavailable-fallback.md:70.

Why it is not in fa1c3a9fc7 — the two fixes you offer both change a boundary, and they contradict what this PR already committed to:

  1. Honouring the escalation only on consecutive_block requires deleting degrades to manual approval at the session total-denial cap (counters(0, 0, 19, 0) -> total_denial), a test this PR added on purpose and whose comment argues the opposite position ("Destructive denials previously counted towards the cap but could never trigger it"). That reverses a stated design decision rather than tightening an oversight.
  2. Comparing blockedState.totalBlock alone against maxTotalDenials on this path gives the destructive branch different totals semantics from the classifier branch, against the explicit invariant at denialTracking.ts:19-21 that alternating denial modes cannot avoid fallback.

Either way the user-visible outcome flips — hard error carrying the destructive reason and the anti-workaround guidance, versus an approvable banner — so it also needs before/after terminal evidence on a real prompt, not just a unit test. That is not something to decide inside a Suggestion-level cleanup.

The decision for a maintainer is binary: (a) restrict the route to consecutive_block, then update the three statements and delete or repoint the total-cap test; or (b) keep the shared cap, then update the same three statements and name the outage-armed case explicitly in Risk & Scope, since "20 session denials" as currently disclosed does not tell a reviewer that unavailability traffic alone can arm it.

Nothing in fa1c3a9fc7 changes this behaviour — that commit is test-only (autoMode.test.ts, +28/-4).

Comment thread packages/core/src/permissions/autoMode.ts
if (fallback.fallback) {
config.setAutoModeDenialState(consumePendingManualRetry(blockedState));
return {
kind: 'fallback',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: This return falsifies a failure boundary stated in committed documentation that the diff does not update. docs/design/auto-classifier-unavailable-fallback.md:70, under ## Failure boundaries, reads "Explicit permission denies and deterministic destructive-command blocks remain errors."; docs/users/features/auto-mode.md:224 attributes the fallback to "3 consecutive policy blocks". After this change a deterministic destructive-command block returns kind: 'fallback', not an error, once consecutiveBlock reaches 3 or session totalBlock + totalUnavailable reaches 20.

The cost is that a maintainer or security reviewer deciding whether AUTO mode can ever surface git reset --hard / terraform destroy as an approval prompt reads that failure-boundary line and concludes it cannot. The next change written on that premise — a new consumer of AutoModeOutcome, a new transport, or a test asserting a hard-blocked command can never reach a confirmation dialog — is built against an invariant the implementation no longer has, and nothing in the doc points at the exception. The concrete states are the two caps, including a first destructive denial in a session already at 19 unrelated denials, which this diff's own counters(0, 0, 19, 0) test pins.

Witness (both docs read at the reviewed commit — the diff touches exactly two files, autoMode.ts and autoMode.test.ts, and neither doc is in it — plus a pristine pipeline probe showing a deterministic destructive block does not remain an error at the cap):

docs/design/auto-classifier-unavailable-fallback.md:70  "- Explicit permission denies and deterministic destructive-command blocks remain errors."
docs/users/features/auto-mode.md:224                    "After **3 consecutive policy blocks** the next tool call falls back to the standard manual-approval prompt."
P2 round1 preparedFallback={"fallback":false} via=blocked:destructive-command
 outcome.kind=fallback reason=consecutive_block
 message="Auto mode reached its consecutive denial limit on this action (Blocked destructive git command: \"git reset --hard\". ...). Review it manually."

Two corrections in your favour, both measured: :20 of the design doc is not falsified — the guard still runs and still hard-blocks below the caps — and auto-mode.md never documented the 20-denial cap (grep -n "20\b" there matches only :148, about environment entries), so :224 alone is the stale user-doc sentence. Scoping the design-doc bullet would close it, e.g. "Explicit permission denies remain errors. Deterministic destructive-command blocks remain errors until denial tracking reaches a cap; at the consecutive-block or session-total cap they fall back to manual approval like classifier blocks (see applyAutoModeDecision).", plus the same rule in docs/users/features/auto-mode.md's "Fallback to manual approval" section.

docs/design/auto-classifier-unavailable-fallback.md has no .zh-CN.md counterpart (measured: a glob over docs/design returns the single English file). That gap is pre-existing and this diff triggers nothing, but AGENTS.md requires linked English and Chinese versions kept synchronised in the same change when an existing design is updated — so fixing :70 by editing the design doc brings that rule into play. docs/users/features/ holds no .zh-CN.md files at all, so auto-mode.md is English-only by convention.

中文说明

这个 return 使已提交文档中的一条失效边界不再成立,而本 diff 没有更新该文档。docs/design/auto-classifier-unavailable-fallback.md:70 在 ## Failure boundaries 下写着「显式权限拒绝与确定性破坏性命令拒绝仍然是错误。」;docs/users/features/auto-mode.md:224 把 fallback 归因于「3 次连续策略拒绝」。改动之后,一旦 consecutiveBlock 达到 3、或会话 totalBlock + totalUnavailable 达到 20,确定性破坏性命令拒绝返回的就是 kind: 'fallback',而不是错误。

代价是:当维护者或安全评审者要判断 AUTO 模式是否可能把 git reset --hard / terraform destroy 呈现为审批弹窗时,他会读到那条失效边界并得出「不可能」的结论。基于该前提写出的下一处改动——新的 AutoModeOutcome 消费方、新的传输通道,或一个断言「硬拒绝命令永远到不了确认对话框」的测试——就建立在实现已不再具备的不变量之上,而文档中没有任何地方指向这个例外。具体状态就是那两个上限,包括在一个已累计 19 次无关拒绝的会话中首次破坏性拒绝——本 diff 自己的 counters(0, 0, 19, 0) 测试正是钉住了它。

证据(两份文档均在被审 commit 上读取——diff 只改动 autoMode.ts 与 autoMode.test.ts 两个文件,两份文档都不在其中——另加一次在未改动源码上的流水线探针,显示确定性破坏性拒绝在上限处不再是错误):

docs/design/auto-classifier-unavailable-fallback.md:70  "- Explicit permission denies and deterministic destructive-command blocks remain errors."
docs/users/features/auto-mode.md:224                    "After **3 consecutive policy blocks** the next tool call falls back to the standard manual-approval prompt."
P2 round1 preparedFallback={"fallback":false} via=blocked:destructive-command
 outcome.kind=fallback reason=consecutive_block
 message="Auto mode reached its consecutive denial limit on this action (Blocked destructive git command: \"git reset --hard\". ...). Review it manually."

有两处修正对你有利,均为实测:设计文档的 :20 没有失效——守卫仍然运行,且在未达上限时仍然硬拒绝;auto-mode.md 也从未记载过 20 次拒绝的上限(在该文件中 grep -n "20\b" 只匹配到 :148,讲的是 environment 条目数量),因此过时的用户文档句子只有 :224 一处。把设计文档那一条限定范围即可修复,例如「显式权限拒绝仍然是错误。确定性破坏性命令拒绝在拒绝计数达到上限之前仍然是错误;在连续拒绝上限或会话总上限处,它们与分类器拒绝一样降级为人工审批(见 applyAutoModeDecision)。」,并在 docs/users/features/auto-mode.md 的「Fallback to manual approval」一节补上同样的规则。

docs/design/auto-classifier-unavailable-fallback.md 没有对应的 .zh-CN.md(实测:对 docs/design 做 glob 只返回这一个英文文件)。这个缺口是既有的,本 diff 也不会触发它;但 AGENTS.md 要求在更新既有设计时,同一次改动中提供互链的英文与中文版本并保持同步——因此如果通过修改设计文档来修 :70,该规则就会生效。docs/users/features/ 下完全没有 .zh-CN.md 文件,所以按惯例 auto-mode.md 只有英文版。

— qwen3.8-max via Qwen Code /review (v0.25.0)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially fixed in 2add596. The design-doc half is coupled to the ruling requested on the shouldFallback(blockedState) thread, so I am leaving this unresolved rather than pre-empting it.

Delivered — docs/users/features/auto-mode.md, "Fallback to manual approval": the bullet no longer attributes the cap to "3 consecutive policy blocks". It now reads "After 3 consecutive blocks — classifier policy blocks and deterministic destructive-command blocks both count — the call that reaches the third block falls back to the standard manual-approval prompt." Two deliberate differences from your suggested wording:

  • It says the call that reaches the third block falls back, not "the next tool call". shouldFallback is evaluated on the post-increment state (autoMode.ts:589-592), so the old phrasing overstated the number of hard blocks by one. denialTracking.ts's header already states the same-call rule ("Calls that reach a consecutive threshold or the total denial cap also fall back on that same call"), and both cap tests in autoMode.test.ts assert it.
  • The 20-denial cap stays undocumented in that file, matching your measurement that it never was documented there.

Not delivered — docs/design/auto-classifier-unavailable-fallback.md:70. Two reasons:

  1. The correct replacement sentence is the open decision on the other thread. Under "restrict this branch to consecutive_block" the bullet must say destructive blocks fall back only at the consecutive cap; under "keep the shared cap" it must additionally name the outage-armed case. Writing either now pre-empts that ruling and would be rewritten by whichever commit implements it.
  2. That file has no .zh-CN.md twin, and AGENTS.md's design-doc rule requires both versions "complete and synchronized in the same change, including when updating an existing design". So a one-line truth fix there carries a ~90-line translated file plus reciprocal language links — disproportionate next to a 16-line production change, and as you measured the gap is pre-existing rather than caused by this diff.

Both belong in the commit that implements the ruling.

One correction on the third statement you cite. autoMode.ts:841-843 ("Regex-based hard blocks ... so API failures or classifier misjudgment cannot allow destructive git/IaC commands through") is still true at this head and needs no edit. The escalation ends in a manual-approval confirmation, and decorateAutoModeFallbackConfirmation sets hideAlwaysAllow: true for every confirmation except ask_user_question (autoMode.ts:756-758). That flag is enforced rather than advisory: packages/cli/src/acp-integration/session/permissionUtils.ts:71-76 filters the visible options, packages/cli/src/serve/server/error-response.ts:203-221 answers a forged suppressed option with 400 invalid_option_id, and the TUI gates its always-allow buttons on the same flag (ToolConfirmationMessage.tsx:69-70, 291, 418). So there is no proceed_always* route by which a destructive command executes without its own human decision, and "cannot allow ... through" still holds. destructive-commands.ts:183-184 ("hard blocks regardless of classifier availability") is the statement the outage-armed cap actually contradicts; it moves with the same ruling as :70.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially delivered again in 43fef6f2b6 — a third instance of the same falsification class, in a file this review did not name.

docs/users/features/approval-mode.md:272 (Safety guardrails → Loop guard) read "after three consecutive policy blocks, the next call also falls back to manual approval". Both halves go stale for the same reasons you measured on auto-mode.md:224: deterministic destructive-command blocks now call recordBlock too, and shouldFallback is evaluated on the post-increment state (autoMode.ts:590), so it is the call that reaches the third block that falls back. It now mirrors the auto-mode.md bullet delivered in 2add596941, so the two user docs stop contradicting each other — approval-mode.md links to auto-mode.md eleven lines above.

Docs-only and ruling-neutral: under "restrict the route to consecutive_block" this sentence stays true as written, because recordBlock is shared by both block kinds and only the total-cap route would go away.

Still not delivered — docs/design/auto-classifier-unavailable-fallback.md:70. Re-verified at 43fef6f2b6, both blockers stand:

  1. The correct replacement sentence is the open pick on the shouldFallback(blockedState) thread. Under "restrict to consecutive_block" the bullet must say destructive blocks fall back only at the consecutive cap; under "keep the shared cap" it must additionally name the outage-armed case. Anything ruling-neutral would be vaguer than either and gets rewritten by whichever commit implements it.
  2. docs/design/README.md:36-42 — "When updating an existing single-language design, add the missing version and align the complete pair." That file has no .zh-CN.md twin (find docs -iname "*auto-classifier*" returns exactly one path), so a one-line truth fix carries a new translated design doc plus reciprocal language links.

No new ask is being posted: the binary decision already sits on the R1-1 thread and I am not pre-empting it.

Evidence — N/A for CLI/TUI/WebUI/wire surfaces: this delta changes documentation text only and touches no runtime path (production code is byte-identical to fd7d48aada; git diff fd7d48aada..43fef6f2b6 --stat = 1 file, docs/users/features/approval-mode.md). The observable artifact is the rendered bullet:

BEFORE  - **Loop guard**: after three consecutive policy blocks, the next call
          also falls back to manual approval so the agent isn't stuck cycling on
          a dead-end approach.
AFTER   - **Loop guard**: after three consecutive blocks — classifier policy blocks
          and deterministic destructive-command blocks both count — the call that
          reaches the third block also falls back to manual approval so the agent
          isn't stuck cycling on a dead-end approach.

Checks: npx prettier --check docs/users/features/approval-mode.md clean; npx vitest run src/permissions/autoMode.test.ts in packages/core = 107 passed / 107 (no test pins doc text, and none should — there is no behavior delta to mutate). Full pre-push review of 9cf2488f52...43fef6f2b6 (correctness + security dimensions) returned no must-fix findings.

Leaving this thread unresolved: the item you named is still open.

Comment thread packages/core/src/permissions/autoMode.test.ts Outdated
Comment thread packages/core/src/permissions/autoMode.test.ts
…tate

The five escalation tests asserted only `kind`, `reason` and the retry
token, so two regressions survived the whole suite:

- deleting `message:` from the escalation branch passed 107/107. That is
  not cosmetic: coreToolScheduler gates `autoModeFallbackCallIds.add` and
  the confirmation decoration on `outcome.message &&`, so an absent
  message leaves the prompt undecorated and the session pinned to manual
  after the user has already approved.
- persisting an un-incremented denial state on the escalation branch also
  passed 107/107, weakening the runaway-denial loop this change exists to
  break.

Both cap tests now assert a non-empty banner naming the route and the
exact persisted counters, mirroring the sibling classifier cap tests.
Mutation-verified: each mutant fails exactly these two tests.

Also reword the `arms the exact-action manual retry` comment, which was
false in both halves. `AUTO_MODE_DESTRUCTIVE_DENIAL_GUIDANCE` says "stop
and ask the user for explicit approval", not retry -- the retry sentence
lives only in `AUTO_MODE_DENIAL_GUIDANCE` on the classifier arm. And a
retry is not re-reviewed while the guard still fires: L5.2.5 runs before
the `skipClassifierReason` short-circuit, and this branch calls
`shouldFallback` without the fingerprint, so `classifier_blocked_retry`
is unreachable from it. The comment now states the real mechanism -- the
token pays off once `isDestructiveCommand` stops matching, since it is
prompt- and session-commit-dependent.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuyz3xgi47
yiliang114 and others added 3 commits October 8, 2026 13:30
`docs/users/features/auto-mode.md` attributed the consecutive-block fallback
to "3 consecutive policy blocks" only. Deterministic destructive-command
blocks now go through the same recordBlock / shouldFallback path, so they
count towards the same cap and the bullet no longer matched behaviour.

Also fixes which call falls back: shouldFallback is evaluated on the
post-increment denial state, so the call that reaches maxConsecutiveBlock is
itself the one that degrades to manual approval -- denialTracking.ts's header
says "Calls that reach a consecutive threshold or the total denial cap also
fall back on that same call", and both cap tests in autoMode.test.ts assert
it. "The next tool call" overstated the number of hard blocks by one.

The session-total cap is deliberately not documented here: this file never
documented it for classifier blocks either, and whether destructive denials
should inherit it is the open question on review thread R1-1.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuz19384dy
…banner

formatDenialFallbackMessage interpolated its classifierReason parameter only
on the consecutive_block branch, so the total_denial branch discarded it.
Because total_denial takes precedence over consecutive_block in shouldFallback,
a denial-heavy session lands on exactly the branch that loses the information:
the new destructive-command escalation returned a banner reading "Auto mode
reached its session denial limit. Review this action manually." with no mention
that a deterministic guard classified the command as work-destroying, while the
identical command at the consecutive cap did name it. Before this PR the
destructive reason was always present on this path, since the branch could only
return kind 'blocked'.

Interpolate the reason on total_denial too, mirroring consecutive_block. The
parameter was already threaded in from both two-argument call sites;
total_denial was the only branch that received a reason and dropped it, so this
makes the formatter internally consistent rather than adding a
destructive-specific special case. It also restores the classifier policy-block
reason on the classifier route's total_denial banner, which the same omission
had been discarding.

Pin the reason in the existing session total-denial cap test. Reverting only
the autoMode.ts hunk turns that assertion red:

  expected 'Auto mode reached its session denial ...' to contain
  'Blocked destructive git command'

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuz5jerle5
`docs/users/features/approval-mode.md` described the AUTO mode loop guard as
"three consecutive policy blocks" after which "the next call" falls back. Since
the destructive-command escalation, deterministic destructive-command blocks
also count towards that cap, and `shouldFallback` is evaluated on the
post-increment state, so the call that reaches the third block falls back on
that same call.

Mirrors the wording already delivered for the sibling bullet in
`docs/users/features/auto-mode.md`, keeping the two user docs consistent.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuzbyw3ref

This branch has not been deployed

No deployments
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.

3 participants