Repository navigation
fix(permissions): escalate repeated destructive-command denials to manual approval - #13636
yiliang114 wants to merge 5 commits into
Conversation
…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 Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR! Template looks good ✓ Problem: observed, not theoretical. Linked issue #13570 is open ( Direction: aligned. Upstream has repeatedly fixed this exact class of bug — a denial message pointing at a route that doesn't work: Size: core path ( Approach: this is close to the minimal change, and it's the shape I'd have written independently. It reuses 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 Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题: 是已观测到的缺陷,不是理论性加固。关联 issue #13570 仍处于开启状态( 方向: 对齐。上游反复修过这一类缺陷——拒绝提示指向一条走不通的路径: 规模: 触及核心路径( 方案: 已经接近最小改动,也正是我独立会写出的形态。它复用了 风险: 无升级风险信号——两个改动文件都不匹配与 revert 相关的路径清单。给后续 reviewer 的一点提示:本 PR 的核心主张是行为性的,新增测试直接断言了 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI wrote down what I'd have done before opening the diff: make the The escalation itself is correct. I walked each cap against The subtlest line in the diff is right. Downstream consumers — all three read, none need changing. One consequence worth stating rather than fixing here: Two non-blocking points, both about the
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: Everything else is clean and matches house conventions: the block-scoped Test evidence — the PR's own CIThis 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 — Final CI results for
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: 中文说明代码审查我在看 diff 之前先写下了自己的方案:让 升级逻辑本身是正确的。 我对照 diff 里最微妙的一行是对的。 下游消费方——三个我都读了,都不需要改。 有一点值得说明而不是在这里修: 两个不阻塞的点,都与传给
一个用户可见的小打磨点。 组合出来的 fallback 文案把一条建议嵌进了另一条里。amend 这一支在连续上限处,用户看到的是: 其余部分干净且符合仓库约定:新增 测试证据——PR 自身的 CI这是一次无人值守的 CI 运行,因此我没有构建或执行本 PR 的任何代码;下面的信号取自被审 commit 上 PR 自身的 check,通过 API 获取。截至撰写时没有失败项,但对这份 diff 最关键的两个 check 仍在运行—— (CI 表格见上方英文部分,未重复粘贴。) 描述中报告了 5 个新增测试(改动前 4 个失败)、两个文件合计 138 个通过,以及逐行回退的变异验证——每次恰好让预期的测试变红。这是很好的非空断言验证,比常见的「测试通过」强得多——但那是作者的说法,本次未独立复跑,在这条路径上我也无法复跑。 沙箱验证可以补齐新增测试没有覆盖的那一层: — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
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 My independent proposal before reading the diff was to lift the escalation block out of the Two things keep this at 4 rather than 5, neither blocking:
The scope discipline here is worth calling out on its own: defects 1, 2, 3 and 4c all sit in 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 So: approving on merit, with the approval deferred until CI lands green on 中文说明Confidence: 4/5 —— 修复正确、最小,并且对齐了一个本就能正常工作的相邻分支;我仍然希望补上的两点是:为 fingerprint 参数加一句意图说明(或一个测试),以及看一下嵌套的 fallback 文案。 退一步看整体:这是在关闭一个真实存在的死胡同,不是理论性加固。我是对照 我在看 diff 之前独立写下的方案就是把 有两点让它停在 4 分而不是 5 分,都不阻塞合并:
这里的范围克制值得单独点出来:缺陷 1、2、3 和 4c 都位于 关于证据,把本次运行做了什么、没做什么说清楚:这是无人值守的 CI 运行,因此我没有执行 PR 中的任何代码。我通过阅读 因此:基于实质内容认可,但审批推迟到 CI 在 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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:
- Honouring the escalation only on
consecutive_blockrequires deletingdegrades 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. - Comparing
blockedState.totalBlockalone againstmaxTotalDenialson this path gives the destructive branch different totals semantics from the classifier branch, against the explicit invariant atdenialTracking.ts:19-21that 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).
| if (fallback.fallback) { | ||
| config.setAutoModeDenialState(consumePendingManualRetry(blockedState)); | ||
| return { | ||
| kind: 'fallback', |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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".
shouldFallbackis 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 inautoMode.test.tsassert 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:
- 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. - That file has no
.zh-CN.mdtwin, 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.
There was a problem hiding this comment.
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:
- The correct replacement sentence is the open pick on the
shouldFallback(blockedState)thread. Under "restrict toconsecutive_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. 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.mdtwin (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.
…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
`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
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-commandbranch ofapplyAutoModeDecisionrecorded the denial and returnedkind: 'blocked'unconditionally, so the counters climbed forever without ever reaching a fallback. It now passes the action fingerprint intorecordBlockand consultsshouldFallback, mirroringcase '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
applyAutoModeDecisionthat 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 pathpendingManualRetryFingerprintwas never set —recordBlockonly 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:Before this change the new
applyAutoModeDecision — blocked:destructive-command escalationblock fails 4 of 5 tests. The most direct one drives a denial state already atconsecutiveBlock: 2and asserts the next destructive denial returnskind: 'fallback'; onmainit returnskind: 'blocked'while the recorded state showsconsecutiveBlock: 3, totalBlock: 3— the counters increment, the escalation never arms. After the change all 5 pass, and the 102 pre-existingautoMode.test.tstests 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) andpreserves an armed retry when the destructive guard preempts it. The latter is whyshouldFallbackis called with the post-increment state only, without the fingerprint argument — passing the fingerprint there would makeclassifier_blocked_retryfire immediately and flip that test's expectedkind: 'blocked'tofallback.Non-vacuity was checked by single-line mutation, each reverting one line of the fix:
recordBlock(denialState, actionFingerprint)back torecordBlock(denialState)reddens exactly 1 test (arms the exact-action manual retry on a destructive denial).shouldFallback(blockedState)toshouldFallback(denialState)reddens exactly 3 (both cap tests andconsumes the pending retry once it escalates).Also verified locally:
packages/coretsc --noEmitclean (exit 0, and--listFilesconfirms both changed files were loaded),eslintclean on both changed files,prettier --checkclean 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
Environment (optional)
Unit tests only (
vitest run), no runtime sandbox involved.Risk & Scope
input.pmForcedAskearly return above the L5.2.5 destructive guard) and defect 4c (theinput.skipClassifierReasoncheck sitting after that guard) both live inevaluateAutoMode, 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, soevaluateAutoModeis not touched at all here.GIT_AMEND_PATTERNmatch 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.DESTRUCTIVE_GIT_PATTERNS. Not implemented;destructive-commands.tsis untouched.recordBlock/shouldFallback/consumePendingManualRetry/formatDenialFallbackMessagehelpers.AutoModeDecisionandAutoModeOutcomeshapes 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 复现:改动前,新增的
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 发生变化。测试输出见上一节。
测试环境
环境(可选)
仅单元测试(
vitest run),不涉及运行时沙箱。风险与范围
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。GIT_AMEND_PATTERN匹配换成基于解析片段的匹配)需要在 shell-semantics / shell-tool 的分词层做改动,而 fix(shell): use bash word separators for shell parsing #12339 同样在该区域活动,且超出本次改动的预算。DESTRUCTIVE_GIT_PATTERNS。未实现,destructive-commands.ts未被触碰。recordBlock/shouldFallback/consumePendingManualRetry/formatDenialFallbackMessage辅助函数。AutoModeDecision与AutoModeOutcome的形状未变。关联 Issue
Refs #13570 —— 刻意不使用自动关闭关键字:该报告描述了四个缺陷,本 PR 只修复第四个,因此 issue 必须为其余推迟项保持开启。