Skip to content

fix(permissions): cite matching deny rule for compound and virtual-op shell denials - #11411

Merged
wenshao merged 5 commits into
QwenLM:mainfrom
yiliang114:fix/issue-11405-deny-rule-citation
Sep 9, 2026
Merged

fix(permissions): cite matching deny rule for compound and virtual-op shell denials#11411
wenshao merged 5 commits into
QwenLM:mainfrom
yiliang114:fix/issue-11405-deny-rule-citation

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

When a shell command is denied by a pattern-scoped permission rule (e.g. Bash(npm view *) or Read(//**/node_modules/**)), the denial message now cites the matching rule and frames the denial as invocation-scoped instead of tool-scoped.

Why it's needed

findMatchingDenyRule() matched strictly fewer rules than evaluate(), which actually makes the deny decision. For compound commands (cd /tmp && npm view foo) and for shell commands denied via a virtual file-op rule (Read(//**/node_modules/**) matching cat /app/node_modules/...), evaluate() returns deny but findMatchingDenyRule() returned undefined, so the message dropped the rule citation. The resulting Tool "run_shell_command" is denied by permission rules. reads as "the whole tool is unavailable", and paired with the give-up instruction in the system prompt, made the model abandon the tool entirely (issue #11405).

Reviewer Test Plan

How to verify

Set permissions.deny: ["Bash(npm view *)", "Read(//**/node_modules/**)"] in settings.json, then in an interactive session:

  1. cd /tmp && npm view foo — the denial message should cite Bash(npm view *) and note that other uses of the shell tool are still permitted.
  2. cat /app/node_modules/lodash/index.js — the denial message should cite Read(//**/node_modules/**).

Expected: the deny message reads This "run_shell_command" invocation was denied by permission rules. Matching deny rule: "Bash(npm view *)". Other uses of this tool are still permitted. (or the Read(...) rule for the second case).

Evidence (Before & After)

Before: Tool "run_shell_command" is denied by permission rules. (no rule cited) for both compound-segment and virtual-op denials — covered by the two new findMatchingDenyRule tests, which failed before the fix and pass after.

After: rule cited + invocation-scoped framing, asserted by new unit tests in permission-manager.test.ts (cites the deny rule for a compound command segment, cites the deny rule when a shell command is denied via a virtual file op) and permissionFlow.test.ts (frames a specifier-scoped deny as invocation-scoped, not tool-scoped).

Tested on

OS Status
🍏 macOS N/A
🪟 Windows N/A
🐧 Linux ✅ tested

Environment (optional)

Unit tests only (npx vitest run under packages/core); no TUI/dev runtime.

Risk & Scope

  • Main risk or tradeoff: findMatchingDenyRule now performs the same compound-command and virtual-file-op passes as evaluate(), so it can only start returning rules it previously missed. Call sites that pass { toolName } without a command are unaffected.
  • Not validated / out of scope: the custom per-rule message feature request (should be tracked separately); the !-bang shell pre-check (shell-utils.ts) and ACP/daemon (Session.ts) deny paths, which use different mechanisms and sit outside the interactive permissionFlow path the reporter hit.
  • Breaking changes / migration notes: none. The message wording changes; tests pinning the old wording were updated or kept passing.

Linked Issues

Fixes #11405

中文说明

这个 PR 做了什么

当 shell 命令被模式级权限规则拒绝时(例如 Bash(npm view *)Read(//**/node_modules/**)),拒绝消息现在会引用命中的规则,并把拒绝表述为「本次调用」级别而非「整个工具」级别。

为什么需要

findMatchingDenyRule() 匹配的规则范围严格窄于真正做出拒绝决定的 evaluate()。对于复合命令(cd /tmp && npm view foo)以及通过虚拟文件操作规则被拒的 shell 命令(Read(//**/node_modules/**) 命中 cat /app/node_modules/...),evaluate() 返回 denyfindMatchingDenyRule() 返回 undefined,导致消息丢失规则引用。最终输出 Tool "run_shell_command" is denied by permission rules. 读起来像「整个工具不可用」,叠加系统提示词中的放弃指令,让模型彻底放弃该工具(issue #11405)。

验证方式

在 settings.json 配置 permissions.deny: ["Bash(npm view *)", "Read(//**/node_modules/**)"],然后在交互会话中:

  1. cd /tmp && npm view foo —— 拒绝消息应引用 Bash(npm view *),并说明该工具的其他用法仍被允许。
  2. cat /app/node_modules/lodash/index.js —— 拒绝消息应引用 Read(//**/node_modules/**)

预期:拒绝消息为 This "run_shell_command" invocation was denied by permission rules. Matching deny rule: "Bash(npm view *)". Other uses of this tool are still permitted.(第二种情况同理引用 Read(...) 规则)。

风险与范围

  • 主要风险/权衡:findMatchingDenyRule 现在执行与 evaluate() 相同的复合命令与虚拟文件操作两套 pass,只会开始返回之前漏掉的规则;不带 command 的 { toolName } 调用点不受影响。
  • 未验证/超出范围:自定义 per-rule 消息的功能请求(应单独跟踪);! 前缀 shell 预检(shell-utils.ts)与 ACP/daemon(Session.ts)拒绝路径(使用不同机制,且不在报告者触发的交互式 permissionFlow 路径内)。
  • 破坏性变更/迁移说明:无。消息措辞有变化;固定旧措辞的测试已更新或保持通过。

… shell denials

findMatchingDenyRule() matched strictly fewer rules than evaluate(),
which makes the actual deny decision. For compound commands and shell
commands denied via a virtual file-op rule (e.g. Read(//**/node_modules/**)
matching `cat /app/node_modules/...`), the deny message dropped the rule
citation and read as tool-scoped, causing the model to abandon the tool.

Now findMatchingDenyRule runs the same compound-command and virtual-op
passes as evaluate(), and the deny message frames the denial as
invocation-scoped and notes that other uses of the tool are still
permitted.

Fixes QwenLM#11405

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-issue-patrol/jmtt7wpbesc
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! Re-run at the new head 475877f — you pushed three commits since the last pass (a main merge, a Prettier fix, and the catch-all change), so this is a fresh gate on the current code, not a restatement of the old one.

Template looks good ✓ — all nine headings from the template are present and filled in, including the Tested-on table, the before/after evidence, and the Chinese translation.

Problem: observed, not theoretical. #11405 is open, P2, and carries the reporter's exact permissions.deny block plus screenshots. The root cause is real and I re-confirmed it in source rather than taking the description's word: findMatchingDenyRule() only ran the single-context match, while evaluate() — the function that actually decides — also runs a full-command virtual-op pass (permission-manager.ts:315-329) and a per-segment compound pass (:334-357). Any denial reached through those two passes came back with no rule to cite.

Direction: aligned. This is a message-accuracy fix on a bug that makes the model abandon an entire tool over one blocked pattern. The property that makes it safe still holds and I re-verified it at this head: findMatchingDenyRule() never decides anything — every consumer uses its return value only to build a string — so widening the matcher cannot change what is allowed or denied. CHANGELOG shows the area is live and recurring, as a supporting signal only: there are entries for Bash Read() deny rules missing cd DIR && cat FILE compounds, and a 2.1.259 change applying Read() deny rules to Bash arguments that was later reverted for false positives. That revert is not an argument against this PR — it widened what gets denied, and this PR changes no decision at all.

Size: core paths are touched, so the breakdown: 87 production lines (permission-manager.ts 51, permissionFlow.ts 36) vs. 88 test lines (permissionFlow.test.ts 56, permission-manager.test.ts 32), +170/−5 over four files. Below every threshold, and you have admin on this repo, so the two-tier core gate does not bind. No maintainer escalation on size, and no large-PR advisory.

Approach: scope feels right and genuinely minimal — four files, no drive-by refactors, and the two things deliberately left out (the custom per-rule message feature request, and the shell-utils.ts / Session.ts deny paths that use different mechanisms) are named in Risk & Scope rather than silently dropped. Deferring the feature request to its own issue matches what the linked issue's triage recommended.

The one question I raised last time still stands as a question, not a blocker: the fix teaches findMatchingDenyRule() to replay evaluate()'s two extra passes, so two implementations of "which rule denied this" now have to stay in sync — and that asymmetry is the bug being fixed. I have gone through the mirror line by line at this head and it is faithful; the durability worry is in the code review below, with a cheaper way to close it than the refactor the issue thread suggested.

Risk: no elevated risk signals — none of the four changed files match the revert-correlated paths.

Status of the previous pass: the Prettier blocker is fixed (Lint & Static is now green) and the catch-all false-reassurance regression is fixed and pinned by a test. One of my four findings from that pass was wrong — I have retracted it explicitly in the review below rather than letting it stand.

Moving on to code review. 🔍

中文说明

感谢贡献!本次是在新 head 475877f 上的重跑——你自上轮之后推了三个 commit(合并 main、Prettier 修复、catch-all 改动),所以这是对当前代码的重新过闸,而不是把旧结论复述一遍。

模板完整 ✓ —— 模板要求的九个小节全部存在且已填写,包括 Tested-on 表格、before/after 证据与中文翻译。

问题:已观测到的问题,不是理论性加固。#11405 处于 open、P2,报告者给出了确切的 permissions.deny 配置与截图。根因是真实的,我没有照抄描述,而是在源码中重新确认过:findMatchingDenyRule() 只跑了单一上下文匹配,而真正做决定的 evaluate() 还会跑「完整命令的虚拟操作 pass」(permission-manager.ts:315-329)与「逐段复合命令 pass」(:334-357)。凡是通过这两条 pass 得出的拒绝,回来时都没有规则可引用。

方向:对齐。这是一个 P2 bug 的消息准确性修复——一个被单一 pattern 拦截的调用,会让模型放弃整个工具。让它安全的那个性质在当前 head 上依然成立,我重新核实过:findMatchingDenyRule() 从不做任何判定,每个消费方都只用它的返回值拼装字符串,因此放宽匹配器不可能改变什么被允许、什么被拒。CHANGELOG 显示该领域活跃且问题反复出现,仅作为辅助信号:其中既有 Bash Read() deny 规则漏掉 cd DIR && cat FILE 复合命令的条目,也有一条 2.1.259 把 Read() deny 规则应用到 Bash 参数、后因误报被回滚的记录。那次回滚并不构成对本 PR 的反对意见——它扩大的是「什么会被拒」,而本 PR 完全不改变任何判定。

规模:触及核心路径,故给出拆分:生产代码 87 行permission-manager.ts 51 行、permissionFlow.ts 36 行)对比 测试 88 行permissionFlow.test.ts 56 行、permission-manager.test.ts 32 行),四个文件共 +170/−5。低于所有阈值,且你对本仓库拥有 admin 权限,因此核心的两级门禁不适用。规模上无需维护者介入,也不触发大 PR 提示。

方案:范围合理且确实最小化——四个文件,没有顺手重构;两处刻意不做的内容(自定义 per-rule 消息的功能请求,以及使用不同机制的 shell-utils.ts / Session.ts 拒绝路径)都在 Risk & Scope 中明确列出,而非静默省略。把功能请求拆到独立 issue,与关联 issue 的 triage 建议一致。

我上轮提的那个问题依然只是问题、不是阻塞项:该修复让 findMatchingDenyRule() 重放 evaluate() 多出的两个 pass,于是「哪条规则拒绝了本次调用」现在有两份实现必须保持同步——而这两个函数之间的不对称正是本次要修的 bug。我在当前 head 上逐行核对过这个镜像,它是忠实的;可持续性的顾虑放在下面的代码审查里,并给出一个比 issue 讨论中所建议的重构更便宜的收口方式。

风险:无升级风险信号——四个变更文件均未命中与回滚相关的路径。

上轮结论的处理情况:Prettier 阻塞项已修复(Lint & Static 现在是绿的),catch-all 错误安抚的回归也已修复并被测试固定。我上轮四条发现中有一条是错的——我在下面的审查中明确撤回,而不是让它继续挂着。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Code review

Evidence carried: static review of the diff against base source in a read-only worktree at main, plus this PR's own CI results and the failing job's log fetched through the API. Nothing was built or executed — this is an unattended CI run, and the gate forbids running code under review. Line numbers below are base-main line numbers. See Not verified at the end.

Both blockers from the last pass are gone, and one of my four findings was simply wrong. Details on all three below, then what is left.

Resolved: Prettier (previous finding 1)

Lint & Static (ubuntu-latest, Node 22.x) is now success on this head. The two files it flagged are the two files 0cf2bc8 reformatted, and the reformat is cosmetic only — I diffed 28b32bf...475877f for permission-manager.ts and the entire delta is two hunks of Prettier line-collapsing (denyRules onto one line, the matchesRule(...) call onto one line). No logic moved.

Resolved: catch-all false reassurance (previous finding 2)

matchingRule?.includes('(') is now matchingRule && !isToolWideDenyRule(matchingRule), and isToolWideDenyRule decides from the parsed rule instead of from punctuation. I walked every branch against parseRule's actual behaviour rather than trusting the doc comment:

  • Bash (no paren) → parseRule returns no specifier and no matchers → tool-wide ✓
  • Bash()rawSpecifier is '', so specifierKind is left undefined and specifier is the empty string, which is falsy → takes the same branch → tool-wide ✓
  • Bash(*)specifierKind: 'command', specifier: '*' → tool-wide ✓ (matches matchesCommandPattern's documented "Bash(*) is equivalent to Bash")
  • Read(//**)specifierKind: 'path', and resolvePathPattern strips one leading slash to /**, which with dot: true matches every path → tool-wide ✓
  • WebFetch(*)specifierKind: 'domain', specifier: '*' → tool-wide ✓
  • Agent(model:opus) → the literal branch consumes model:opus into toolParamMatchers and leaves specifier undefined, so !rule.toolParamMatchers?.length is false → correctly treated as scoped, reassurance shown ✓
  • Read(*) and Read(/**) → the path branch short-circuits before the * check, so both are scoped. That is right: Read(*) resolves to path.join(cwd, '*') (direct children only) and Read(/**) resolves against the project root, so neither blocks the tool and reads outside them are genuinely still permitted ✓

The new test pins the three realistic catch-alls. This was the finding I would not have merged past, and it is properly closed.

Retracted: the cd-tracking divergence (previous finding 3)

This one was wrong, and I want to say so plainly rather than let it sit in the thread. I claimed evaluateSingle() "deliberately does not re-run virtual ops" per segment, and therefore that your recursion re-ran a pass evaluate() never runs, resolving cat foo against the original cwd and citing a rule that did not decide the denial.

evaluateSingle() does re-run it — permission-manager.ts:455-467 calls extractShellOperationsAcrossCommand(command, cwd) per segment, with a comment saying exactly why: "The cross-command cd-tracking pass at the top of evaluate() handles cd && wrapper patterns — per-segment unwrapping handles wrappers in isolation." I read the top-of-evaluate() comment about cd tracking and inferred the per-segment behaviour from it instead of reading evaluateSingle().

Your recursion mirrors the real structure. Re-running the worked example I gave: with deny: ["Bash(rm *)", "Read(./foo)"] and cd /tmp && cat foo && rm x, the full-command pass resolves cat foo to /tmp/foo and does not match; but evaluateCompoundCommand then evaluates the cat foo segment through evaluateSingle, whose per-segment virtual-op pass resolves foo against the original cwd, matches Read(./foo), and returns deny — short-circuiting before rm x is ever reached. So Read(./foo) is the deciding rule, and your citation names it correctly. There was nothing to fix. Sorry for the noise.

Two smaller ordering notes, both non-blocking and neither worth a commit on their own: within a segment your recursion checks virtual ops before base rules while evaluateSingle does the reverse, so with e.g. deny: ["Bash(cat *)", "Read(//**/node_modules/**)"] and cat /app/node_modules/x the message cites the Read rule where evaluateSingle hit the Bash rule first — both are true matching deny rules for that invocation, and the Read one is arguably the more informative citation. And the recursion re-enters the public method, so each segment is passed through normalizePermissionContext a second time; that is a no-op here because the function returns early for anything but monitor, and for monitor the segments come from an already-normalized safetyCommand with the trailing & already stripped by splitCompoundCommand, so re-normalizing is stable.

Still open, non-blocking: two implementations of one question (previous finding 4)

Unchanged, and I am still not asking for the evaluate()-records-the-rule refactor in this PR — that would change a return type with many call sites and balloon a message fix. The cheaper version of the same protection is one table-driven test asserting findMatchingDenyRule agrees with evaluate() across the compound, virtual-op and catch-all shapes. That converts "these must stay in sync" from a comment into something CI enforces, and it is the thing that would have caught the original bug.

Two residual nits in the new code

  • The base sentence is still unconditionally invocation-scoped, so deny: ["Bash"] reads This "run_shell_command" invocation was denied by permission rules. Matching deny rule: "Bash". The harmful half is gone — no reassurance is appended, and the bare rule name is cited — but a tool-wide block is still described as a per-call one. Gating that sentence on isToolWideDenyRule too would finish the thought.
  • matchesCommandPattern treats only the exact string * as the universal catch-all in its fast path, but its regex builder also makes Bash(**) and Bash(* *) match every command (^.*.*$ and ^.*( .*)?$). isToolWideDenyRule compares specifier === '*', so those two forms still draw the reassurance. Contrived configs and the mild failure direction, so I would not block on it — noting it because it is the same shape as the bug you just fixed.

What I verified as correct

  • The fix is live, not inert. evaluatePermissionFlow passes the same pmCtx to both evaluatePermissionRules and findMatchingDenyRule (permissionFlow.ts:80-107), and buildPermissionCheckContext populates command from toolParams['command'], so the matcher sees the identical context that produced the deny.
  • pathCtx is built identically in findMatchingDenyRule (:914-919) and evaluateSingle (:403-408) — same cwd ?? this.config.getCwd() — and the new virtual-op pass uses pathCtx?.cwd ?? process.cwd() for the extractor, matching evaluate():325. So relative path rules resolve against the same directory in both.
  • The new matchesRule call shape matches the reference. Nine positional arguments plus 'canonical', with command/specifier/toolParams/toolAliases undefined — exactly what evaluateShellVirtualOps produces through evaluateSingle({toolName: op.virtualTool, cwd, filePath, domain}). The cwdUnknown escalation in evaluateShellVirtualOps only ever raises to ask, never deny, so a citation walk that ignores it cannot miss a deny.
  • Recursion terminates. splitCompoundCommand returns [command] when there is nothing to split, so subCommands.length > 1 is false on the recursive call.
  • Pass order mirrors evaluate(): full-command virtual ops first and short-circuiting, then compound, then single-context — the same order as :315-357.
  • Every consumer is accounted for. permissionFlow.ts:106 (message), coreToolScheduler.ts:305 and :2553 (both pass { toolName } with no command, and both new passes are command !== undefined-guarded — provably unaffected, confirming your own risk note), plus the memory- and skill-scoped shims, which only delegate.
  • No import cycle. permissionFlow.ts now imports parseRule from ../permissions/rule-parser.js; rule-parser.ts imports only from node:*, picomatch, shell-quote, ../utils/* and ./types.js — nothing from core/. The file already imported from ../permissions/types.js, so the dependency direction is unchanged.
  • No pre-existing test pinned the old wording. The two existing deny-message tests mock findMatchingDenyRule with 'deny rm -rf *' and 'deny exit_plan_mode' — neither has a paren, so both take the tool-wide branch and get no reassurance — and they assert only toContain('denied by permission rules'), which the new wording still satisfies. Your "kept passing" claim checks out, and CI agrees.

CI status

Read once, no polling — the unit suite runs longer than any in-agent wait. The Qwen Triage Finalize job rewrites the region below when CI settles. review-pr (a bot orchestration check, not PR CI) was still in flight at review time; it is not part of this PR's test signal.

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

Check Conclusion
Test (ubuntu-latest, Node 22.x) ❌ failure
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
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 red check is pre-existing main breakage, not this PR. The evidence, rather than an assertion:

The failure is in a package this PR does not touch. From the Test job log:

FAIL  App.test.tsx > App session callbacks > does not rerender App for other split sessions (outer pending: false)
ReferenceError: mockUseDaemonActivePromptBridge is not defined
 ❯ App.test.tsx:28940:14
    28940|       expect(mockUseDaemonActivePromptBridge).toHaveBeenCalled();
Test Files  1 failed | 287 passed (288)
     Tests  2 failed | 6710 passed (6712)
JUNIT report written to .../packages/web-shell/junit.xml

Your own tests are green, and I confirmed the new ones actually ran. Per-package attribution from the JUNIT paths in the same log:

  • packages/core — 648 test files, 23 914 passed, 10 skipped. Inside that block: ✓ src/permissions/permission-manager.test.ts (422 tests) and ✓ src/core/permissionFlow.test.ts (31 tests).
  • packages/cli — 1022 test files, 29 272 passed, 90 skipped.
  • packages/web-shell — the 2 failures above.

permissionFlow.test.ts has 29 it() blocks on base main (counted statically in the worktree) and CI reports 31 — exactly the two this PR adds. The permission-manager.test.ts hunk adds exactly two it() blocks and nothing else. So all four new tests ran and passed, which closes the "not verified: the new unit tests passing" gap from the last pass with real evidence rather than an assumption.

Sandboxed verification would settle what CI cannot: @qwen-code /tmux — that the reworded denial actually reaches the model mid-session and that the model then keeps using the shell tool for other commands. That second half is the whole complaint in #11405 and it is a model-behaviour claim no unit test can pin: your four new tests assert the string, not that the model stops abandoning the tool. You have write access, so /tmux is available directly. A /verify run is already in flight on this head (the verify job of run 34299893776) and will A/B the citation against the base build for the compound and virtual-op cases — worth reading when it lands, though it settles the citation mechanics rather than the model's subsequent behaviour.

Not verified

  • Not verified by execution — unattended CI run; the gate forbids building or running the code under review. All findings are static reads at the cited file:line.
  • Not verified: the model-behaviour outcome. Whether the reworded message actually stops the model abandoning the tool is the issue's real claim. Hence the /tmux line above.
  • Not verified: the in-flight /verify report. Run 34299893776's verify job was still running at review time; I have not guessed its result.
  • Not verified: the reporter's screenshots in Denied tool with a pattern, forces model to not use the tool at all #11405 — attachments were not fetched. The described symptom matches the patternless message.
  • Not verified: Bash(**) / Bash(* *) as real user configs. I derived their match-everything behaviour from reading matchesCommandPattern's regex builder, not from running it.
中文说明

所携带的证据:在 main 上的只读 worktree 中对 diff 与基线源码做静态审查,外加通过 API 取得的本 PR 自身 CI 结果与失败任务日志。未构建、未执行任何代码——本次为无人值守 CI 运行,门禁禁止运行受审代码。下文行号均为基线 main 的行号。

上轮的两个阻塞项都已消失,而我上轮四条发现中有一条纯粹是错的。三点分别说明如下,然后是剩下的部分。

已解决:Prettier(上轮发现 1)。 当前 head 上 Lint & Static (ubuntu-latest, Node 22.x) 已是 success。它上轮点名的两个文件正是 0cf2bc8 重排的那两个,且重排纯属格式:我对比了 28b32bf...475877fpermission-manager.ts 的全部差异,只有两处 Prettier 折行合并(denyRules 合成一行、matchesRule(...) 调用合成一行),没有任何逻辑移动。

已解决:catch-all 的错误安抚(上轮发现 2)。 matchingRule?.includes('(') 已换成 matchingRule && !isToolWideDenyRule(matchingRule),判断依据从标点变成了已解析的规则。我没有轻信注释,而是把每个分支都对着 parseRule 的真实行为走了一遍:裸 Bash(无括号)→ 无 specifier 无 matcher → 工具级 ✓;Bash()rawSpecifier 为空串,specifierKind 保持 undefined、specifier 为空串(假值)→ 走同一分支 → 工具级 ✓;Bash(*) → command 类、specifier 为 * → 工具级 ✓(与 matchesCommandPattern 文档所述「Bash(*) 等价于 Bash」一致);Read(//**) → path 类,resolvePathPattern 去掉一个前导斜杠得到 /**,配合 dot: true 匹配所有路径 → 工具级 ✓;WebFetch(*) → domain 类、specifier 为 * → 工具级 ✓;Agent(model:opus) → literal 分支把 model:opus 收进 toolParamMatchersspecifier 留空,因此 !rule.toolParamMatchers?.length 为假 → 正确判定为受限并给出安抚 ✓;Read(*)Read(/**) → path 分支在 * 判断之前短路,两者都算受限,这也是对的:Read(*) 解析为 path.join(cwd, '*')(仅当前目录直接子项),Read(/**) 相对项目根解析,都没有封死整个工具,其外的读取确实仍被允许 ✓。新增测试固定了三种现实的 catch-all。这是我上轮不愿放行的那条,现在收口得很干净。

撤回:cd 跟踪分歧(上轮发现 3)。 这一条是错的,我想明确说出来,而不是让它留在线程里。 我当时声称 evaluateSingle()「刻意不重跑虚拟操作」,因此你的递归重跑了一个 evaluate() 从不跑的 pass,会把 cat foo 按原始 cwd 解析,从而引用到一条并未做出该拒绝决定的规则。

事实是 evaluateSingle() 确实会重跑——permission-manager.ts:455-467 对每一段调用 extractShellOperationsAcrossCommand(command, cwd),并且注释就写着原因:「evaluate() 顶部的跨命令 cd 跟踪 pass 处理 cd && wrapper 形态——逐段解包则单独处理 wrapper。」我读了 evaluate() 顶部那段关于 cd 跟踪的注释,就据此推断了逐段行为,而没有去读 evaluateSingle()

你的递归镜像的是真实结构。把我给的例子重算一遍:deny: ["Bash(rm *)", "Read(./foo)"]、命令 cd /tmp && cat foo && rm x,完整命令 pass 把 cat foo 解析为 /tmp/foo,不匹配;但随后 evaluateCompoundCommand 会通过 evaluateSingle 评估 cat foo 这一段,其逐段虚拟操作 pass 按原始 cwd 解析 foo,命中 Read(./foo) 并返回 deny——在走到 rm x 之前就短路了。所以 Read(./foo) 正是决定性的那条规则,你的引用完全正确。这里没有任何东西需要修。造成的干扰抱歉。

另外两点更小的顺序差异,均非阻塞、也不值得单独发一个 commit:在一段之内,你的递归先查虚拟操作再查基础规则,而 evaluateSingle 顺序相反,因此例如 deny: ["Bash(cat *)", "Read(//**/node_modules/**)"] 配合 cat /app/node_modules/x 时,消息会引用 Read 规则,而 evaluateSingle 先命中的是 Bash 规则——两者对该调用都是真实命中的 deny 规则,且 Read 那条作为引用信息量更大。还有,递归调用的是公有方法,因此每一段会被第二次送进 normalizePermissionContext;这里是无害的空操作,因为该函数对非 monitor 一律提前返回,而对 monitor 来说各段来自已归一化的 safetyCommand、尾部 & 也已被 splitCompoundCommand 剥离,再次归一化是稳定的。

仍未处理、非阻塞:同一个问题的两份实现(上轮发现 4)。 没有变化,我依然要求在本 PR 里做「由 evaluate() 记录决定性规则」那个重构——那会改动一个有许多调用点的返回类型,把一个消息修复撑大。同样保护的更便宜版本是:加一个表驱动测试,断言 findMatchingDenyRule 在复合命令、虚拟操作、catch-all 各形态下与 evaluate() 一致。这会把「两者必须保持同步」从一句注释变成 CI 能强制执行的东西,而这正是当初能抓住原 bug 的手段。

新代码里的两个残留小问题:

  • 基础句式仍然是无条件调用级的,因此 deny: ["Bash"] 会读作「本次 run_shell_command 调用被权限规则拒绝。命中的 deny 规则:"Bash"」。有害的那一半已经没了——不再追加安抚句,且裸规则名被引用出来——但工具级封禁仍被描述成单次调用被封禁。把这句话也用 isToolWideDenyRule 收一下,思路就完整了。
  • matchesCommandPattern 的快速路径只把字符串恰好为 * 视为万能通配,但它的正则构造器同样会让 Bash(**)Bash(* *) 匹配任何命令(^.*.*$^.*( .*)?$)。isToolWideDenyRule 比较的是 specifier === '*',所以这两种写法仍会带上安抚句。配置很刻意、失败方向也温和,因此我不会据此阻塞——之所以提出来,是因为它与你刚修掉的那个 bug 形态相同。

已验证正确的部分:

  • 修复是生效的,不是空转。 evaluatePermissionFlow同一个 pmCtx 同时传给 evaluatePermissionRulesfindMatchingDenyRulepermissionFlow.ts:80-107),且 buildPermissionCheckContext 会从 toolParams['command'] 填充 command,因此匹配器看到的正是产生该拒绝的那个上下文。
  • pathCtx 构造完全一致。 findMatchingDenyRule:914-919)与 evaluateSingle:403-408)都是 cwd ?? this.config.getCwd();新增虚拟操作 pass 用 pathCtx?.cwd ?? process.cwd() 作为提取器 cwd,与 evaluate():325 一致。因此相对路径规则在两边解析到同一目录。
  • 新增 matchesRule 调用的参数形态与参考实现一致。 九个位置参数加 'canonical',其中 command/specifier/toolParams/toolAliases 为 undefined——正是 evaluateShellVirtualOpsevaluateSingle({toolName: op.virtualTool, cwd, filePath, domain}) 产生的形态。evaluateShellVirtualOps 里的 cwdUnknown 升级只会升到 ask、绝不会到 deny,因此忽略它的引用查找不会漏掉任何 deny。
  • 递归会终止。 无可拆分时 splitCompoundCommand 返回 [command],故递归调用时 subCommands.length > 1 为假。
  • pass 顺序镜像 evaluate() 先完整命令虚拟操作并短路,再复合,再单一上下文——与 :315-357 同序。
  • 所有消费方都已核实。 permissionFlow.ts:106(消息)、coreToolScheduler.ts:305:2553(都只传 { toolName }、不带 command,而两个新 pass 都以 command !== undefined 为条件——可证明不受影响,也印证了你自己的风险说明),以及 memory 与 skill 两个作用域 shim(仅做委托)。
  • 没有循环依赖。 permissionFlow.ts 现在从 ../permissions/rule-parser.js 导入 parseRule;而 rule-parser.ts 只从 node:*picomatchshell-quote../utils/*./types.js 导入,不涉及 core/。该文件本来就已从 ../permissions/types.js 导入,依赖方向未变。
  • 没有既有测试固定旧措辞。 两个既有的拒绝消息测试把 findMatchingDenyRule mock 成 'deny rm -rf *''deny exit_plan_mode'——都不含括号,因此都走工具级分支、都拿不到安抚句——且它们只断言 toContain('denied by permission rules'),新措辞依然满足。你「保持通过」的说法成立,CI 也印证了。

CI 状态:只读取一次,不轮询——单测套件耗时超过任何在 agent 内等待的预算。CI 结束后由 Qwen Triage Finalize 任务重写下方表格区域。review-pr(bot 编排检查,不是 PR 的 CI)在审查时仍在运行,它不属于本 PR 的测试信号。

红色检查项是 main 上的既有故障,不是本 PR 造成的。 依据如下,而不是空口断言:失败发生在本 PR 完全没有触及的包里。失败文件是 packages/web-shell/client/App.test.tsx,而本 PR 的 diff 是四个文件、全部位于 packages/core;错误是 mock 标识符未定义(测试本身坏了,不是行为差异),且在 vitest --retry=2 下依然失败;main 此后已落地两个正是修它的提交——3a75f37#11406,改动 packages/web-shell/client/App.test.tsx +5/−3,补丁中三次提到 mockUseDaemonActivePromptBridge)与 c3023b3#11412),两次运行均为绿。你上次合并 main(9b5c76b)是 2026-09-08T23:15Z,而 #11406 在 00:10Z、#11412 在约 01:13Z 落地,都在其后,所以你的代码树仍带着那个坏测试。再合并一次 main 即可清除,diff 无需任何改动。

你自己的测试是绿的,且我确认新增测试确实跑过了。 按同一日志中的 JUNIT 路径归属:packages/core 648 个测试文件、23 914 通过、10 跳过,其中包含 ✓ src/permissions/permission-manager.test.ts (422 tests)✓ src/core/permissionFlow.test.ts (31 tests)packages/cli 1022 个文件、29 272 通过packages/web-shell 即上述 2 个失败。基线 mainpermissionFlow.test.ts 有 29 个 it()(在 worktree 中静态计数),CI 报告 31 个——正好是本 PR 新增的两个;permission-manager.test.ts 的那个 hunk 也恰好新增两个 it()、别无其他。因此四个新测试都跑过且通过,这用真实证据(而非假设)补上了上轮「未验证:新增单测是否通过」的缺口。

沙箱验证可以补上 CI 补不了的部分:@qwen-code /tmux——验证改写后的拒绝消息确实在会话中送达模型,且模型随后继续使用 shell 工具执行其他命令。后半句正是 #11405 的全部诉求,而它是模型行为层面的断言,任何单测都无法固定:你新增的四个测试断言的是字符串,而不是模型不再放弃该工具。你具备写权限,因此可直接使用 /tmux。当前 head 上已有一个 /verify 运行在进行中(run 34299893776 的 verify 任务),它会就复合命令与虚拟操作两种场景与基线构建做 A/B 对比——落地后值得一读,不过它验证的是引用机制本身,而非模型后续的行为。

未验证项:

  • 未经执行验证——无人值守 CI 运行,门禁禁止构建或运行受审代码。所有结论均为对所引 file:line 的静态阅读。
  • 未验证:模型行为结果。 改写后的消息是否真能让模型不再放弃该工具,是该 issue 的真正诉求,因此给出上面的 /tmux 一行。
  • 未验证:进行中的 /verify 报告。 审查时 run 34299893776 的 verify 任务仍在运行,我没有猜测其结果。
  • 未验证:Denied tool with a pattern, forces model to not use the tool at all #11405 报告者的截图——附件未下载。所述症状与「无规则引用的消息」一致。
  • 未验证:Bash(**) / Bash(* *) 是否为真实用户配置。 它们「匹配一切」的行为是我读 matchesCommandPattern 的正则构造器推导出来的,不是运行得出的。

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

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — both blockers from the last pass are properly closed and the core suite is green including all four new tests; what is left is two nits and a red check that is not yours.

@yiliang114 stepping back from the line-by-line: I believe the bug, I believe the root cause, and the fix is aimed at the right place. Re-running the gate on the new head rather than re-reading my old notes changed my mind on two of the four things I told you last time, in opposite directions.

The catch-all fix is the one that mattered. isToolWideDenyRule was the right response — I asked for either the parsed specifier or treating * as tool-scoped, and you used the parsed specifier, which is the version that survives the next rule-grammar change. I walked all seven shapes I could construct against parseRule's real behaviour, including the awkward ones (Bash() with its falsy empty-string specifier, Agent(model:opus) where the literal branch swallows the specifier into matchers, Read(*) which the path branch correctly declines to call tool-wide), and every one lands on the right side. Pinning it with a test is what makes it stay right.

The cd-tracking finding I retracted. evaluateSingle() re-runs the per-segment virtual-op pass and says so in a comment; I had inferred the per-segment behaviour from the top-of-evaluate() comment instead of reading the function. Your recursion mirrors the real structure and cites the rule that actually decided the denial. That one cost you a review round for nothing, and the fault was mine, not the diff's.

Going back to the proposal I wrote before reading the diff — I would still have had evaluate() record the deciding rule rather than teach a second function to replay its passes, and I still am not asking for that here. It would change a return type with many call sites and turn a message fix into an API change. What I would genuinely like, in this PR or the next one, is the cheap version: one table-driven test asserting findMatchingDenyRule agrees with evaluate() across the compound, virtual-op and catch-all shapes. That is the only thing that stops the two implementations separating again, which is how this bug got here in the first place. Everything else I flagged is a nit I would not hold the PR for — the unconditionally invocation-scoped base sentence for tool-wide rules, and Bash(**)/Bash(* *) slipping past the specifier === '*' comparison.

On the safety question, which is the one that actually matters for a permissions change: I verified again at this head that findMatchingDenyRule() never decides anything. Every consumer builds a string from it, the two coreToolScheduler call sites pass no command and sit behind the new command !== undefined guards, and the new matchesRule argument shape matches what evaluateShellVirtualOps produces. This cannot change what a user is allowed to run. That is also why I am comfortable approving with the model-behaviour half still unproven — the worst case there is that the message does not fully fix #11405, not that it breaks enforcement.

About the red check. Test (ubuntu-latest, Node 22.x) fails on packages/web-shell/client/App.test.tsx with ReferenceError: mockUseDaemonActivePromptBridge is not defined. That file is not in your diff, and main has already fixed it twice over (#11406 and #11412), both landing after your last main merge at 23:15Z on 09-08. Your own package is green: packages/core 23 914 passed, with permission-manager.test.ts (422 tests) and permissionFlow.test.ts (31 tests) both ✓ — and 31 against a base of 29 confirms the two new flow tests actually ran rather than being silently skipped. Merging main again is the only thing standing between this and a green board.

One practical consequence worth knowing before you do it: this approval is pinned to 475877f, and branch protection dismisses stale reviews on push, so the re-merge will dismiss it. That is the correct behaviour, not a problem — re-run @qwen-code /triage afterwards and it will re-approve against the new head if nothing else changed. I would rather approve the code I actually read than withhold the vote over a broken test in a package you did not touch.

The /verify run already in flight on this head is worth reading when it lands, and /tmux remains the only lane that can confirm the second half of the issue's claim — that the model keeps using the shell tool for other commands after one is blocked. Your tests pin the string; the string is the mechanism, not the outcome.

中文说明

信心度:4/5 —— 上轮两个阻塞项都已妥善收口,core 套件全绿且包含全部四个新测试;剩下的是两个小问题,以及一个不属于你的红色检查项。

@yiliang114 抛开逐行细节来看:我认可这个 bug,认可根因,也认可修复打在了正确的位置。这次我是在新 head 上重新过闸、而不是复读旧笔记,结果我上轮告诉你的四件事里有两件改变了判断——方向还相反。

catch-all 那个修复是关键。isToolWideDenyRule 是正确的回应——我要求的是「要么用解析后的 specifier,要么把 * 视为工具级」,你选了前者,而它是能扛住下一次规则语法改动的那个版本。我把能构造出的七种形态都对着 parseRule 的真实行为走了一遍,包括几处别扭的(Bash() 那个假值空串 specifier、Agent(model:opus) 这种 literal 分支把 specifier 吞进 matcher 的情况、以及 Read(*) ——path 分支正确地拒绝把它判为工具级),每一种都落在了对的一侧。用测试把它固定下来,才是它能一直对下去的原因。

cd 跟踪那条发现我撤回了。evaluateSingle() 会重跑逐段的虚拟操作 pass,而且注释里就写着;我当时是从 evaluate() 顶部的注释去推断逐段行为,而没有去读那个函数。你的递归镜像的是真实结构,引用的正是实际做出该拒绝决定的那条规则。这一条让你白跑了一轮 review,问题在我,不在 diff。

回到我在读 diff 之前写下的方案——我仍然会让 evaluate() 记录决定性的那条规则,而不是教第二个函数去重放它的各个 pass;我也依然在这里要求这么做。那会改动一个有许多调用点的返回类型,把一个消息修复变成 API 变更。我真正希望的(本 PR 或下一个都行)是那个便宜版本:加一个表驱动测试,断言 findMatchingDenyRule 在复合命令、虚拟操作、catch-all 各形态下与 evaluate() 一致。那是唯一能阻止两份实现再次分叉的东西,而这个 bug 当初就是这么来的。其余我提到的都是我不会据此卡住 PR 的小问题——工具级规则下基础句式仍无条件是调用级的,以及 Bash(**)/Bash(* *) 会从 specifier === '*' 的比较中漏过去。

关于安全性问题,也是权限改动真正要紧的那个:我在这个 head 上再次核实了 findMatchingDenyRule() 从不做任何判定。每个消费方都只用它拼装字符串,coreToolScheduler 的两个调用点不传 command 且位于新增的 command !== undefined 保护之后,新增 matchesRule 的参数形态与 evaluateShellVirtualOps 产生的形态一致。它不可能改变用户被允许执行什么。这也是为什么在模型行为那一半仍未被证实的情况下我依然可以放心批准——最坏情况是这条消息没能完全修好 #11405,而不是它破坏了权限执行。

关于那个红色检查项。 Test (ubuntu-latest, Node 22.x) 失败在 packages/web-shell/client/App.test.tsx,报 ReferenceError: mockUseDaemonActivePromptBridge is not defined。该文件不在你的 diff 里,而 main 已经把它修了两次(#11406#11412),两者都在你上次合并 main(09-08 23:15Z)之后落地。你自己的包是绿的:packages/core 23 914 通过,permission-manager.test.ts (422 tests)permissionFlow.test.ts (31 tests) 均为 ✓——而 31 对基线的 29 正好确认那两个新的 flow 测试真的跑了,而不是被静默跳过。再合并一次 main,就是这个 PR 与全绿之间唯一的距离。

在你动手之前有一个实际后果值得知道:本次批准固定在 475877f,而分支保护会在 push 时dismiss 过期 review,所以那次 re-merge 会把它 dismiss 掉。这是正确行为、不是问题——之后再跑一次 @qwen-code /triage,如果没有别的变化,它会针对新 head 重新批准。我宁愿批准我真正读过的代码,也不愿因为一个你没碰过的包里的坏测试而扣住这一票。

当前 head 上已在进行的 /verify 运行,落地后值得一读;而 /tmux 仍是唯一能确认 issue 诉求后半段的通道——即在一条命令被拦截之后,模型是否继续用 shell 工具执行其他命令。你的测试固定的是字符串;字符串是机制,不是结果。

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

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

@qwen-code-ci-bot qwen-code-ci-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.

Needs another pass before this can merge — details in the Stage 2 and Stage 3 notes above. Two things I would not merge past:

  1. Lint & Static is red on this PR's own files. Prettier flags packages/core/src/core/permissionFlow.test.ts and packages/core/src/permissions/permission-manager.ts — both modified here, while the third modified file is clean and main is green. npx prettier --write on those two clears it.

  2. Catch-all rules now get a false reassurance. rule-parser.ts documents Bash(*) as equivalent to a bare Bash (tool-wide), but it contains a (, so matchingRule?.includes('(') appends "Other uses of this tool are still permitted." for a tool that is fully blocked. Read(//**) and WebFetch(*) behave the same way. That is a new inaccuracy where the old text was correct, and it reproduces #11405's outcome in the opposite direction — the model keeps retrying a blocked tool. The scoped-vs-tool-wide call is being made from punctuation in the raw string; the parsed rule already knows via rule.specifier. No test covers either case.

Not blocking, but worth a look: the per-segment recursion re-runs the virtual-op pass that evaluate() deliberately runs only on the full command in order to preserve cd tracking, so for cd /tmp && cat foo && rm x the citation can name a rule that did not decide the denial. Explanatory-only — I verified no consumer uses findMatchingDenyRule() for an enforcement decision, so nothing here changes what a user may run.

Test (ubuntu-latest, Node 22.x) was still in progress at review time, so the new unit tests are not yet confirmed green.

中文说明

合并前需要再过一轮——详见上方 Stage 2 与 Stage 3 的记录。两点我不建议直接合并:

  1. Lint & Static 因本 PR 自己的文件而红。 Prettier 报出 packages/core/src/core/permissionFlow.test.tspackages/core/src/permissions/permission-manager.ts——两者都是本 PR 修改的文件,而第三个被修改的文件是干净的、main 也是绿的。对这两个文件执行 npx prettier --write 即可解决。

  2. 通配(catch-all)规则现在会得到错误的安抚。 rule-parser.ts 记载 Bash(*) 等价于裸 Bash(工具级),但它含有 (,因此 matchingRule?.includes('(') 会为「已被完全禁用」的工具追加「本工具的其他用法仍被允许」。Read(//**)WebFetch(*) 同理。这是在原本正确的文本处新增的不准确表述,并以相反方向重现了 #11405 的后果——模型会不断重试一个被禁用的工具。「调用级还是工具级」这个判断是靠原始字符串里的标点做出的;解析后的规则通过 rule.specifier 本来就知道答案。这两种情况目前都没有测试覆盖。

不阻塞,但值得看一眼:逐段递归重跑了虚拟操作 pass,而 evaluate() 刻意只在完整命令上运行它以保住 cd 跟踪,因此对 cd /tmp && cat foo && rm x,引用可能指向一条并未做出该拒绝决定的规则。这只影响解释文本——我已核实没有任何消费方把 findMatchingDenyRule() 用于判定执行,因此这些都不改变用户被允许执行什么。

审查时 Test (ubuntu-latest, Node 22.x) 仍在进行中,因此新增单测尚未确认通过。

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

wenshao and others added 3 commits September 9, 2026 07:15
Prettier reflows the denyRule construction and the long toContain assertion;
no behavioral change.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmttbhaw6si
The deny message decided scoped-vs-tool-wide by sniffing `(` in the raw
rule string, so a catch-all like `Bash(*)` (equivalent to bare `Bash`) got
a false "Other uses of this tool are still permitted." for a tool that is
fully blocked. Use the parsed rule's specifier instead: bare `*` and `//**`
catch-alls are tool-wide; only genuinely scoped specifiers get the
reassurance.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmttcwqrask
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 126 passed · 6 failed · 132 total

Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:126 通过 · 6 失败 · 132 总计

抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11411 — deep verification

Verdict: findings — 126 / 132 scripted assertions passed; the 6 failures are
one defect (Finding 1), counted once per harness that probes it.
Verified head OID: 475877f272d8a2632fb7e18a6532855b22f68dca (git rev-parse HEAD^2).
Control (base tip of the merge ref): 3a75f37ef563f100bd158e4699236a16c4c4ee51 (HEAD^1).

The central claim is proven load-bearing: deny-rule citation coverage on denied
shell invocations goes 6/23 → 23/23 from base to head, with zero change to any
permission decision. One new defect is introduced by the fourth commit's catch-all
guard, and a measured fix for it is included below.

中文摘要

结论:findings(发现问题) — 132 条脚本断言中 126 条通过;6 条失败全部指向同一个缺陷(Finding 1),只是被多个 harness 分别探测到。

A/B 结论:PR 的核心主张成立且是 load-bearing 的。在「被拒绝的 shell 调用必须引用到判定规则」这一不变量上,base 为 6/23,head 为 23/23,共 17 个用例从"拒绝但不引用规则"翻转为"拒绝且正确引用"。同时 base 与 head 在全部 25 个上下文、22 条规则 × 探针上的 evaluate() 判定逐字节相同,说明本 PR 只改变了消息与引用,没有改变任何权限判定(印证了"无破坏性变更")。PR 自带 Reviewer Test Plan 的两步均在真实代码路径上复现,第 1 步产出的消息与 PR 描述的 Expected 字符串逐字节一致

发现的问题

  1. (Suggestion)第 4 个提交新增的 isToolWideDenyRule 只枚举了两种"全工具封禁"字面量(*//**),而通配符写法是一个。实测有 4 种规则会把工具完全封禁(6/6 探针全部 deny)却仍然输出"Other uses of this tool are still permitted.":Bash(**)Bash(***)Bash(* *)Read(//**/**)。这正是该提交想要消除的同一缺陷的兄弟形态,且是本 PR 新引入(base 侧 over-reassure 为 0)。已给出经实测的修复:over-reassure 4 → 0,22 条规则中 18 条分类不变、无任何 deny 判定漂移、受影响测试仍为 453/453。
  2. (Nice to have,属对注释/测试前提的更正)代码注释称"* 是 command 与 domain specifier 的文档化 catch-all",但 domain 侧不成立:matchesDomainPattern 只做精确与子域匹配、不支持通配符,实测 WebFetch(*)WebFetch(domain:*) 对 4 个域名探针 0/4 deny,即这两种写法根本无法拒绝任何请求。因此 PR 自带测试里以 WebFetch(*) 作为"全工具 catch-all"样例,覆盖的是一个真实 findMatchingDenyRule 永远不会返回的字符串;而真实可能出现的 Bash(**) 反而未被覆盖。
  3. (Nice to have,覆盖说明)does not reassure for tool-wide catch-all deny rules (#11405) 这条测试并不固定新措辞:变异 M9(把消息回退为 base 的 Tool "X" is denied by permission rules.)后该测试仍为绿——它只断言"不含安抚句",而 PR 前的措辞同样不含。措辞由兄弟测试固定,两条合起来才能检出 M9,故仅为信息项。

变异矩阵:未变异对照先确认可测量地为绿(422 + 31 passed,0 failed)。8 个守卫变异中 7 个被"预期测试"精确杀死;组合行 M3(同时删除两个新 pass)杀死两条新测试(2 failed),证明两个 pass 各自都是 load-bearing、并非互相掩护。两个同文件阳性对照均成功使测试变红(M4 → 6 failed,M10 → 3 failed),证明 runner 确实收集并执行了被变异的那个文件,因此"无存活变异"不是 runner 空转的结果。无存活变异

未覆盖范围:未按 Test Plan 的原始方式跑真实模型的交互式 TUI 会话(已在真实代码路径上复现其渲染的消息,但未验证模型是否真的不再放弃该工具,即只复现了 shape 而非 cause);浅克隆(depth 2)导致无法逐提交归因(快照列 4 个提交,本地 HEAD^1..HEAD^2 只可达 1 个),仅验证聚合 diff;快照的 baseRefOid422929b3)本地不存在,main 已前移,无法做 trial merge;未跑仓库级测试/集成测试/bundle;isToolWideDenyRule 中 param-matcher 分支(Agent(model:opus))无法构造出被拒探针,端到端未被执行。

Scope

Central claim. findMatchingDenyRule() matched strictly fewer rules than
evaluate(), so pattern-scoped shell denials dropped the rule citation and read as
tool-scoped. The PR widens it with the same virtual-op and compound-command passes
evaluate() runs, and re-frames the message as invocation-scoped.

Secondary claims. (a) The framing must not reassure for tool-wide catch-alls
(fourth commit). (b) "Breaking changes: none" — call sites passing { toolName }
without a command are unaffected.

Out of scope by choice: repo-wide test suites, integration/TUI tests, the bundle,
non-shell permission families beyond what the census touches.

A/B: the central claim

Control built with git worktree add tmp/base-tree HEAD^1 + npm run build -w packages/core (38 s), sharing the root node_modules. The PR touches no
package.json/lockfile, so the shared tree is a clean control; the changed modules
import no @&#8203;qwen-code/* workspace package (verified by grep over the compiled
permission-manager.js, permissionFlow.js, rule-parser.js, shell-semantics.js),
so the internal-symlink trap does not apply. Each harness re-derives its arm from a
marker unique to the head change and exits 2 if the marker disagrees with the arm
it was told to run — that guard fired once during this round and caught a bad marker
(opMatchArgs pre-exists elsewhere in the file: base dist 5, head dist 7); the
marker used is the Compound-command pass section comment (base 0, head 1).

Oracle for every cell: the real compiled PermissionManager, driven with a real
deny-rule set, asserting the invariant evaluate(ctx) === 'deny'
findMatchingDenyRule(ctx) !== undefined
.

cell environment oracle result
base tmp/base-tree @​ 3a75f37e, packages/core rebuilt invariant over 25 contexts 6/23 denied cases cited, 17 violations
head working tree @​ 475877f2 (prebuilt dist) same 25 contexts 23/23 denied cases cited, 0 violations

17 cells flip from broken to fixed. See 01-ab-citation-base-6-of-23-vs-head-23-of-23.png.

Coverage by mechanism family (base → head):

family cells denied base cited head cited
PR's two named shapes 2 2 0/2 2/2
positive controls (must already work on base) 3 3 3/3 3/3
compound separators (&&, ;, |, ||, newline, subshell, 3-segment, if/then/fi, quoted-must-not-deny) 10 8 1/8 8/8
virtual ops (Read / Edit / Write / WebFetch, cd-tracking, bash -lc unwrap) 6 6 0/6 6/6
combined (compound × virtual-op, multi-rule) 3 3 2/3 3/3
monitor tool with a compound command 1 1 0/1 1/1
total 25 23 6/23 23/23

The two compound cells that are not denied are themselves assertions: if true; then rm -rf /tmp/x; fi resolves to ask and echo "cd /tmp && npm view foo" to allow
on both arms, so the new passes did not broaden matching into control flow or
into quoted text.

The three positive controls stay green on both arms, so the flip is attributable
to the two new passes and not to the harness.

No decision changed anywhere

The strongest safety result of the round: across 25 census contexts and all
22 rules × their probe invocations in the catch-all census, the evaluate()
decision is byte-identical between base and head (asserted in h4, which compares
the saved per-probe decision arrays, not summaries). The PR changes what the user is
told, never what is permitted. It also introduces no spurious citation: 0
non-denied contexts gained one.

Citations are not merely non-empty, they are independently sufficient: for 5
multi-mechanism contexts, re-running with only the cited rule present still denies
and still cites the same rule (5/5). Where base and head disagree on which rule to
cite — deny: ["Bash(cat *)", "Read(//**/node_modules/**)"] on
cat /project/node_modules/lodash/index.js, base cites Bash(cat *), head cites
Read(//**/node_modules/**) — head is the faithful one: evaluate()'s top-level
virtual-op pass short-circuits on deny before the Bash-rule pass, so the Read rule
is the deciding rule. The PR's pass ordering mirrors evaluate() correctly.

Reviewer Test Plan, walked step by step

Both steps were run with the plan's own permissions.deny setting through the real
evaluatePermissionFlow. See 06-testplan-base-vs-head-exact-message.png.

step base (before) head (after)
1. cd /tmp && npm view foo Tool "run_shell_command" is denied by permission rules. byte-identical to the plan's Expected string, including Matching deny rule: "Bash(npm view *)" and the reassurance sentence
2. cat /app/node_modules/lodash/index.js same tool-scoped sentence, no citation cites Read(//**/node_modules/**), invocation-scoped

No step of the plan was unperformable at the message level. 8/8 assertions pass at
head; the base arm reproduces the "Before" the PR's Evidence section claims.

Recursion and scaling

The compound pass adds a recursion into findMatchingDenyRule. 13 adversarial
separator/nesting shapes (trailing and leading separators, 50 bare &&, 200 empty
segments, 200 real segments, mixed separators, nested bash -lc quotes, a 10-deep
wrapper, unclosed quote, unclosed subshell, heredoc, $(...), backticks) all
terminate without throwing on both arms, and agree on the decision. See
03-stress-scaling-and-citation-truthfulness.png.

Cost of the two new passes, evaluate() + findMatchingDenyRule() combined:

segments chars base head Δ
50 562 6 ms 8 ms +2 ms
200 2 212 12 ms 16 ms +4 ms
800 8 812 30 ms 46 ms +16 ms
3 200 35 212 109 ms 137 ms +28 ms (+26%)

Per-segment cost falls across rungs (0.160 → 0.043 ms/segment), so the added work
is sublinear, not the superlinear curve that would make a 35 KB command a hang. The
residual +26% at the largest rung is the two extra passes themselves; the whole
residual is accounted for. Recorded as a measured cost, not a finding.

Mutation matrix — vacuity and pinning

Unmutated control measurably green first: permission-manager.test.ts 422 passed /
0 failed, permissionFlow.test.ts 31 passed / 0 failed. The runner aborts (exit 3)
if the control is not measurably green, so an unparseable run can never masquerade
as "everything survived". See 04-mutation-matrix-7-of-8-killed-controls-live.png.

id mutation intended test failed / passed verdict
M1 delete the virtual-op pass cites the deny rule when a shell command is denied via a virtual file op 1 / 421 KILLED
M2 delete the compound pass cites the deny rule for a compound command segment 1 / 421 KILLED
M3 combination: delete both passes both of the above 2 / 420 KILLED
M4 positive control, same file: disable the pre-existing single-context loop 6 / 416 CONTROL-KILLED
M5 isToolWideDenyRule → always false does not reassure for tool-wide catch-all deny rules (#11405) 1 / 30 KILLED
M6 isToolWideDenyRule → always true frames a specifier-scoped deny as invocation-scoped, not tool-scoped 1 / 30 KILLED
M7 fine: neuter only the specifier === '*' clause tool-wide test 1 / 30 KILLED
M8 fine: neuter only the specifier === '//**' clause tool-wide test 1 / 30 KILLED
M9 revert the message wording to base's sentence both flow tests 1 / 30 WRONG-FAIL (see Finding 3)
M10 positive control, same file: drop the citation sentence 3 / 28 CONTROL-KILLED

Zero survivors. Every guard the PR adds is pinned by a test that fails when the
guard is removed, and M3's combination row proves the two passes are each
load-bearing rather than one covering for the other. Both positive controls landed
in the same file as their mutants and killed real tests, so no survivor could be
explained by "the runner never collected that file" — and there were no survivors.

The kills fail the intended behavioural assertion, not an import, a compile, or a
fixture — re-run individually and quoted:

M1  × findMatchingDenyRule > cites the deny rule when a shell command is denied via a virtual file op
    AssertionError: expected undefined to be 'Read(//**/node_modules/**)' // Object.is equality
    - Expected: "Read(//**/node_modules/**)"
    + Received: undefined                      (permission-manager.test.ts:3880)

M5  × evaluatePermissionFlow > does not reassure for tool-wide catch-all deny rules (#11405)
    AssertionError: expected 'This "shell" invocation was denied by…' not to contain
                    'Other uses of this tool are still per…'
    Received: …Matching deny rule: "Bash(*)". Other uses of this tool are still permitted.
                                     (permissionFlow.test.ts:141)

undefined vs the expected rule string, and a reassurance sentence that should be
absent: each is the mismatch the test exists to catch.

Targeted gates

gate command result
affected tests (head) cd packages/core && npx vitest run src/permissions/permission-manager.test.ts src/core/permissionFlow.test.ts 453 passed / 0 failed, 2 files
typecheck npx tsc --noEmit -p packages/core/tsconfig.json exit 0, no output
lint npx eslint on the 4 changed files exit 0, no output
format npx prettier --check on the 4 changed files all files conform

All four were proven live before being cited: planting an unused any and a
type error in a scratch copy made eslint report 2 errors
(no-unused-vars, no-explicit-any) and tsc report 2 (TS6133, TS2322); the
canary was then removed and the restore verified by diff.

Corrections

These correct the PR's description and the new code comment. They are not requests
to change behaviour.

The comment "* is the documented catch-all for command and domain specifiers"
is wrong for domains.
matchesDomainPattern (rule-parser.ts:1378) supports only
exact match and .endsWith('.' + pattern) subdomain match — no globbing at all. A
* pattern therefore matches no domain: measured 0/4 probes denied for both
WebFetch(*) and WebFetch(domain:*), versus 4/4 for the bare WebFetch.
Consequence for the PR's own test: the WebFetch(*) row of
does not reassure for tool-wide catch-all deny rules (#11405) exercises a rule
string that a real findMatchingDenyRule can never return, while Bash(**) — which
a real one can and does return, and which mis-classifies — is absent from that list.

The Risk & Scope claim "Call sites that pass { toolName } without a command are
unaffected" is correct, and I verified it rather than accepting it.
Both new passes
are gated on SHELL_TOOL_NAMES.has(toolName) && command !== undefined. Grepping all
non-test call sites found four: permissionFlow.ts:107 (the one this PR targets),
coreToolScheduler.ts:305 and :2553 (both pass { toolName } only), and two
delegating shims (skillReviewAgentPlanner.ts:244, memory-scoped-agent-config.ts:432)
that forward to the base manager. The registry control cell (deny: ["Bash"],
{ toolName: 'run_shell_command' }) cites Bash identically on both arms.

The out-of-scope declaration holds. shell-utils.ts:2113 (the !-bang
pre-check) emits Command '<cmd>' is blocked by permission rules — already
command-scoped, so the #11405 "whole tool is gone" reading does not arise there; it
does still omit the rule citation, which is pre-existing and not a regression. The
other deny messages (coreToolScheduler.ts:323, :2558, :2576, :2603, :3387)
are tool-registration and --exclude-tools paths where tool-scoped wording is the
correct semantic.

Findings

1. Four catch-all spellings fully block a tool and still promise other uses are permitted — Suggestion

isToolWideDenyRule (packages/core/src/core/permissionFlow.ts:136, literals at
:145 and :148) decides scoped-vs-tool-wide by exact string equality against two
literals
:

if (rule.specifierKind === 'path') {
  return rule.specifier === '//**';
}
return rule.specifier === '*';

But "catch-all" is a class of spellings, not two strings. Measured against the
real matcher, four rule forms deny every probe of their tool yet are classified
as scoped, so the model is told a fully-blocked tool still has other permitted uses:

deny rule tool probes denied reassurance emitted
Bash(**) run_shell_command 6/6 yes ← wrong
Bash(***) run_shell_command 6/6 yes ← wrong
Bash(* *) run_shell_command 6/6 yes ← wrong
Read(//**/**) read_file 6/6 yes ← wrong

Actual message for the first row:

This "run_shell_command" invocation was denied by permission rules. Matching deny rule: "Bash(**)". Other uses of this tool are still permitted.

Reproduce:

node tmp/pr11411-verify-20260909-015234/h3-guard-census.mjs . head
# OVER-REASSURE (fully blocked but promised other uses): 4

This is new in this PR: the base arm measures OVER-REASSURE: 0 because base
has no reassurance sentence at all (02-guard-census-four-catchalls-over-reassure.png
shows the head arm; logs-h3-base.txt the base arm). It is also the same defect
class the fourth commit exists to close
— that commit correctly stopped Bash(*)
and Read(//**) from reassuring, and these four are its immediate siblings. The
impact is bounded and I want to be explicit about what it is not: no permission
decision changes (the scope column is identical on both arms for all 22 rules),
nothing is allowed that should be denied, and there is no security consequence. The
harm is the #11405 harm in reverse — a model told a blocked tool is partly available
keeps retrying it and burns turns. That last step is inferred from the issue's own
report, not measured here
; see Not covered on the absent live-model run.

Severity is Suggestion rather than Critical because the four spellings are
increasingly exotic (Bash(*) and Read(//**), the forms a user actually writes,
are handled correctly), and because the failure is a misleading sentence, not a
wrong decision.

Measured minimal fix (preserves the fourth commit's intent)
  if (rule.specifierKind === 'path') {
    // `//` is the rule grammar's absolute-filesystem root. Every remaining
    // segment being `**` means no path can escape the rule (`//**`,
    // `//**/**`). `/**` stays project-scoped and `//**/*` still misses
    // root-level files, so neither is tool-wide.
    return /^\/\/\*\*(?:\/\*\*)*$/.test(rule.specifier);
  }
  // Command and domain specifiers glob, so a specifier made of nothing but
  // wildcards (`*`, `**`, `* *`) matches every target.
  return /^[\s*]+$/.test(rule.specifier);

Applied in a scratch worktree at head and rebuilt (44 s), then re-driven through the
same harnesses. Three results:

  1. Hostile fixtures go cleanOVER-REASSURE: 4 → 0, UNDER-REASSURE: 0;
    the guard census goes 18/22 → 22/22.
  2. Zero collateral — of 22 rules, 18 keep the identical reassurance
    classification and the 4 that change are exactly the four defective rows. Every
    genuinely scoped rule keeps its reassurance: Bash(git *) 1/6,
    Read(//**/node_modules/**) 1/6, Read(/**) 2/6, Read(//**/*) 5/6,
    Read(//*) 1/6, WebFetch(example.com) 1/4. No deny decision changed for any
    probe of any rule.
  3. Suite counts unchanged — 453 passed / 0 failed, identical to head.

See 05-candidate-fix-over-reassure-4-to-0.png.

The path branch needs the //-root anchor rather than a charset test: a naive
"only stars and slashes" rule would also swallow /** and /**/*, which measure
2/6 — project-scoped, not tool-wide — and would wrongly withhold the reassurance
from them. Anchoring on // plus all-** segments separates //** and //**/**
(6/6) from //**/* (5/6) and //* (1/6), which is exactly the measured boundary.

The suite is green both with and without this patch, so the axis is unpinned.
The fixture that would go red is one line added to the existing loop in
does not reassure for tool-wide catch-all deny rules (#11405):

for (const raw of ['Bash(*)', 'Read(//**)', 'Bash(**)', 'Read(//**/**)']) {

The patch should ship with that fixture; without it nothing distinguishes head from
head-plus-fix.

2. The WebFetch(*) test row covers a rule that can never be returned — Nice to have

Covered under Corrections above, restated as a finding because it has an action:
the domain branch of the guard's comment is factually wrong, and the test list it
justifies spends a row on an unreachable string while omitting a reachable
mis-classifying one (Finding 1). Fixing the comment and swapping WebFetch(*) for
Bash(**) in that loop costs nothing and makes the test's name match its fixture.

3. The #11405-named test does not pin the new wording — Nice to have

Mutation M9 (revert the message to base's Tool "${toolName}" is denied by permission rules.${ruleInfo}) turned exactly one test red —
frames a specifier-scoped deny as invocation-scoped, not tool-scoped — and left
does not reassure for tool-wide catch-all deny rules (#11405) green. That is
not vacuity: the scenario runs and the sibling assertion fails. The test simply
asserts only the absence of the reassurance sentence, which the pre-PR message also
satisfies, so it cannot detect a wording revert on its own. Informational — the pair
of tests does pin the change, and M9 was detected by the suite.

4. For a genuinely tool-wide deny the new wording is less precise than base's — Nice to have

Base said Tool "run_shell_command" is denied by permission rules. Matching deny rule: "Bash". Head says This "run_shell_command" invocation was denied by permission rules. Matching deny rule: "Bash". Eleven of the 22 censused rules fully
block their tool; for the seven of those that the guard classifies correctly
(Bash, Bash(), Bash(*), Read(//**), Edit(//**), Write(//**),
WebFetch), "this invocation" understates a permanent, tool-level block. Messages
were printed verbatim for the first four (in framing-head.json); the remaining
three go through the same single template. The fourth commit correctly
removed the false reassurance for these, so no wrong promise is made — this is only
about the leading clause. A wording that keys off the same isToolWideDenyRule
predicate (Tool "X" is denied… when tool-wide, This "X" invocation was denied…
when scoped) would make both branches accurate. Cosmetic; no assertion in this round
fails on it.

Not covered

  • No live interactive TUI session with a real model. The Test Plan's stated
    method is an interactive session; I reproduced the exact message such a session
    renders, through the real evaluatePermissionFlow, but I did not observe the
    model's behavioural response. This reproduces the shape of the Denied tool with a pattern, forces model to not use the tool at all #11405 fix, not
    its cause: whether a model that previously abandoned run_shell_command now
    keeps using it is the issue's actual outcome and needs a real model run.
  • Per-commit attribution. The checkout is shallow (git rev-parse --is-shallow-repositorytrue). The metadata snapshot lists 4 commits;
    git rev-list HEAD^1..HEAD^2 reaches only 1 (475877f2). At a shallow boundary
    that count is not evidence of a single-commit PR, so I verified the aggregate
    HEAD^1..HEAD diff only and make no per-commit claims.
  • Trial merge into current main. The snapshot's baseRefOid
    (422929b3a7df91e645f29db6ae52aeea7f666918) is not present locally
    (git cat-file -t → fatal), so main has moved since this merge ref was built and
    I could not merge or re-measure there. The control used is the merge ref's own base
    tip HEAD^1.
  • Repo-wide gates. No repo-wide npm run test, no npm run lint across the
    monorepo, no integration tests, no bundle, no build of packages other than
    packages/core. Only the two affected test files, tsc on packages/core, and
    eslint/prettier on the four changed files.
  • isToolWideDenyRule's param-matcher branch is unexercised end-to-end. The
    !rule.toolParamMatchers?.length path is reached only when a rule has matchers
    and no specifier (Agent(model:opus)); I could not construct a probe that such a
    rule denies (0/2 and 0/3 across two attempts), so no message is produced and the
    branch's classification is verified by reading only, not by measurement. M6 does
    prove the function's return value is load-bearing.
  • The declared out-of-scope deny paths were inspected, not exercised. I confirmed
    the PR's scope claim by reading shell-utils.ts:2113 and the five
    coreToolScheduler.ts sites, and by grepping all four non-test
    findMatchingDenyRule call sites; I did not drive the !-bang pre-check or the
    ACP/daemon paths at runtime.
  • Not probed: MCP deny rules, symlink/pathMatchMode interactions beyond the
    census's canonical-mode paths, session-vs-persistent rule precedence in citation
    order, and non-shell compound behaviour.
  • Harness caveats, disclosed. Two of my own harnesses were wrong before they were
    right. (1) The first arm marker (opMatchArgs) is not unique to the head change —
    it pre-exists elsewhere in the same file — and the build-integrity check exited 2
    on the base arm rather than silently running the wrong build; the marker was
    replaced with the Compound-command pass comment. (2) The first mutation run
    parsed vitest's colourised summary, read every run as "0 passed / 0 failed", and
    therefore reported 8 fake survivors and 2 dead positive controls. That output
    was discarded, a measurability guard was added (a run is only classifiable if its
    summary parses to a non-zero test count, and the matrix aborts with exit 3 if the
    unmutated control is not measurably green), and the re-run aborted correctly before
    the ANSI-stripping fix landed. Every matrix number quoted above is from the final
    post-fix run, whose unmutated control measured 422 + 31 passed / 0 failed.

Methodology

Environment: the CI verify job's merge-ref checkout at fcabe38c (depth 2) in a
credential-free node:22-bookworm container, with npm ci and npm run build
already completed at head. The control is a scratch worktree at HEAD^1 with only
packages/core rebuilt (38 s) against the shared root node_modules; the candidate
fix is a second scratch worktree at head with one function replaced and rebuilt
(44 s). Both scratch worktrees were removed with git worktree remove --force once
every cell was captured, so the tmp/base-tree / tmp/fix-tree paths in this report
no longer exist; recreate the control with
git worktree add tmp/base-tree 3a75f37ef563f100bd158e4699236a16c4c4ee51 && ln -s "$PWD/node_modules" tmp/base-tree/node_modules && ln -s "$PWD/packages/core/node_modules" tmp/base-tree/packages/core/node_modules && (cd tmp/base-tree && npm run build -w packages/core).
Every census harness (h1, h2, h3, h5, h7) is a .mjs file in this
directory driving the real compiled dist/ output — real PermissionManager,
real evaluatePermissionFlow, real rule parser and shell-semantics extractor, no
stub of any unit under test; only the Config and invocation collaborators are
thin fakes, and for the framing and test-plan harnesses the PermissionManager itself
is real rather than mocked (the PR's own permissionFlow.test.ts mocks it, which is
why its WebFetch(*) row went unnoticed). The mutation matrix is the exception and
necessarily so: it runs the project's own vitest against source, because that is
the only level at which a source hunk can be reverted and the suite re-run without a
rebuild. Each census harness re-derives which arm it loaded from a marker unique to
the head change and aborts on disagreement. Mutations were applied to source in the
working tree, with pristine copies kept in this directory and the restore verified by
diff and git status --porcelain after every run; the tree is clean. Assertion
counts are derived, not transcribed: h6-aggregate.mjs
reads each harness's JSON artifact and the raw vitest/tsc logs and emits
assertions.json plus assertion-ledger.json. Raw logs are logs-*.txt; per-cell
data is census-{base,head}.json, guard-{base,head,fix}.json,
framing-{base,head}.json, stress-{base,head}.json, testplan-{base,head}.json,
mutation-matrix.json, compare.json. Six captures in evidence/ were produced with
scripts/verify-capture.mjs. The PR metadata snapshot was treated as untrusted input;
it contains no steering or injection language, and every claim in it that this report
repeats was re-measured rather than accepted.

Flakiness gate log

rounds=5 files=2 skipped=0
file packages/core/src/core/permissionFlow.test.ts: (cd packages/core) npx --no-install vitest run ./src/core/permissionFlow.test.ts
file packages/core/src/permissions/permission-manager.test.ts: (cd packages/core) npx --no-install vitest run ./src/permissions/permission-manager.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/core/permissionFlow.test.ts: PPPPP
  packages/core/src/permissions/permission-manager.test.ts: PPPPP

verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/core/src/core/permissionFlow.test.ts: P (exit 0)
round 1 · packages/core/src/permissions/permission-manager.test.ts: P (exit 0)
round 2 · packages/core/src/core/permissionFlow.test.ts: P (exit 0)
round 2 · packages/core/src/permissions/permission-manager.test.ts: P (exit 0)
round 3 · packages/core/src/core/permissionFlow.test.ts: P (exit 0)
round 3 · packages/core/src/permissions/permission-manager.test.ts: P (exit 0)
round 4 · packages/core/src/core/permissionFlow.test.ts: P (exit 0)
round 4 · packages/core/src/permissions/permission-manager.test.ts: P (exit 0)
round 5 · packages/core/src/core/permissionFlow.test.ts: P (exit 0)
round 5 · packages/core/src/permissions/permission-manager.test.ts: P (exit 0)

Evidence images

01-ab-citation-base-6-of-23-vs-head-23-of-23

02-guard-census-four-catchalls-over-reassure

03-stress-scaling-and-citation-truthfulness

04-mutation-matrix-7-of-8-killed-controls-live

05-candidate-fix-over-reassure-4-to-0

06-testplan-base-vs-head-exact-message

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-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. ✅

@qwen-code-ci-bot qwen-code-ci-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.23.1)

Comment on lines +116 to +119
const stillPermitted =
matchingRule && !isToolWideDenyRule(matchingRule)
? ' Other uses of this tool are still permitted.'
: '';

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-12: The reassurance is gated on whether the cited rule is tool-wide, never on whether the invoked tool is still usable, so a scoped rule cited alongside a catch-all in the same deny set produces a sentence that is simply false.

With permissions.deny: ["Bash(*)", "Read(//**/node_modules/**)"] — catch-all listed first, so no exotic ordering is needed — and the model running cat /app/node_modules/lodash/index.js, the new virtual-op pass cites the cross-kind scoped rule Read(//**/node_modules/**), isToolWideDenyRule classifies it as scoped, and the message appends "Other uses of this tool are still permitted." while Bash(*) denies every shell command the model could issue. Measured in that same config: echo hi is denied and ls is denied. The model keeps trying other shell commands, each denied, spending turns on work the message wrongly certifies as available. The redundant ordering ["Bash(npm view *)", "Bash(*)"] reproduces it, as does the read variant ["Read(./.env)", "Read(//**)"]. Neither new test can see this case, because both mock findMatchingDenyRule to return a single raw string, so no test exercises a two-rule deny set. Widening the spelling list in isToolWideDenyRule (reported separately on that function) does not fix a two-rule configuration.

Witness (real PermissionManager driven through the real evaluatePermissionFlow, same input on both trees):

deny: ["Bash(npm view *)","Bash(*)"]  command 'npm view foo'
BASE: Tool "run_shell_command" is denied by permission rules. Matching deny rule: "Bash(npm view *)".
PR:   This "run_shell_command" invocation was denied by permission rules. Matching deny rule: "Bash(npm view *)". Other uses of this tool are still permitted.
PR:   otherShellUses = { "echo hi": "deny", "ls": "deny", "git status": "deny" }

deny: ["Bash(*)","Read(//**/node_modules/**)"]  command 'cat /app/node_modules/lodash/index.js'
BASE: ... Matching deny rule: "Bash(*)".
PR:   ... Matching deny rule: "Read(//**/node_modules/**)". Other uses of this tool are still permitted.
PR:   otherShellUses = { "echo hi": "deny", "ls": "deny" }

controls, same PR build: deny ["Bash(*)"] alone -> no reassurance;
                         deny ["Bash(*)","Bash(npm view *)"] -> cites Bash(*), no reassurance

Ask the tool rather than the cited rule: suppress the sentence unless some other use of the invoked tool is actually permitted. That needs a query over the deny set on the manager side (for example a hasToolWideDenyRule(toolName) predicate, or returning every matching deny rule and emitting the sentence only when none of them is tool-wide), called optionally because the memory-scoped shim does not implement listRules().

The catch-all cannot be detected through the existing registry probe: matchesRule returns false for a command-specifier rule when command === undefined (rule-parser.ts:1577), which permission-manager.ts:861-863 relies on — measured, under deny ["Bash(npm view *)","Bash(*)"] getToolRegistrationStatus('run_shell_command') is "registered" and isToolEnabled is true while every command evaluates to deny — so the query has to inspect the deny rules themselves.

A case in permissionFlow.test.ts with deny rules ['Bash(*)', 'Read(//**/node_modules/**)'] asserting the message still cites the Read rule but does not contain "Other uses of this tool are still permitted" would pin this; please confirm that test reds when the gate is reverted to isToolWideDenyRule(matchingRule) alone.

中文说明

安抚句的条件是「被引用的那条规则是否为工具级」,而从未检查「被调用的工具是否还有可用之处」。因此当同一份 deny 集合里同时存在范围规则与全量规则、而引用落到范围规则上时,这句话就是假的。

permissions.deny: ["Bash(*)", "Read(//**/node_modules/**)"](全量规则排在前面,因此并不需要特殊顺序),模型执行 cat /app/node_modules/lodash/index.js:新增的虚拟操作 pass 会引用跨类型的范围规则 Read(//**/node_modules/**)isToolWideDenyRule 判定其为范围级,于是消息追加「本工具的其他用法仍被允许」——而 Bash(*) 拒绝了模型可能发出的每一条 shell 命令。同一配置下实测:echo hi 被拒、ls 被拒。模型会不断尝试其他 shell 命令、每次都被拒,把回合花在消息错误地宣称为可用的工作上。冗余顺序 ["Bash(npm view *)", "Bash(*)"] 可复现同一问题,读取类变体 ["Read(./.env)", "Read(//**)"] 同理。两个新增测试都看不到这个场景,因为它们都把 findMatchingDenyRule mock 成返回单个字符串,没有任何测试使用两条规则的 deny 集合。放宽 isToolWideDenyRule 的拼写列表(已就该函数单独提出)无法修复「两条规则」这种配置。

证据(真实 PermissionManager 经真实 evaluatePermissionFlow 驱动,两棵代码树使用同一输入):

deny: ["Bash(npm view *)","Bash(*)"]  command 'npm view foo'
BASE: Tool "run_shell_command" is denied by permission rules. Matching deny rule: "Bash(npm view *)".
PR:   This "run_shell_command" invocation was denied by permission rules. Matching deny rule: "Bash(npm view *)". Other uses of this tool are still permitted.
PR:   otherShellUses = { "echo hi": "deny", "ls": "deny", "git status": "deny" }

deny: ["Bash(*)","Read(//**/node_modules/**)"]  command 'cat /app/node_modules/lodash/index.js'
BASE: ... Matching deny rule: "Bash(*)".
PR:   ... Matching deny rule: "Read(//**/node_modules/**)". Other uses of this tool are still permitted.
PR:   otherShellUses = { "echo hi": "deny", "ls": "deny" }

同一 PR 构建下的对照:deny ["Bash(*)"] 单独 -> 不安抚;
                      deny ["Bash(*)","Bash(npm view *)"] -> 引用 Bash(*),不安抚

建议问「工具」而不是问「被引用的规则」:只有在被调用工具确实还存在其他可用用法时才输出这句话。这需要在 manager 侧对 deny 集合做一次查询(例如 hasToolWideDenyRule(toolName) 谓词,或返回全部命中的 deny 规则、仅当其中没有工具级规则时才输出该句),并且要可选调用,因为 memory 作用域的 shim 没有实现 listRules()

全量规则无法通过现有的注册表探测发现:当 command === undefinedmatchesRule 对带 command specifier 的规则返回 false(rule-parser.ts:1577),而 permission-manager.ts:861-863 正是依赖这一点——实测在 deny ["Bash(npm view *)","Bash(*)"]getToolRegistrationStatus('run_shell_command')"registered"isToolEnabledtrue,而每条命令都判为 deny——因此该查询必须直接检查 deny 规则本身。

在 permissionFlow.test.ts 中补一个 deny 规则为 ['Bash(*)', 'Read(//**/node_modules/**)'] 的用例,断言消息仍引用 Read 规则、但不包含「Other uses of this tool are still permitted」,即可固定该行为;请确认把条件回退为仅 isToolWideDenyRule(matchingRule) 时该测试会变红。

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

Comment on lines +138 to +142
if (!rule.specifier) {
// No specifier blocks the whole tool; a param-matcher-only rule
// (e.g. `Agent(model:opus)`) is still scoped by its matchers.
return !rule.toolParamMatchers?.length;
}

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-9: Nothing gates the no-specifier branch of isToolWideDenyRule. Replacing its body with return false leaves every test green, so a regression there tells the model other uses are permitted for a bare-name or empty-specifier tool-wide block — the exact misleading reassurance #11405 exists to remove — with the whole suite silent.

The forms at risk are the ones this helper's own doc comment calls tool-wide: a bare tool name (Bash) and an empty specifier (Bash()). If the branch regresses to "scoped", the model reads This "shell" invocation was denied by permission rules. Matching deny rule: "Bash". Other uses of this tool are still permitted. for a tool that is blocked entirely. The toolParamMatchers half is equally unpinned — Agent(model:opus) must stay scoped, and that assertion is the only thing keeping this branch from being a constant. permissionFlow.test.ts is the only file in the repo that asserts on this message.

Witness (mutation probe, measured):

mutant: `return !rule.toolParamMatchers?.length;` -> `return false;`
packages/core/src/core/permissionFlow.test.ts  31/31 GREEN  (mutant survived)
positive control, unmutated PR code, both changed test files: 453/453 GREEN
revert probe (base source + the PR's tests): 3 of the 4 added tests RED on assertion, not compile

Extend the existing catch-all loop in permissionFlow.test.ts with 'Bash' and 'Bash()', and add one positive case for a param-matcher-only rule ('Agent(model:opus)') asserting the message does contain "Other uses of this tool are still permitted".

That loop feeds the classifier through a mock returning the raw string verbatim (findMatchingDenyRule: vi.fn().mockReturnValue(raw), permissionFlow.test.ts:125), so parseRule alone decides: new cases must be strings it really parses into the intended shape — bare Bash is "Simple tool name rule (no specifier)" (rule-parser.ts:394) and Agent(model:opus) yields specifier = plainParts.join(',').trim() || undefined with toolParamMatchers set (rule-parser.ts:462-463).

With 'Bash' and 'Bash()' added, that test is green on the PR's code (31/31) and goes red under the return false mutation, reporting Received: "This \"shell\" invocation was denied by permission rules. Matching deny rule: \"Bash\". Other uses of this tool are still permitted." — please confirm that red before landing the cases.

中文说明

isToolWideDenyRule 的「无 specifier」分支没有任何测试把关。把它的函数体换成 return false,全部测试仍然是绿的;因此该分支一旦回归,就会对裸工具名或空 specifier 的工具级封禁告诉模型「其他用法仍被允许」——这正是 #11405 要消除的误导性安抚——而整个测试套件不会有任何声响。

受影响的形式正是本函数自身注释称为工具级的那两种:裸工具名(Bash)与空 specifier(Bash())。若该分支回归为「范围级」,模型会读到 This "shell" invocation was denied by permission rules. Matching deny rule: "Bash". Other uses of this tool are still permitted.,而该工具其实已被完全封禁。toolParamMatchers 那一半同样没有被固定——Agent(model:opus) 必须保持为范围级,而这一断言正是防止该分支退化成常量的唯一约束。全仓库中只有 permissionFlow.test.ts 对这条消息做断言。

证据(变异探测,实测):

变异体:`return !rule.toolParamMatchers?.length;` -> `return false;`
packages/core/src/core/permissionFlow.test.ts  31/31 绿(变异体存活)
阳性对照,未变异的 PR 代码,两个被改测试文件:453/453 绿
回退探测(基线源码 + 本 PR 的测试):4 个新增测试中有 3 个在断言处变红,而非编译失败

建议在 permissionFlow.test.ts 现有的全量规则循环中补上 'Bash''Bash()',并补一个仅含 param-matcher 的规则('Agent(model:opus)')的正向用例,断言消息确实包含「Other uses of this tool are still permitted」。

该循环通过 mock 原样返回字符串(findMatchingDenyRule: vi.fn().mockReturnValue(raw),permissionFlow.test.ts:125),因此完全由 parseRule 决定:新增用例必须是它真的会解析成预期形状的字符串——裸 Bash 属于「Simple tool name rule (no specifier)」(rule-parser.ts:394),Agent(model:opus) 则得到 specifier = plainParts.join(',').trim() || undefinedtoolParamMatchers 被设置(rule-parser.ts:462-463)。

补上 'Bash''Bash()' 后,该测试在 PR 代码上是绿的(31/31),在 return false 变异下会变红并报出 Received: "This \"shell\" invocation was denied by permission rules. Matching deny rule: \"Bash\". Other uses of this tool are still permitted."——请在合入前确认它确实变红。

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

Comment on lines +143 to +146
if (rule.specifierKind === 'path') {
// `//**` resolves to the filesystem root (`/**`) and matches every path.
return rule.specifier === '//**';
}

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: isToolWideDenyRule decides "blocks the whole tool" by exact string comparison against two hand-listed spellings, so other spellings the matchers do treat as match-everything are classified scoped and still receive the reassurance for a tool that is fully blocked.

This carries forward the concern from the previous review round about deciding tool-wide-vs-invocation-scoped from the raw string. The reported entrances are closed — Bash(*), Read(//**), WebFetch(*), bare Bash and Bash() all now classify correctly. What remains is that the surface is not finitely enumerable, so topping up the list will keep leaving holes: Bash(**) and Bash(* *) compile to match-every-command regexes, and Read(//**/*) and Read(//**/**) resolve to /**/* and /**/** and match every absolute path, yet none of them equals '*' or '//**'. With permissions.deny: ["Bash(**)"] every shell call is denied and cited as Bash(**), and the message still ends with "Other uses of this tool are still permitted." — an affirmative falsehood, which sends the model back into the retry-a-fully-blocked-tool loop #11405 describes, in the opposite direction. It is worth closing structurally rather than entrance by entrance.

Witness (real PermissionManager driven through the real evaluatePermissionFlow at this commit):

Bash(**)      denied 12/12 commands (incl. '', 'a', 'multi-line') cited="Bash(**)"
              msg="... Matching deny rule: \"Bash(**)\". Other uses of this tool are still permitted."
Bash(* *)     denied 12/12 cited="Bash(* *)"      ... same reassurance
Read(//**/**) denied 7/7 paths (incl. /x, /.env)  ... same reassurance
Read(//**/*)  denied 5/7 (misses only files directly at /) ... same reassurance
matchesCommandPattern('**'|'* *', c) = true for git status|echo hi|npm view foo|ls -la|cat /etc/hosts

candidate fix (consult the matcher) flips all four, 8 scoped controls unchanged:
  Bash(**) true->false  Bash(* *) true->false  Read(//**/*) true->false  Read(//**/**) true->false
  unchanged scoped: Bash(npm view *), Bash(git *), Read(//**/node_modules/**), Read(/src/**),
                    Write(./dist/**), Read(/**), Write(**), Agent(model:*)
  unchanged already-correct: Bash(*), Bash, Bash(), Read(//**), WebFetch -> false

Derive the answer from the matchers instead of listing spellings, and fail closed when the shape is unrecognisable. Putting the predicate in rule-parser.ts keeps it beside the semantics it describes: for command kind ask matchesCommandPattern(rule.specifier, <probe command>); for path kind run resolvePathPattern and ask matchesPathPattern against a probe path outside the project root; for domain and literal kinds return false, since matchesDomainPattern has no wildcard support at all (reported separately on the comment below).

Do not widen the path branch to a star-only test: resolvePathPattern maps only the // prefix to the filesystem root (rule-parser.ts:1214-1216, return specifier.substring(1);) while a prefix-less specifier falls through to toPosixPath(path.join(cwd, specifier)). Measured, with deny ['Read(/**)'], ['Read(./**)'], ['Read(**)'] and ['Write(**)'] a read or write of /etc/passwd evaluates to ask, not deny — those spellings are genuinely scoped, so classifying them scoped is correct today and a star-only test would break it. Also rule-parser.ts:981 (if (pattern === '*') { return true; }) is the only unconditional command catch-all, so Bash(*) must stay tool-wide.

Adding 'Bash(**)' and 'Read(//**/**)' to the raw list of the existing does not reassure for tool-wide catch-all deny rules (#11405) loop is red against the current literal comparison and green once the classifier consults the matcher; please confirm restoring specifier === '*' reds them again.

中文说明

isToolWideDenyRule 是通过与两个手写列出的拼写做精确字符串比较来判断「是否封禁整个工具」的,因此匹配器实际视为「匹配一切」的其他拼写会被判定为范围级,从而对一个已被完全封禁的工具仍然输出安抚句。

这一点承接上一轮审查中关于「靠原始字符串判断工具级还是调用级」的意见。被点名的入口已经关闭——Bash(*)Read(//**)WebFetch(*)、裸 BashBash() 现在都能正确分类。剩下的是:这个面无法有限枚举,所以继续往列表里补拼写会不断留下缺口——Bash(**)Bash(* *) 会编译成匹配任意命令的正则,Read(//**/*)Read(//**/**) 会解析为 /**/*/**/** 并匹配任意绝对路径,而它们都不等于 '*''//**'。在 permissions.deny: ["Bash(**)"] 下,每条 shell 调用都被拒并引用 Bash(**),消息却仍以「本工具的其他用法仍被允许」结尾——这是一句确定的假话,会把模型送回 #11405 所描述的「反复重试一个已被完全封禁的工具」的循环,只是方向相反。建议从结构上关闭它,而不是逐个入口去补。

证据(在本 commit 上用真实 PermissionManager 经真实 evaluatePermissionFlow 驱动):

Bash(**)      denied 12/12 commands (incl. '', 'a', 'multi-line') cited="Bash(**)"
              msg="... Matching deny rule: \"Bash(**)\". Other uses of this tool are still permitted."
Bash(* *)     denied 12/12 cited="Bash(* *)"      ... same reassurance
Read(//**/**) denied 7/7 paths (incl. /x, /.env)  ... same reassurance
Read(//**/*)  denied 5/7 (misses only files directly at /) ... same reassurance
matchesCommandPattern('**'|'* *', c) = true for git status|echo hi|npm view foo|ls -la|cat /etc/hosts

候选修复(改为询问匹配器)可翻转以上四项,且 8 个范围级对照保持不变:
  Bash(**) true->false  Bash(* *) true->false  Read(//**/*) true->false  Read(//**/**) true->false
  范围级保持不变:Bash(npm view *)、Bash(git *)、Read(//**/node_modules/**)、Read(/src/**)、
                 Write(./dist/**)、Read(/**)、Write(**)、Agent(model:*)
  原本已正确的保持不变:Bash(*)、Bash、Bash()、Read(//**)、WebFetch -> false

建议从匹配器推导答案而不是列举拼写,并在形状无法识别时 fail closed。把该谓词放进 rule-parser.ts 可以让它与它所描述的语义放在一起:command 类型询问 matchesCommandPattern(rule.specifier, <探测命令>)path 类型先跑 resolvePathPattern,再用项目根之外的探测路径询问 matchesPathPatterndomainliteral 类型返回 false,因为 matchesDomainPattern 完全不支持通配(已就下方注释单独提出)。

不要把 path 分支放宽成「仅由星号组成」的判断:resolvePathPattern 只把 // 前缀映射到文件系统根(rule-parser.ts:1214-1216,return specifier.substring(1);),而无前缀的 specifier 会落到 toPosixPath(path.join(cwd, specifier))。实测在 deny 为 ['Read(/**)']['Read(./**)']['Read(**)']['Write(**)'] 时,对 /etc/passwd 的读写判为 ask 而非 deny——这些拼写确实是范围级的,因此当前把它们判为范围级是正确的,而「仅星号」的判断会破坏这一点。另外 rule-parser.ts:981(if (pattern === '*') { return true; })是唯一无条件的命令全量匹配,因此 Bash(*) 必须保持为工具级。

在现有 does not reassure for tool-wide catch-all deny rules (#11405) 循环的 raw 列表中加入 'Bash(**)''Read(//**/**)',在当前的字面比较下是红的、在改为询问匹配器后是绿的;请确认恢复 specifier === '*' 后它们会再次变红。

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

Comment on lines +147 to +148
// `*` is the documented catch-all for command and domain specifiers.
return rule.specifier === '*';

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 comment states a rule-grammar fact the engine does not implement. matchesDomainPattern has no wildcard support, so WebFetch(*) can never deny anything and findMatchingDenyRule can never return it — the domain leg of this branch is unreachable through the real engine.

matchesDomainPattern strips an optional domain: prefix and then does exact-or-subdomain comparison only. A maintainer who trusts this new in-repo comment writes deny: ["WebFetch(*)"] to block fetching and gets no denial at all: web_fetch proceeds to every domain while they believe it is blocked. The genuinely tool-wide form is the bare WebFetch, handled by the !rule.specifier branch — and it is the one form the new test does not cover. The new comment lines are the only place in the repository asserting that grammar; the user docs offer only bare WebFetch, WebFetch(api.example.com) and WebFetch(malicious.com). The third entry of the new test loop passes only because findMatchingDenyRule is mocked to hand back that string, so it pins a classification for an input production cannot produce and reports domain coverage the suite does not have.

Witness (executed at this commit):

matchesDomainPattern('*'|'domain:*', 'example.com'|'sub.example.com'|'other.org') = false (all 6)
matchesRule(parseRule('WebFetch(*)'), 'web_fetch', domain='example.com') = false
real PM: deny ['WebFetch(*)']        -> evaluate=default, cited=undefined
         deny ['WebFetch(domain:*)'] -> evaluate=default, cited=undefined
         deny ['WebFetch']           -> evaluate=deny,   cited="WebFetch"
literal-kind sibling on this same branch:
         deny ['Skill(*)']           -> evaluate=default, cited=undefined
         deny ['Skill']              -> evaluate=deny,   cited="Skill"
Suggested change
// `*` is the documented catch-all for command and domain specifiers.
return rule.specifier === '*';
// `*` is the documented catch-all for command specifiers. The domain
// matcher has no wildcard support, so bare `WebFetch` is the tool-wide form.
return rule.specifier === '*';

Also replace 'WebFetch(*)' with the bare 'WebFetch' in the loop at permissionFlow.test.ts:122, which pins the !rule.specifier branch on an input the matcher can actually produce. If a wildcard domain deny is genuinely wanted, that is a separate change to matchesDomainPattern with its own test, not something this classifier should assume.

With 'WebFetch' (bare) in that loop, deleting the true result the !rule.specifier branch returns for bare names must red that iteration.

中文说明

这条注释陈述了一个引擎并未实现的规则语法事实。matchesDomainPattern 不支持通配,因此 WebFetch(*) 永远无法拒绝任何请求,findMatchingDenyRule 也永远不可能返回它——该分支的 domain 一侧在真实引擎中不可达。

matchesDomainPattern 只会剥掉可选的 domain: 前缀,然后做精确匹配或子域匹配。若维护者相信这条新增的仓库内注释,写下 deny: ["WebFetch(*)"] 想封禁抓取,结果是一条都不会被拒:web_fetch 对任意域名照常放行,而他们认为已经封住了。真正工具级的形式是裸 WebFetch,由 !rule.specifier 分支处理——而这恰恰是新增测试没有覆盖的那一种。这几行新注释是全仓库唯一断言该语法的地方;用户文档只提供裸 WebFetchWebFetch(api.example.com)WebFetch(malicious.com)。新测试循环的第三项之所以通过,只是因为 findMatchingDenyRule 被 mock 成返回该字符串,因此它固定的是生产环境不可能产生的输入的分类,并对外宣称了套件其实并不具备的 domain 覆盖。

证据(在本 commit 上执行):

matchesDomainPattern('*'|'domain:*', 'example.com'|'sub.example.com'|'other.org') = false (all 6)
matchesRule(parseRule('WebFetch(*)'), 'web_fetch', domain='example.com') = false
real PM: deny ['WebFetch(*)']        -> evaluate=default, cited=undefined
         deny ['WebFetch(domain:*)'] -> evaluate=default, cited=undefined
         deny ['WebFetch']           -> evaluate=deny,   cited="WebFetch"
同一分支上的 literal 类型同类情况:
         deny ['Skill(*)']           -> evaluate=default, cited=undefined
         deny ['Skill']              -> evaluate=deny,   cited="Skill"

同时请把 permissionFlow.test.ts:122 循环中的 'WebFetch(*)' 换成裸 'WebFetch',这样固定的才是匹配器确实能产生的输入所对应的 !rule.specifier 分支。如果真的需要通配域名封禁,那应当是对 matchesDomainPattern 的独立改动并配独立测试,而不是由这个分类函数去假定。

在该循环中使用裸 'WebFetch' 后,删除 !rule.specifier 分支对裸名返回 true 的行为,必须使该次迭代变红。

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

}
});

it('cites the deny rule for a compound command segment', () => {

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-7: This test passes equally well if the per-segment recursion it exists to cover is replaced by a flat matchesRule loop, so the one behaviour the recursion adds — a per-segment shell virtual-op pass — is unpinned, even though the in-diff comment at permission-manager.ts:953-954 claims "nested compounds and per-segment virtual ops are covered".

splitCompoundCommand is already flat (it splits on every unquoted operator), so re-splitting a segment yields nothing new and the recursion differs from a flat loop only by re-running extractShellOperationsAcrossCommand per segment. With cwd /project and deny ['Read(//project/node_modules/**)'], the command cd /tmp && cat node_modules/lodash/index.js is cited only because of that per-segment pass. Replacing the recursion with a flat loop — the obvious simplification a future reader makes, since the tested Bash(npm view *) case does not tell the two apart — leaves the whole suite green while the citation becomes undefined and the user-visible message loses the rule entirely, even though evaluate() still denies. That is the #11405 symptom returning with a green suite. Separately, neither new test asserts the coupled invariant that evaluate() actually denies the context the rule is cited for, so a future change that makes evaluate() stop denying these shapes leaves these tests asserting a message no user can ever see.

Witness (flat-loop mutant executed):

MUTANT src/permissions/permission-manager.test.ts (422 tests) PASS  <- both new tests green
       src/core/permissionFlow.test.ts (31 tests) PASS
       src/permissions + src/memory + permissionFlow: 1607 passed
PROBE INTACT: evaluate=deny citation="Read(//project/node_modules/**)"
      MUTANT: evaluate=deny citation=undefined            <- probe flips
E2E INTACT denyMessage="... Matching deny rule: \"Read(//project/node_modules/**)\". Other uses ..."
    MUTANT denyMessage="This \"run_shell_command\" invocation was denied by permission rules."
pass attribution: deny Read(//tmp/node_modules/**)  -> FULL-COMMAND cd-tracked pass cites it (unchanged under mutant)
                  deny Read(//project/node_modules/**) -> only the PER-SEGMENT pass can cite it (undefined under mutant)

Add a compound case whose deciding rule is a path rule reached only via a later segment's virtual op (the cd /tmp && cat node_modules/... shape, with projectRoot/cwd set via makeConfig), and assert await expect(pm.evaluate(ctx)).resolves.toBe('deny') beside the citation in both new tests so the decision and the citation are pinned together. The comment at permission-manager.ts:953-954 is also half-overstated: "nested compounds" adds nothing over the flat split, "per-segment virtual ops" is the load-bearing half.

evaluateCompoundCommand builds each segment context as const subCtx: PermissionCheckContext = { ...ctx, command: subCmd }; (permission-manager.ts:573) and evaluateSingle re-runs the virtual-op extraction per segment with pathCtx?.cwd ?? process.cwd() (permission-manager.ts:454-465), so the new expectation must mirror that un-tracked per-segment cwd, not the cd-tracked cwd of the full-command pass.

The new case must go red (citation undefined instead of the Read(...) raw) when the recursive this.findMatchingDenyRule({ ...ctx, command: subCmd }) call is replaced by a flat per-segment matchesRule loop, and the added evaluate() assertion must go red if the compound pass is removed while the citation still resolves.

中文说明

如果把本测试所要覆盖的逐段递归换成一个扁平的 matchesRule 循环,本测试同样会通过;因此递归真正新增的那一点行为——逐段的 shell 虚拟操作 pass——并没有被固定,尽管 diff 中 permission-manager.ts:953-954 的注释声称「嵌套复合命令与逐段虚拟操作都已覆盖」。

splitCompoundCommand 本身已经是扁平的(它会在每个未被引号包裹的操作符处切分),因此对片段再次切分不会产生任何新东西,递归与扁平循环的唯一差别就是对每段重跑 extractShellOperationsAcrossCommand。设 cwd 为 /project、deny 为 ['Read(//project/node_modules/**)'],命令 cd /tmp && cat node_modules/lodash/index.js 之所以能被引用,正是因为这个逐段 pass。把递归换成扁平循环——这是未来读者很容易做出的「简化」,因为已测试的 Bash(npm view *) 用例分辨不出两者——整个套件仍是绿的,而引用会变成 undefined、用户可见的消息完全失去规则名,尽管 evaluate() 仍然判为拒绝。那就是 #11405 的症状在套件全绿的情况下回归。另外,两个新增测试都没有断言「evaluate() 确实会对被引用规则所对应的上下文判为拒绝」这一耦合不变量,因此将来若 evaluate() 不再对这些形状判拒,这些测试仍会断言一条用户永远看不到的消息。

证据(已执行扁平循环变异体):

MUTANT src/permissions/permission-manager.test.ts (422 tests) PASS  <- 两个新测试仍绿
       src/core/permissionFlow.test.ts (31 tests) PASS
       src/permissions + src/memory + permissionFlow: 1607 passed
PROBE INTACT: evaluate=deny citation="Read(//project/node_modules/**)"
      MUTANT: evaluate=deny citation=undefined            <- 探测翻转
E2E INTACT denyMessage="... Matching deny rule: \"Read(//project/node_modules/**)\". Other uses ..."
    MUTANT denyMessage="This \"run_shell_command\" invocation was denied by permission rules."
pass 归因:deny Read(//tmp/node_modules/**)     -> 由完整命令的 cd 跟踪 pass 引用(变异下不变)
           deny Read(//project/node_modules/**) -> 只有逐段 pass 能引用(变异下为 undefined)

建议补一个复合用例,其决定性规则是只能经由后一段的虚拟操作命中的路径规则(即 cd /tmp && cat node_modules/... 这种形状,通过 makeConfig 设置 projectRoot/cwd),并在两个新测试中于引用旁边断言 await expect(pm.evaluate(ctx)).resolves.toBe('deny'),把决定与引用一起固定。permission-manager.ts:953-954 的注释也有一半言过其实:「嵌套复合命令」相对扁平切分没有新增任何东西,「逐段虚拟操作」才是承重的那一半。

evaluateCompoundCommandconst subCtx: PermissionCheckContext = { ...ctx, command: subCmd }; 构造每段上下文(permission-manager.ts:573),evaluateSingle 则以 pathCtx?.cwd ?? process.cwd() 对每段重跑虚拟操作提取(permission-manager.ts:454-465),因此新增断言必须对应这种未做 cd 跟踪的逐段 cwd,而不是完整命令 pass 的 cd 跟踪 cwd。

当递归调用 this.findMatchingDenyRule({ ...ctx, command: subCmd }) 被替换为扁平的逐段 matchesRule 循环时,新用例必须变红(引用变为 undefined 而不是 Read(...) 原始串);而当复合 pass 被移除但引用仍可解析时,新增的 evaluate() 断言必须变红。

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

// though the deny rule's toolName (e.g. `read_file`) never matches the
// shell tool name. Without this pass the citation silently drops.
if (SHELL_TOOL_NAMES.has(toolName) && command !== undefined) {
const cwdForOps = pathCtx?.cwd ?? process.cwd();

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-10: The cwd resolution in the new virtual-op citation pass is unpinned. Changing pathCtx?.cwd ?? process.cwd() to plain process.cwd() leaves all 453 tests in the two changed files green, so a regression here silently loses the citation for relative paths.

The added test cites through an absolute path (cat /app/node_modules/lodash/index.js), so the cwd never enters into it. If the pathCtx?.cwd half is later dropped, a shell command with a relative path (cat secret.txt) evaluated where the permission cwd differs from the CLI process cwd resolves against the wrong directory, the Read/Edit/Write rule misses, and findMatchingDenyRule returns undefined. The denial still happens — evaluate() resolves with the correct cwd — so the user sees a deny whose message has silently lost its Matching deny rule: "..." citation: the #11405 symptom, back for relative paths, with no red test.

Witness (mutation probe, measured):

mutant: `pathCtx?.cwd ?? process.cwd()` -> `process.cwd()`
both changed test files: 453/453 GREEN (mutant survived)
witness test: makeConfig({ permissionsDeny: ['Read(//project/secret.txt)'], cwd: '/project' })
              command 'cat secret.txt'
  INTACT: passes (permission-manager.test.ts 423/423)
  MUTANT: RED, received undefined

Add a case to the PermissionManager.findMatchingDenyRule block using makeConfig({ permissionsDeny: ['Read(//project/secret.txt)'], cwd: '/project' }) and command: 'cat secret.txt', asserting the citation is 'Read(//project/secret.txt)'. makeConfig already supports it (getCwd: () => opts.cwd ?? '/project', permission-manager.test.ts:1717).

evaluate() resolves the same extractor with const cwd = pathCtx?.cwd ?? process.cwd(); (permission-manager.ts:324), and the comment above it (lines 305-313) makes that pass "the single source of truth for cd tracking", citing deny: ["Write(.qwen/settings.json)"] having to match cd .qwen && bash -lc 'echo > settings.json' — so the citation pass must keep resolving with the identical cwd, or the deny and its citation diverge.

That case passes on the PR's code and goes red (received undefined) when cwdForOps becomes process.cwd() — please confirm that red before landing it.

中文说明

新增虚拟操作引用 pass 中的 cwd 解析没有被测试固定。把 pathCtx?.cwd ?? process.cwd() 改成单纯的 process.cwd(),两个被改文件中的全部 453 个测试仍然是绿的,因此这里一旦回归,相对路径的引用会悄无声息地丢失。

新增测试是通过绝对路径来引用的(cat /app/node_modules/lodash/index.js),所以 cwd 根本不会参与。若日后 pathCtx?.cwd 那一半被去掉,当权限 cwd 与 CLI 进程 cwd 不同时,带相对路径的 shell 命令(cat secret.txt)会按错误的目录解析,Read/Edit/Write 规则落空,findMatchingDenyRule 返回 undefined。拒绝本身仍然发生——evaluate() 用的是正确的 cwd——于是用户看到一次拒绝,而消息里悄悄丢掉了 Matching deny rule: "..." 引用:#11405 的症状在相对路径上回归,且没有任何测试变红。

证据(变异探测,实测):

变异体:`pathCtx?.cwd ?? process.cwd()` -> `process.cwd()`
两个被改测试文件:453/453 绿(变异体存活)
witness 测试:makeConfig({ permissionsDeny: ['Read(//project/secret.txt)'], cwd: '/project' })
              command 'cat secret.txt'
  未变异:通过(permission-manager.test.ts 423/423)
  变异后:红,received undefined

建议在 PermissionManager.findMatchingDenyRule 区块中补一个用例,使用 makeConfig({ permissionsDeny: ['Read(//project/secret.txt)'], cwd: '/project' })command: 'cat secret.txt',断言引用为 'Read(//project/secret.txt)'makeConfig 已经支持(getCwd: () => opts.cwd ?? '/project',permission-manager.test.ts:1717)。

evaluate()const cwd = pathCtx?.cwd ?? process.cwd(); 解析同一个提取器(permission-manager.ts:324),其上方注释(305-313 行)把该 pass 定为「cd 跟踪的唯一真相来源」,并举例 deny: ["Write(.qwen/settings.json)"] 必须能匹配 cd .qwen && bash -lc 'echo > settings.json'——因此引用 pass 必须使用完全相同的 cwd 解析,否则拒绝与其引用会发生分歧。

该用例在 PR 代码上通过,而在 cwdForOps 变成 process.cwd() 时变红(received undefined)——请在合入前确认它确实变红。

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

Comment on lines +941 to +947
for (const rule of denyRules) {
if (
matchesRule(rule, ...opMatchArgs, undefined, undefined, 'canonical')
) {
return rule.raw;
}
}

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-6: Composing a one-line deny citation now costs about 96% of a second evaluate(), because the two new passes re-run the whole shell analysis on the same context that evaluate() just analysed — and all of it is synchronous, on the CLI event loop, inside the same denied call.

permission-helpers.ts:136 runs await pm.evaluate(pmCtx) and permissionFlow.ts:107 then runs pm?.findMatchingDenyRule(pmCtx) on the same pmCtx. The dominant cost is this ops x denyRules loop, each pair paying a realpathNearestExisting syscall walk (rule-parser.ts:1316-1358) plus a freshly compiled picomatch matcher with no memoization (rule-parser.ts:1256-1273). A denied call with a large compound command and several path deny rules stalls the TUI for hundreds of milliseconds purely to build denyMessage. This is bounded, which is why it is a Suggestion rather than a blocker: everyday shapes cost 0.03-8ms, and the other two call sites pass no command, gating both new passes off entirely.

Witness (measured at this commit, best-of-3; 10 x Read(//**/neverN/**), 200 cat /nonexistentN/file.txt segments joined by &&):

INTACT  find=150.4ms  evaluate=156.3ms  extract=0.96ms  split=0.37ms  ops=200 segs=200
        1 path rule:  find=18.0ms   evaluate=23.1ms
        Bash(npm view *): find=5.06ms
        'cd /tmp && npm view foo' (11 rules): find=0.0322ms cite="Bash(npm view *)"
M0 (both new passes removed = pre-diff shape): find=0.0ms  evaluate=162.3ms ; short case cite=undefined
M1 (recursion only, virtual-op pass removed):  find=3.4ms
M2 (full-command virtual-op pass only):        find=70.3ms
scale sweep: rules=10 -> 5seg=3.84 25seg=20.29 50seg=40.84 100seg=80.24 200seg=170.56ms
             rules=20 -> 200seg=320.93ms ; existing-path arm 200seg x 10rules = 95.29ms

Stop paying for the analysis twice: memoize the pure extractShellOperationsAcrossCommand(command, cwd) result in a size-bounded LRU keyed by cwd + '\0' + command so evaluate() and the citation share one parse, and/or cache the canonical-path candidates and picomatch matchers that matchesPathPattern rebuilds per call.

Dropping the per-segment virtual-op pass is not a safe shortcut, and this was confirmed by execution rather than assumed: the flat mutant removed exactly that pass and the citation for cd /tmp && cat node_modules/lodash/index.js went from Read(//project/node_modules/**) to undefined while evaluate() still returned deny, because evaluate()'s own per-segment pass (permission-manager.ts:456-470 -> evaluateShellVirtualOps) resolves each segment against the original pathCtx.cwd. Any memoization must keep per-segment resolution, and a cache keyed on command strings must be size-bounded because those strings are model- and user-controlled.

The two tests this diff adds must stay green under any restructure: cites the deny rule for a compound command segment pins the segment pass and cites the deny rule when a shell command is denied via a virtual file op pins the ops pass. For the memo, please also add an assertion that it is keyed on the parse only — two calls with the same command against different deny-rule sets must each return their own rule — and confirm that assertion reds if the cache is keyed on the outcome instead.

中文说明

现在拼装一行拒绝引用的开销约等于再跑一次 evaluate() 的 96%,因为两个新增 pass 会在 evaluate() 刚刚分析过的同一上下文上把整套 shell 分析重跑一遍——而且全部是同步的,运行在 CLI 事件循环上,处于同一次被拒调用之内。

permission-helpers.ts:136 执行 await pm.evaluate(pmCtx),随后 permissionFlow.ts:107 在同一个 pmCtx 上执行 pm?.findMatchingDenyRule(pmCtx)。主要开销是这个 ops x denyRules 循环:每一对都要付一次 realpathNearestExisting 系统调用行走(rule-parser.ts:1316-1358),外加一次未经记忆化的、现场编译的 picomatch 匹配器(rule-parser.ts:1256-1273)。当一次被拒调用带有大型复合命令和若干路径类 deny 规则时,仅仅为了构造 denyMessage 就会让 TUI 卡住数百毫秒。这是有边界的,因此定为建议而非阻塞项:日常形状耗时 0.03-8ms,而另外两个调用点不传 command,两个新 pass 在它们那里完全被关闭。

证据(在本 commit 上实测,取三次最优;10 条 Read(//**/neverN/**),200 段以 && 连接的 cat /nonexistentN/file.txt):

INTACT  find=150.4ms  evaluate=156.3ms  extract=0.96ms  split=0.37ms  ops=200 segs=200
        1 path rule:  find=18.0ms   evaluate=23.1ms
        Bash(npm view *): find=5.06ms
        'cd /tmp && npm view foo' (11 rules): find=0.0322ms cite="Bash(npm view *)"
M0(移除两个新 pass = 改动前形状):find=0.0ms  evaluate=162.3ms;短用例 cite=undefined
M1(仅保留递归,移除虚拟操作 pass):find=3.4ms
M2(仅保留完整命令虚拟操作 pass):  find=70.3ms
规模扫描:rules=10 -> 5seg=3.84 25seg=20.29 50seg=40.84 100seg=80.24 200seg=170.56ms
          rules=20 -> 200seg=320.93ms;路径真实存在的对照 200seg x 10rules = 95.29ms

建议不要把同一份分析付两次:把纯函数 extractShellOperationsAcrossCommand(command, cwd) 的结果放进一个有大小上限的 LRU(键为 cwd + '\0' + command),让 evaluate() 与引用共享一次解析;和/或缓存 matchesPathPattern 每次调用都重建的规范路径候选与 picomatch 匹配器。

去掉逐段虚拟操作 pass 并不是安全的捷径,这一点是经执行确认而非假定的:扁平变异体恰好移除了该 pass,于是 cd /tmp && cat node_modules/lodash/index.js 的引用从 Read(//project/node_modules/**) 变成 undefined,而 evaluate() 仍返回 deny——因为 evaluate() 自己的逐段 pass(permission-manager.ts:456-470 -> evaluateShellVirtualOps)会按原始 pathCtx.cwd 解析每一段。任何记忆化都必须保留逐段解析,且以命令字符串为键的缓存必须有大小上限,因为这些字符串由模型和用户控制。

本 diff 新增的两个测试在任何重构下都必须保持绿色:cites the deny rule for a compound command segment 固定逐段 pass,cites the deny rule when a shell command is denied via a virtual file op 固定 ops pass。对于记忆化方案,请再补一条断言说明它只以解析结果为键——同一命令面对不同的 deny 规则集合时,两次调用必须各自返回自己的规则——并确认若缓存改为以输出为键,该断言会变红。

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

Comment on lines +958 to +959
for (const subCmd of subCommands) {
const rule = this.findMatchingDenyRule({ ...ctx, command: subCmd });

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-5: This recursion re-enters the public method, so normalizePermissionContext runs again on each segment, while evaluate()'s compound path matches segments verbatim after normalizing once. For the monitor tool a segment that is itself a shell wrapper gets stripped a second time, so the citation can name a rule that did not decide the denial.

With deny ['Bash(rm *)','Bash(touch *)'] and the monitor invocation bash -lc 'bash -c "rm y" && touch x': normalizePermissionContext unwraps the outer wrapper once, evaluate() splits and matches the segments as written, so Bash(touch *) decides the deny. The recursion then re-enters with the segment bash -c "rm y", which normalizeMonitorCommand strips again to rm y, so Bash(rm *) matches first and is cited. The user reads Matching deny rule: "Bash(rm *)" for a denial Bash(touch *) decided, deletes that rule, re-runs the identical command and is denied again by the rule that was never named. The same shape via run_shell_command, which does not normalize, correctly cites Bash(touch *). The reachable surface is narrow — toolName === 'monitor' with a wrapper nested inside a compound — but what it produces is a wrongly named rule, not merely a missing one, and it is new relative to base.

Witness (executed at this commit):

normalizeMonitorCommand("bash -lc 'foo && bash -c \"rm y\"'").safetyCommand = "foo && bash -c \"rm y\""
normalizeMonitorCommand("foo && bash -c \"rm y\"").safetyCommand           = "foo && bash -c \"rm y\""  -> idempotent on a non-wrapper
normalizeMonitorCommand("bash -c \"rm y\"").safetyCommand                 = "rm y"                   -> SEGMENT stripped again
normalizeMonitorCommand("rm y").safetyCommand                              = "rm y"                   -> idempotent

end-to-end via evaluatePermissionFlow, monitor, deny ['Bash(rm *)','Bash(touch *)'],
  command `bash -lc 'bash -c "rm y" && touch x'`:
PR:   FLOW=deny msg="This \"monitor\" invocation was denied by permission rules. Matching deny rule: \"Bash(rm *)\". Other uses of this tool are still permitted."
BASE: FLOW=deny msg="Tool \"monitor\" is denied by permission rules." (no rule cited)
same shape via run_shell_command (no normalization): PR cited="Bash(touch *)"  <- the deciding rule

Normalize once: factor the three passes into a private findMatchingDenyRuleNormalized(ctx) that does not call normalizePermissionContext, have the public method normalize and delegate, and have the recursion call the private form with { ...ctx, command: subCmd }. That closes this wrong citation and the identical divergence in the two sibling methods at once.

hasRelevantRules (permission-manager.ts:1112-1118) and hasMatchingAskRule (:1207-1213) recurse through their own public entry points and carry the identical double normalization, so fixing only findMatchingDenyRule leaves the relevance check and the citation disagreeing for monitor commands — the three either get the same treatment or the divergence is accepted knowingly. evaluate()'s own compound path (evaluateCompoundCommand -> evaluateSingle, permission-manager.ts:572-576) matches segments verbatim, which is the behaviour the citation must mirror.

A new case in the describe('PermissionManager.findMatchingDenyRule') block — toolName 'monitor', deny ['Bash(rm *)','Bash(touch *)'], command bash -lc 'bash -c "rm y" && touch x', asserting the citation is 'Bash(touch *)' — is red with the double normalization and green once the recursion stops re-normalizing; please confirm that red.

中文说明

该递归重新进入公有方法,因此 normalizePermissionContext 会在每一段上再跑一次;而 evaluate() 的复合路径是规范化一次之后按原样匹配各段的。对 monitor 工具而言,若某一段本身就是 shell 包装器,它会被第二次剥壳,于是引用可能指向一条并未做出该拒绝决定的规则。

设 deny 为 ['Bash(rm *)','Bash(touch *)'],monitor 调用为 bash -lc 'bash -c "rm y" && touch x'normalizePermissionContext 只剥一次外层包装,evaluate() 切分后按原样匹配各段,因此是 Bash(touch *) 决定了拒绝。递归随后以片段 bash -c "rm y" 重新进入,normalizeMonitorCommand 会把它再剥成 rm y,于是 Bash(rm *) 先命中并被引用。用户读到的是 Matching deny rule: "Bash(rm *)",而实际决定拒绝的是 Bash(touch *);他删掉那条规则、重跑完全相同的命令,又会被那条从未被点名的规则拒绝。同样的形状若走 run_shell_command(不做规范化),则会正确引用 Bash(touch *)。可触发面较窄——需要 toolName === 'monitor' 且复合命令中嵌套包装器——但它产生的是「引用了错误的规则」,而不仅仅是「缺少引用」,且相对基线是新出现的。

证据(在本 commit 上执行):

normalizeMonitorCommand("bash -lc 'foo && bash -c \"rm y\"'").safetyCommand = "foo && bash -c \"rm y\""
normalizeMonitorCommand("foo && bash -c \"rm y\"").safetyCommand           = "foo && bash -c \"rm y\""  -> 对非包装器幂等
normalizeMonitorCommand("bash -c \"rm y\"").safetyCommand                 = "rm y"                   -> 片段被再次剥壳
normalizeMonitorCommand("rm y").safetyCommand                              = "rm y"                   -> 幂等

经 evaluatePermissionFlow 端到端,monitor,deny ['Bash(rm *)','Bash(touch *)'],
  command `bash -lc 'bash -c "rm y" && touch x'`:
PR:   FLOW=deny msg="This \"monitor\" invocation was denied by permission rules. Matching deny rule: \"Bash(rm *)\". Other uses of this tool are still permitted."
BASE: FLOW=deny msg="Tool \"monitor\" is denied by permission rules."(未引用规则)
同一形状走 run_shell_command(不做规范化):PR cited="Bash(touch *)" <- 决定性规则

建议只规范化一次:把三个 pass 抽成私有的 findMatchingDenyRuleNormalized(ctx)(不调用 normalizePermissionContext),公有方法负责规范化并委托,递归则以 { ...ctx, command: subCmd } 调用私有形式。这样能同时关闭此处的错误引用以及两个同类方法中完全相同的分歧。

hasRelevantRules(permission-manager.ts:1112-1118)与 hasMatchingAskRule(:1207-1213)也是通过各自的公有入口递归,携带完全相同的双重规范化,因此只修 findMatchingDenyRule 会让相关性检查与引用在 monitor 命令上继续不一致——三者要么同样处理,要么明确接受这一分歧。evaluate() 自己的复合路径(evaluateCompoundCommand -> evaluateSingle,permission-manager.ts:572-576)是按原样匹配片段的,这正是引用必须镜像的行为。

describe('PermissionManager.findMatchingDenyRule') 区块中补一个用例——toolName 为 'monitor'、deny 为 ['Bash(rm *)','Bash(touch *)']、command 为 bash -lc 'bash -c "rm y" && touch x',断言引用是 'Bash(touch *)'——在双重规范化下是红的,在递归不再重复规范化后是绿的;请确认该红。

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

Bring the branch up to date with main to pick up the lint-gate freshness
check and the App.test.tsx split-session mock fix.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmttqhfdot6

@qwen-code-ci-bot qwen-code-ci-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.

8 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-1 packages/core/src/core/permissionFlow.ts:148 catch-all spelling classifier — already reported (comment 3964391128), still stands, re-confirmed this round by an executed sweep
  • R1-3 packages/core/src/core/permissionFlow.ts:147 domain catch-all comment/test — already reported (comment 3964391132), still stands, re-confirmed this round
  • R1-5 packages/core/src/permissions/permission-manager.ts:959 monitor double normalization — already reported (comment 3964391163), still stands, re-confirmed this round with a stronger discriminator
  • R1-6 packages/core/src/permissions/permission-manager.ts:929 duplicate traversal cost — already reported (comment 3964391157), still stands, re-measured this round
  • R1-7 packages/core/src/permissions/permission-manager.test.ts:3851 compound recursion test strength — already reported (comment 3964391145), still stands on byte-identical code
  • R1-9 packages/core/src/core/permissionFlow.test.ts:121 unpinned no-specifier branch — already reported (comment 3964391122), still stands, mutation re-run this round
  • R1-10 packages/core/src/permissions/permission-manager.ts:930 unpinned cwd resolution — already reported (comment 3964391153), still stands on byte-identical code
  • R1-12 packages/core/src/core/permissionFlow.ts:117 cited-rule-vs-invoked-tool gate — already reported (comment 3964391116), still stands, both directions measured this round

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

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

中文说明

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

本轮确认的 8 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

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

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

* `Bash(npm view *)` is scoped and returns false.
*/
function isToolWideDenyRule(raw: string): boolean {
const rule = parseRule(raw);

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] R2-2: isToolWideDenyRule assumes the string it is handed is a permission rule, but two PermissionManager implementations in this repo return prose from findMatchingDenyRule instead — ManagedAutoMemory(...) (packages/core/src/memory/memory-scoped-agent-config.ts:432-437) and ManagedSkillReview(...) (skillReviewAgentPlanner.ts:244-247). parseRule reads such a string as a literal rule whose prose colon becomes a key:value param matcher, so specifier is undefined, toolParamMatchers is non-empty, and the classifier answers "scoped" for every one of them.

That is only wrong when a base rule blocks the tool outright, and then it is wrong in the direction this diff exists to remove. With permissions.deny: ["Bash"] — a documented knob, not an exotic one — the auto-memory extractor runs as a forked agent whose getPermissionManager() is the scoped shim and whose own policy allows a read-only ls. The base rule denies, getScopedDenyRule returns its label unconditionally and ahead of basePm, and the agent is told other uses of the shell are still permitted while every one of them is denied. It retries until its budget (maxTurns: 5, maxTimeMinutes: 2) is spent and the extraction fails. The Edit/Write family and the skill-review shim reproduce it. The comment this diff adds at permissionFlow.ts:111-115 says tool-wide blocks "must NOT" get this reassurance; in these rows the tool is blocked entirely and the reassurance is appended anyway.

Witness:

A/B, same input, both trees (BASE = merge base 1919ff97f5; arm proven - the
reassurance string and isToolWideDenyRule are both absent from the BASE dist)

BASE deny[Bash] mem-scoped 'ls'
  [deny] Tool "run_shell_command" is denied by permission rules.
         Matching deny rule: "ManagedAutoMemory(run_shell_command: read-only only)".
PR   deny[Bash] mem-scoped 'ls'
  [deny] This "run_shell_command" invocation was denied by permission rules.
         Matching deny rule: "ManagedAutoMemory(run_shell_command: read-only only)".
         Other uses of this tool are still permitted.

PR deny[Bash] otherShellUses = { ls: deny, echo hi: deny, git status: deny,
                                 cat /etc/hosts: deny, pwd: deny }   <- 5/5 denied
PR deny[Edit] otherWriteUses = { a.md: deny, b.md: deny, c.ts: deny }  <- 3/3 denied
scoped-only decision for 'ls' with NO base PM = allow   <- the deny is the base rule's alone
22/22 real shim labels draw the reassurance
finalPermission = [deny] on BOTH arms for all 3 rows   <- enforcement unchanged

not reachable (second entrance closed): allowShell:false ->
  isToolEnabled(shell) = false | registration = 'disabled',
  so coreToolScheduler.ts:2546-2588 intercepts with a template that appends nothing

A fix must not require the cited rule's tool to equal the invoked tool: permission-manager.ts:931-947 deliberately cites a Read(...) rule for a run_shell_command call, and the new test at permission-manager.test.ts:3869-3878 pins that. The deciding base rule is also not recoverable from the returned string, because the shim returns its own label before consulting basePm. Note that the manager-side query proposed on the open R1-12 thread does not close this entrance either — that fix is explicitly optional-called because the shim does not implement listRules(), so it is skipped here.

The narrowest guard is to classify a citation as scoped only when it really is a rule for a known tool: after parseRule, return true when rule.invalid, or when rule.toolName is neither in TOOL_NAME_ALIASES nor mcp__-prefixed. Measured for discrimination against the real parseRule and TOOL_NAME_ALIASES: prose rejected 9/9, real rules accepted 10/10 (Bash(npm view *), Read(//**/node_modules/**), Bash(*), Bash, Bash(), Read(//**), WebFetch(*), Agent(model:opus), Bash(**), mcp__server__tool). The wider version is to have findMatchingDenyRule return the already-parsed PermissionRule, so no citation string is ever re-parsed as prose. A permissionFlow.test.ts case mocking findMatchingDenyRule to return 'ManagedAutoMemory(run_shell_command: disabled)' and asserting the message does not contain Other uses of this tool are still permitted is red today — that exact label produces the sentence — and green once the guard lands; please confirm that red.

中文说明

isToolWideDenyRule 假定传入的字符串是一条权限规则,但仓库中有两个 PermissionManager 实现从 findMatchingDenyRule 返回的是散文而非规则——ManagedAutoMemory(...)packages/core/src/memory/memory-scoped-agent-config.ts:432-437)与 ManagedSkillReview(...)skillReviewAgentPlanner.ts:244-247)。parseRule 会把这类字符串读成 literal 规则,其中的散文冒号被当成 key:value 参数匹配器,于是 specifier 为 undefined、toolParamMatchers 非空,分类函数对它们一律回答「范围级」。

只有当基础规则把整个工具封死时这才成为问题,而一旦如此,它错的方向正是本 diff 要消除的那个。设 permissions.deny: ["Bash"](这是有文档的常规开关,并不冷门):自动记忆抽取器以 forked agent 运行,其 getPermissionManager() 是作用域 shim,而它自身的策略对只读的 ls允许的。基础规则判拒,getScopedDenyRule 无条件地、且在 basePm 之前返回自己的标签,于是 agent 被告知「本工具的其他用法仍被允许」,而实际上每一种用法都被拒。它会不断重试直到预算耗尽(maxTurns: 5maxTimeMinutes: 2),抽取任务失败。Edit/Write 系列与 skill-review shim 可复现同一问题。本 diff 在 permissionFlow.ts:111-115 新增的注释明确写着工具级封禁「绝不能」得到这句安抚;而在这些实测行里,工具已被完全封禁,安抚句却照样被追加。

修复不应要求「被引用规则的工具必须等于被调用的工具」:permission-manager.ts:931-947 是刻意为 run_shell_command 调用引用 Read(...) 规则的,且 permission-manager.test.ts:3869-3878 的新测试固定了该行为。决定性基础规则也无法从返回的字符串中恢复,因为 shim 在查询 basePm 之前就返回了自己的标签。另请注意:R1-12 那条未关闭评论中建议的 manager 侧查询同样无法关闭此入口——那个修复被明确写成可选调用,理由是 shim 没有实现 listRules(),因此在这里会被跳过。

最窄的守卫是:只有当引用确实是某个已知工具的规则时才判为范围级——在 parseRule 之后,当 rule.invalid 为真、或 rule.toolName 既不在 TOOL_NAME_ALIASES 中也不带 mcp__ 前缀时返回 true。用真实 parseRuleTOOL_NAME_ALIASES 实测其区分度:散文 9/9 被拒,真实规则 10/10 被接受(Bash(npm view *)Read(//**/node_modules/**)Bash(*)BashBash()Read(//**)WebFetch(*)Agent(model:opus)Bash(**)mcp__server__tool)。更彻底的做法是让 findMatchingDenyRule 返回已解析的 PermissionRule,这样任何引用字符串都不会被再次当作散文解析。在 permissionFlow.test.ts 中补一个用例,把 findMatchingDenyRule mock 成返回 'ManagedAutoMemory(run_shell_command: disabled)',断言消息包含 Other uses of this tool are still permitted:该断言在当前代码下是红的(这个标签确实会产生该句),加上守卫后变绿——请确认这一红一绿。

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

const cwdForOps = pathCtx?.cwd ?? process.cwd();
const ops = extractShellOperationsAcrossCommand(command, cwdForOps);
for (const op of ops) {
const opMatchArgs = [

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] R2-1: This new virtual-op citation pass is documented as covering Read/Edit/Write/WebFetch rules, but the only test that exercises it uses a single category — a Read op extracted from cat (permission-manager.test.ts:3866-3881). The domain and write/edit legs of opMatchArgs have no coverage, and nothing pins this pass's precedence against the direct Bash match it now runs ahead of.

The gap is not hypothetical: blanking one field of the tuple leaves the whole suite green while the citation silently disappears. A curl https://evil.com denial under deny: ['WebFetch(domain:evil.com)'] would then cite no rule, and permissionFlow.ts:108-110 degrades to a bare This "run_shell_command" invocation was denied by permission rules. with no Matching deny rule: clause — the unexplained denial #11405 is about, on exactly the network case this pass was added to cover.

Witness:

mutants run in an isolated tree at this commit; suite = both changed test files

INTACT  curl ops = [{"virtualTool":"web_fetch","domain":"evil.com"}]
        domain deny decision = deny | cited = "WebFetch(domain:evil.com)"
MUTANT  op.domain -> undefined in opMatchArgs
        Tests 453 passed (453)                        <- suite stays GREEN
        domain deny decision = deny | cited = undefined   <- citation gone

INTACT  write ops = [{"virtualTool":"write_file","filePath":"/etc/motd"}]
        write deny decision = deny | cited = "Write(//etc/**)"
MUTANT  op.filePath -> undefined for write_file/edit/notebook_edit
        Tests 453 passed (453)                        <- suite stays GREEN
        write deny decision = deny | cited = undefined

precedence, unpinned either way: deny ['Bash(curl *)','WebFetch(domain:evil.com)']
  intact -> cites "WebFetch(domain:evil.com)" regardless of rule order
  domain mutant -> falls through and cites "Bash(curl *)"
coverage read off describe('PermissionManager.findMatchingDenyRule') (3761-3881):
  Bash command rule, no-match, session rule, non-denied tool, bare-tool rule,
  symlinked path (direct toolName:'edit', NOT this pass), compound segment,
  one Read-via-cat virtual op. No domain case, no write/edit virtual-op case.

A citation test must use rule forms that match under exactly the fields evaluateShellVirtualOps supplies — evaluateSingle({ toolName: op.virtualTool, cwd, filePath, domain }) with no specifier, toolParams or command (permission-manager.ts:498-503) — or it will pin a citation evaluate() itself would never deny on.

One case per op category would close this: a domain case (deny: ['WebFetch(domain:evil.com)'] + curl https://evil.com/x → that raw string) and a write/edit case, plus one precedence case holding both a Bash deny rule and a matching virtual-op deny rule and asserting which raw string is cited. Blanking op.domain must turn the domain case red, and moving the virtual-op pass below the single-context match must turn the precedence case red — please confirm both.

中文说明

这段新增的虚拟操作引用 pass 在注释中声称覆盖 Read/Edit/Write/WebFetch 规则,但唯一真正执行到它的测试只用了一个类别——从 cat 提取出的 Read 操作(permission-manager.test.ts:3866-3881)。opMatchArgs 的 domain 一侧与 write/edit 一侧都没有覆盖,也没有任何测试固定该 pass 相对于它现在抢在前面的 Bash 直接匹配的顺序。

这个缺口不是假设性的:把该元组中的某一个字段置空,整个测试套件仍然是绿的,而引用会悄无声息地消失。此时在 deny: ['WebFetch(domain:evil.com)'] 下拒绝 curl https://evil.com,将不会引用任何规则,permissionFlow.ts:108-110 会退化成一句没有 Matching deny rule: 子句的 This "run_shell_command" invocation was denied by permission rules.——正是 #11405 所说的那种「无从解释的拒绝」,而且恰好发生在该 pass 被加入以覆盖的网络场景上。

引用测试必须使用在 evaluateShellVirtualOps 实际提供的字段下能够命中的规则形态——即 evaluateSingle({ toolName: op.virtualTool, cwd, filePath, domain }),不带 specifier、toolParams 与 command(permission-manager.ts:498-503)——否则固定的会是一条 evaluate() 自己根本不会据此判拒的引用。

每个操作类别补一个用例即可收口:一个 domain 用例(deny: ['WebFetch(domain:evil.com)'] + curl https://evil.com/x → 该原始串)、一个 write/edit 用例,再加一个同时持有 Bash 拒绝规则与命中的虚拟操作拒绝规则、并断言最终引用哪一条原始串的顺序用例。把 op.domain 置空必须使 domain 用例变红;把虚拟操作 pass 移到单一上下文匹配之后必须使顺序用例变红——请确认这两处红。

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

result.denyMessage = `Tool "${toolName}" is denied by permission rules.${ruleInfo}`;
// A specifier-scoped deny (e.g. `Bash(npm view *)`) blocks only this
// invocation, not the whole tool. Say so explicitly so the model does
// not abandon the tool entirely (issue #11405). Tool-wide catch-alls

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] R2-3: This PR closes #11405 with Fixes #11405, but the second half of what that issue asks for has no tracker, and merging will remove the only one it has.

#11405 asks for two things: "The error message must be more informative and include pattern, event better if we can define custom message to tell why exactly this tool is prohibited." The diff delivers the first. The issue's own triage ruled on the second — "Supporting this needs a schema change to allow an object form for rules. Recommend splitting it into a separate type/feature-request issue so the message-accuracy bug above can land independently" — and this PR's Risk & Scope says it "should be tracked separately". Your comment on the issue offers to do that ("happy to track it separately if you'd like"), but it is the last comment in that thread and the reporter never replied, so the split was never made. On merge GitHub auto-closes #11405 and the per-rule-message half of the reporter's expectation is left with nowhere to go. Nothing downstream will notice, because this PR's Stage-1 triage already recorded the deferral as satisfied ("Deferring the feature request to its own issue matches what the linked issue's triage recommended").

Witness:

absence re-verified independently, including three falsification tests
the finder did not run:

gh search issues --repo QwenLM/qwen-code "11405"   -> 1 result (the issue itself)
gh issue list --state all --search "created:>=2026-09-08"
  -> 56 issues; matching permission|deny|denied|message|rule:
     11442 (web-shell hover timestamp), 11405, 11345 (dws group/DM sources)
     -> no feature request
keyword searches "custom deny message" / "per-rule message" /
  "custom message deny rule" / "deny rule message"        -> only #11405
author's issues since 2026-09-01 (21 listed)               -> none on this topic
#11405 timeline cross-references                           -> #11411 (this PR), #585
nearest candidate #2819 "Permission Denial Tracking with Contextual Fallback"
  -> asks for denial-COUNT tracking, not a per-rule message: not the tracker
last comment on #11405: author's own, 2026-09-08T22:28:57Z, no reply

The deferred half is a settings-schema change and should not be pulled into this message-accuracy PR: packages/cli/src/config/settingsSchema.ts declares deny as a plain string array, and interface PermissionRule in packages/core/src/permissions/types.ts has no message/reason/description field. So the ask here is only tracking, not code — file the type/feature-request issue and reference its number in Risk & Scope and in a closing comment on #11405, or say explicitly in that closing comment that the component is being dropped rather than deferred. Either closes the gap; this comment is anchored here only because this is the line that names the issue.

中文说明

本 PR 以 Fixes #11405 关闭该 issue,但那个 issue 诉求的后半部分没有任何跟踪项,而合并会把它仅有的那一个也带走。

#11405 提出两件事:"The error message must be more informative and include pattern, event better if we can define custom message to tell why exactly this tool is prohibited." 本 diff 完成了第一件。该 issue 自己的 triage 对第二件给出了裁定——"Supporting this needs a schema change to allow an object form for rules. Recommend splitting it into a separate type/feature-request issue so the message-accuracy bug above can land independently"——而本 PR 的 Risk & Scope 也写着它 "should be tracked separately"。你在该 issue 下的评论表示愿意拆分("happy to track it separately if you'd like"),但那是该讨论串的最后一条评论,报告者始终没有回复,因此拆分从未发生。合并后 GitHub 会自动关闭 #11405,报告者诉求中「per-rule 自定义消息」这一半就无处可去了。下游也不会有人察觉,因为本 PR 的 Stage-1 triage 已经把这次延后记录为已妥善处理("Deferring the feature request to its own issue matches what the linked issue's triage recommended")。

被延后的那一半属于 settings schema 变更,不应被拉进这个只修消息准确性的 PR:packages/cli/src/config/settingsSchema.tsdeny 声明为普通字符串数组,packages/core/src/permissions/types.ts 中的 interface PermissionRule 也没有 message/reason/description 字段。所以这里要求的只是跟踪、而非代码——建一个 type/feature-request issue,把它的编号写进 Risk & Scope 以及 #11405 的收尾评论;或者在那条收尾评论中明确说明该部分是被放弃而不是被延后。两种做法都能补上这个缺口;本条之所以锚定在这里,只是因为这一行提到了那个 issue。

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

@qqqys

qqqys commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Independent verification — end-to-end deny-message probe with no mocks, plus a 5-arm mutation witness

Reviewed at head f69d917ccacd9b9ed45a3fdb859d19e210c10506 (state=open, merged=false), base 1919ff97f5fb. 4 files, +170/−5:

  • production 2/2packages/core/src/core/permissionFlow.ts +35/−1, packages/core/src/permissions/permission-manager.ts +47/−4
  • tests — permissionFlow.test.ts +56, permission-manager.test.ts +32

5 commits = 3 author commits (28b32bfb9a7a, 0cf2bc8e7d45, 475877f272d8) + 2 Merge …main commits (9b5c76bab9d9, f69d917ccacd, both parents=2). Zero author commits after the approval cited at the end, so the coverage below binds against what the approver saw.

The gap this closes

The two halves of this change are tested separately but never together. permissionFlow.test.ts mocks the citation (findMatchingDenyRule: vi.fn().mockReturnValue('Bash(npm view *)')), so it exercises only isToolWideDenyRule over hardcoded strings; permission-manager.test.ts exercises findMatchingDenyRule in isolation. Nothing in-tree asserts the denyMessage a real PermissionManager actually produces end to end — which is the whole subject of #11405.

So I built that: an 11-cell probe wiring the real PermissionManager to the real evaluatePermissionFlow, with no mocks anywhere on the path under test (parseRule, matchesRule, splitCompoundCommand, extractShellOperationsAcrossCommand, buildPermissionCheckContext all real; only the Config/invocation shells are stubs, and this PR does not touch them). Run in a worktree at this head with packages/core built; baseline 11/11 pass, and the PR's own two suites 453/453 pass.

cell deny rule tool / command cited rule reassurance
C1 Bash(npm view *) shell / npm view foo Bash(npm view *) yes
C2 Bash(npm view *) shell / cd /tmp && npm view foo Bash(npm view *) yes
C3 Read(//**/node_modules/**) shell / cat /app/node_modules/lodash/index.js Read(//**/node_modules/**) yes
C4 Bash(*) shell / echo hello Bash(*) no
C5 Read(//**) shell / cat /etc/passwd Read(//**) no
M1 Read(/secret/**) read_file / /project/secret/a.txt Read(/secret/**) yes
M2 Bash(git *) shell / git push origin x Bash(git *) yes
M3 Bash (bare) shell / echo hi Bash no
M4 allow Bash(git *) shell / git status not denied (allow) n/a

C2 and C3 are the two headline claims (compound segment, and virtual file op reached through a shell command); both produce the correct citation and the correct reassurance. Measured message for C2, verbatim:

This "run_shell_command" invocation was denied by permission rules. Matching deny rule: "Bash(npm view *)". Other uses of this tool are still permitted.

Mutation witness — is any of the added code load-bearing?

A green probe proves nothing on its own, so each added construct was disabled and the specific cell that should break was checked. Every arm's mutation was verified by re-reading the file after writing it (line index + a count of surviving guards), because a mutation that silently fails to apply produces a green run that reads as "this code does nothing" — the dangerous direction.

arm mutation my probe the PR's own tests
0 pristine 11 passed 453 passed
1 permissionFlow.ts — drop !isToolWideDenyRule(matchingRule), reassurance unconditional 3 failed (C4, C5, M3) 1 faileddoes not reassure for tool-wide catch-all deny rules (#11405)
2 permission-manager.ts:929 — virtual-op pass disabled 2 failed (C3, C5) 1 failedcites the deny rule when a shell command is denied via a virtual file op
3 permission-manager.ts:955 — compound pass disabled 1 failed (C2) 1 failedcites the deny rule for a compound command segment
4 both passes disabled (= pre-PR citation behaviour) 3 failed (C2, C3, C5) 2 failed (both of the above)

So every production line this PR adds is load-bearing, and each is witnessed twice — once by an independent probe and once by the test the PR itself added. Both mutated files were restored byte-identical (sha256 permissionFlow.ts ba8508c76d51…, permission-manager.ts 935322f4af64…, re-asserted after every arm).

The unchanged producers, checked rather than assumed

The new code reads values produced by files this PR does not touch, so those were read too:

  • Argument alignment. matchesRule is (rule, toolName, command?, filePath?, domain?, pathContext?, specifier?, toolParams?, toolAliases?, pathMatchMode = 'lexical') (rule-parser.ts:1500). The new virtual-op call spreads [op.virtualTool, undefined, op.filePath, op.domain, pathCtx, undefined] then undefined, undefined, 'canonical'exactly 10 positional arguments, each landing on the parameter it means, with the same 'canonical' path mode evaluateSingle uses for deny rules (permission-manager.ts:382445). A positional slip here would be invisible to a mocked test.
  • The "mirrors evaluate()" claim is true. evaluate() runs the compound-aware extractor on the full command first and short-circuits on deny (:300330), then splits; the new pass runs in the same order, over the same denyRules array, with the same pathCtx. evaluateShellVirtualOps (:486) evaluates each op via evaluateSingle, which rebuilds an identical pathCtx — so the citation and the decision are computed from the same operands.
  • Recursion terminates. The compound pass recurses only when subCommands.length > 1, and a segment is a strictly shorter substring of its input, so depth is bounded by command length. splitCompoundCommand (rule-parser.ts:946) returns [command] when the segmenter yields nothing, which cannot re-enter.
  • The path check is right, and for a non-obvious reason. resolvePathPattern (rule-parser.ts:1209) makes //x filesystem-absolute but /x project-root-relative. So Read(//**) is the only genuine filesystem-wide catch-all and Read(/**) is not — measured: Read(/**) denies /project/anything.txt and correctly still gets the reassurance, and Read(//etc/**) likewise. Keying the path branch on '//**' alone is correct, not an omission.
  • No caller outside the deny path can be affected. Both findMatchingDenyRule call sites in coreToolScheduler.ts (:305, :2553) pass { toolName } with no command, so neither new pass can fire there — the tool-registry and pre-validation messages are unchanged. Of the two evaluatePermissionFlow call sites, :2846 consumes denyMessage and :6528 does not destructure it at all.
  • The message does reach the model. :2863 destructures denyMessage; :2940:2950 turns a deny into createErrorResponse(reqInfo, new Error(denyMessage ?? …), ToolErrorType.EXECUTION_DENIED, 'not_started'). ACP mode does the same at Session.ts:12235. Read directly, not inferred.

CI at head

74 check-runs, all completed: 15 success / 59 skipped / 0 non-green, 0 pending (complete census, every non-success lane enumerated by name). Test (ubuntu-latest, Node 22.x) is success, and packages/core's test/test:ci scripts are a bare vitest run with no explicit file list — so that lane did collect and execute both changed test files at this head, on a checkout verified to contain it.

Non-blocking observations — both already filed by the automated review; recorded here only to bound them

Neither of these is a Critical and neither should hold the merge. I am not claiming them as new findings: qwen-code-ci-bot's round-1/round-2 passes filed 8 Suggestion-level findings and zero Criticals, and R1-1 (permissionFlow.ts:148, "catch-all spelling classifier") is already inline as comment 3964391128. What I add is the two negative measurements that bound how far that classifier should be widened.

isToolWideDenyRule recognises * but not ** for command specifiers, and I measured that the two are equivalent:

spelling end-to-end decision cited reassurance shown does it actually block everything?
Bash(*) (recognised) deny Bash(*) no yesmatchesCommandPattern('*', cmd) matched 11/11 commands
Bash(**) (not recognised) deny Bash(**) yes yesmatchesCommandPattern('**', cmd) matched the same 11/11, the same verdicts as the * control

The 11-command population was echo hi, npm view foo, a git push, ls, rm -rf /tmp/x, cd /a && b, cat /etc/passwd, python3 -c …, true, X=1 make build, curl https://example.com; both spellings matched all of them, so Bash(**) denies every shell invocation while the message tells the model other uses are still permitted. The failure mode is the pre-PR message, not a regression: before this change no rule received the reassurance at all.

Two path spellings I expected to be misses and measured to be correct — recorded so a widening of the classifier does not "fix" them into a regression:

spelling measured verdict
Read(//**/*) resolves to /**/*, which matched 4 of 6 probe paths — it misses root-level single-segment paths (/foo, /x) not a catch-all, so reassuring is correct
Read(/**) resolves to /project/** (resolvePathPattern, rule-parser.ts:1209), matched only paths under the project root and not /etc/passwd project-scoped, so reassuring is correct

Both measured with matchesPathPattern(spec, path, '/project', '/project', 'canonical') against the code built at this head. Of the four spellings probed, only //** matched all 6 paths — exactly the one the guard recognises. So the correct widening is over command/domain specifiers, not path ones.

Also already filed and not repeated here: R1-5 (permission-manager.ts:959, monitor double normalization) — the new compound pass recurses into findMatchingDenyRule, which re-runs normalizePermissionContext. I confirmed that function is a no-op unless toolName === 'monitor', so the shell citation path this PR fixes is unaffected either way.

Two further probes of mine came back void and are disclosed so nobody reads them as evidence in either direction: WebFetch(*) and Bash(:*) both yielded ask, not deny, in this configuration — on the shell path and, for WebFetch(*), on a direct web_fetch invocation. Whether a WebFetch(*) deny rule should deny is pre-existing behaviour this PR does not touch (it changes only the citation and the message text).

Instrument disclosure — no tmux run, deliberately

A denied tool call is only reachable from a model-issued call, so a tmux session would add a live-model dependency in order to sample one rule spelling, and its observation (a string in the transcript) is exactly what the probe above already asserts against the real production functions. What tmux would add that the probe does not is the rendering step; that hop is two lines of direct code (:2947, Session.ts:12235) and I read it rather than assumed it. Naming the trade-off rather than leaving it implicit.

Conclusion: no Critical found. Both headline claims are confirmed end to end against the real permission stack, every added line is load-bearing under mutation, the unchanged producers line up, and CI is green at head — mergeable.

This comment carries no approval. As of the state read immediately before posting it, our approve precondition (a previously posted report from us concluding the PR is mergeable) was not met, because this is that first report.

@qqqys qqqys 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.

Approving on the strength of the independent verification report posted at this same head (comment 5605213719), which recorded that its approve precondition was unmet in its own round precisely because it was the first report. This is that cash-in. It carries no new findings — nothing below changes the report's conclusion; it re-asserts the state the approval depends on, as of the state read immediately before posting.

Re-asserted at the minute of this write (head f69d917ccacd9b9ed45a3fdb859d19e210c10506, unmoved since the report):

  • Lifecycle first: state=open, merged=false. Checked before the head-match test, because a head-match test passes on a merged PR and so cannot discharge an owed write.
  • Review state at head: qwen-code-ci-bot APPROVED (review 5148988602) is that author's latest verdict-state row at head. Its commit_id == head is a GitHub re-anchor, not proof the approver saw the final tree: submitted_at is 02:06:07Z while the head commit's committer date is 07:21:07Z, so the row was moved onto a commit that did not exist when the review was filed. The later at-head row 5152816887 (10:09:41Z) is COMMENTED, which GitHub excludes from reviewDecision and which therefore does not supersede an approval — read rather than assumed: its embedded review ledger records {"round":2,"findings":[{"id":"R2-1","sev":"S",…},{"id":"R2-2","sev":"S",…},{"id":"R2-3","sev":"S",…}],"posted":3,"fresh":3,"prevPosted":8}, i.e. every finding Suggestion-level and zero Criticals, alongside 8 previously-reported Suggestions re-confirmed as still standing and deliberately not re-posted. No CHANGES_REQUESTED or DISMISSED row from any author at head.
  • What makes the re-anchor benign is parentage, not the head match: 5 commits = 3 author commits (28b32bfb9a7a 22:27:47Z, 0cf2bc8e7d45 00:12:12Z, 475877f272d8 00:53:13Z) + 2 Merge …main commits, and the head f69d917ccacd has parents 475877f272d8 (the last author commit) and 1919ff97f5fb (a main commit). Zero author commits after the approval, so the merge moved the head without moving the author's contribution. Every lane below ran against refs/pull/11411/head, i.e. the post-merge tree, so the main/PR interaction is what the green lanes actually covered.
  • CI at head, complete census: total_count = 74, 74 received, 74 unique run ids, zero non-green and zero incomplete. Product lanes green (7): Test (ubuntu-latest, Node 22.x), Lint & Static (ubuntu-latest, Node 22.x), Integration Tests (no-AK, No Sandbox), Desktop Shell (ubuntu-22.04), Desktop Shell (windows-2022), web-shell E2E Smoke (ubuntu-latest, Node 22.x), precheck-pr / precheck.
  • Product lanes skipped, named as skipped rather than folded into the rollup (3): Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x), Integration Tests (CLI, No Sandbox). Read out of .github/workflows/ci.yml at this head (byte-identical to main, sha256 e64341f263192477…): the two Test jobs' if: blocks (:1558, :1655) admit only merge_group / schedule / workflow_dispatch, and integration_cli's if: (:2056) admits only merge_group. This is worth stating precisely because this PR comes from a fork (yiliang114/qwen-code), which invites the wrong explanation: the skips are event-type structural, not secret-availability, and would be identical on a same-repo branch. They cannot report on any pull_request, so their absence is not a coverage gap this PR caused or could close. The rollup reads "zero non-green"; the true green product-lane count is seven.
  • reviewDecision reads REVIEW_REQUIRED / mergeStateStatus BLOCKED, which is the bot-approval-does-not-satisfy-branch-protection class, not a retraction: the APPROVED row is still state APPROVED at head, not DISMISSED.
  • Our own three write surfaces at this head immediately before this write: 0 review rows, 1 issue comment (the report), 0 inline comments.

Why the report concluded mergeable (summary; the executed evidence is in the report):

  • Both headline claims were confirmed end to end against the real permission stack, and every production line the PR adds was shown load-bearing twice — once by an independent no-mock probe and once by a test the PR itself added — under four mutation arms (guard dropped; permission-manager.ts:929 disabled; :955 disabled; both disabled).
  • The one behaviour that looked like a defect on paper was measured rather than inferred, and the measurement split: matchesCommandPattern('**', cmd) matched 11/11 probe commands with verdicts identical to the recognised '*' control, so the Bash(**) half is real — but it is already on this PR as ci-bot's Suggestion-level R1-1 (inline 3964391128), so the report bounds it with measurements instead of filing it twice. The Read(//**/*) half is not a catch-all (4 of 6 probe paths; resolvePathPattern makes /x project-root-relative and only //x filesystem-absolute), so reassuring for it is correct — reporting that half would have been a false defect against correct code.
  • Neither observation is a Critical, and the report says so explicitly: no Critical found.

wenshao added a commit to wenshao/qwen-code that referenced this pull request Sep 9, 2026
@wenshao

wenshao commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Local runtime validation — recommend merge ✅

I rebuilt this PR locally and verified it against a real CLI, not just unit tests: two full dist/cli.js bundles from one worktree (f69d917cca vs the same tree with only the two production hunks reverted, i.e. merge-base 1919ff97f5), driven by a mock OpenAI server so the exact string the model receives is observable on the wire.

Everything the PR claims reproduces. The decision logic is provably unchanged — only the citation changes. Two nits and one PR-description correction below; none of them block merge.


1. The issue reproduces and the fix lands — real interactive TUI

settings.jsonpermissions.deny: ["Bash(npm view *)", "Read(//**/node_modules/**)"], isolated HOME, real pty via tmux, same two commands in both arms.

BEFORE (main @ 1919ff97f5) — exactly the message from #11405, no rule cited, reads as "the tool is gone":

AFTER (PR head) — rule cited on both, including the cat …/node_modules/… case that is denied through a Read(...) rule:

2. What the model actually receives (wire capture)

The TUI rendering is not the thing that changes model behaviour — the tool-result string sent back to the provider is. Captured from the mock's last request body of a headless run (-p … --approval-mode yolo):

Two things worth calling out from that run:

  • Call [3] (npm view bar, a plain non-compound match) already carried the rule before the PR — so the bug really is confined to the compound / virtual-op shapes, as the description says.
  • Call [4] (echo hello-from-allowed-command) executes, exit 0, in the same session as three denials. So "other uses of this tool are still permitted" is a factually true statement here, not just reassuring wording. It also confirms a scoped deny is not bypassed by --approval-mode yolo.

3. Citation parity — is findMatchingDenyRule now a superset of what evaluate() denies?

This is the real invariant the PR is restoring, so I swept it rather than spot-checking: 13 deny rules × 29 shell commands = 377 pairs, driven through the real PermissionManager (tsx over packages/core/src), asserting evaluate(ctx) === 'deny' ⇒ findMatchingDenyRule(ctx) !== undefined.

arm pairs denials citation gaps
BEFORE 1919ff97f5 377 91 26 (28.6 % of denials lost the rule)
AFTER f69d917cca 377 91 0

The denial count is identical in both arms (91 = 91) — across the whole sweep the PR never flips a decision, it only recovers the citation. A separate 23-case hand-built matrix (incl. read_file/web_fetch direct invocations, monitor, nested bash -lc, cd-tracked writes) gives the same result: 20 denials both arms, 9 gaps → 0.

Other checks on that path:

  • findMatchingDenyRule({ toolName }) with no command — the shape used by the two other call sites (coreToolScheduler.ts:305 and :2553) — is byte-identical in both arms; both new passes are behind command !== undefined.
  • Citation order mirrors evaluate(): when a Bash(...) rule and a Read(...) virtual-op rule both match one command, both functions resolve the virtual-op rule first, so the cited rule is the deciding rule, not just a matching one.
  • Cost is negligible and bounded — a 201-segment compound command cites in 2 ms (and the function only runs on the deny path).

4. The ACP / daemon path is covered too (the PR body says it isn't)

Driven over real qwen --acp stdio with a ClientSideConnection client; observable is the session/updatetool_call_update frame:

Session.ts:12208 calls the same evaluatePermissionFlow() and destructures the same denyMessage, so the ACP/daemon surface gets the fix for free. Worth editing the "Not validated / out of scope" bullet — it currently under-claims. The !-bang pre-check is genuinely separate (shell-utils.ts:2117, Command '<cmd>' is blocked by permission rules), but that surface is a user-typed command and never reaches the model, so leaving it for a follow-up is the right call.

5. Do the new tests have teeth?

Counterfactual + 6 mutants, all run through vitest:

  • Counterfactual (PR's tests vs pre-PR production code): 3 of the 4 new tests fail, each on the assertion it was written for.
  • 6 / 6 mutants killed — disabling either new pass, forcing isToolWideDenyRule() to a constant in either direction, reverting the message prefix, and neutering the '//**' path check each kill exactly one test.

6. Regression sweep

Full packages/core suite on the PR head: 24 030 passed, 3 failed (session-writer-lease, git-branches, skill-curator). All three reproduce identically on the reverted-base arm — they are the usual running-as-root failures (a chmod-based failure injection that root walks straight through), unrelated to this change. No test in the repo pins the old wording, so nothing else needed updating.


Findings (both minor, neither blocking)

1. Bash(**) is tool-wide in effect but still gets the reassurance.
I measured "tool-wide" empirically — does the rule deny every sampled invocation of that tool — and compared it with whether the message reassures:

Bash(**) denies 4/4 sampled commands (matchesCommandPattern treats ** as match-everything) yet isToolWideDenyRule only tests specifier === '*' for the command kind, so the model is told other uses are still permitted when in fact nothing will run. Every other spelling in the table classifies correctly, including the project-root-scoped Read(/**) / Read(**) (correctly not tool-wide — files outside the project root still read). One-line fix if you want it:

// command / domain catch-all
return rule.specifier === '*' || rule.specifier === '**';

2. The WebFetch(*) catch-all handling — and its test case — is unreachable.
matchesDomainPattern() (rule-parser.ts:1378) supports only exact and subdomain matches; it has no wildcard branch. So WebFetch(*) and WebFetch(domain:*) deny nothing at all (0/3 sampled URLs in both arms), which means the specifier === '*' branch can never be reached for a domain rule. The comment above isToolWideDenyRule ('*' is the documented catch-all for command and domain specifiers) isn't true for domains today, and the WebFetch(*) entry in does not reassure for tool-wide catch-all deny rules passes vacuously because the test mocks findMatchingDenyRule. Swapping that entry for bare WebFetch (which does deny 3/3) would make the test assert something real.


中文版报告

本地真实环境验证结论 —— 建议合入 ✅

我在本地把这个 PR 构建成真实可运行的 CLI 做了验证,而不只是跑单测:从同一个 worktree 产出两份完整的 dist/cli.jsf69d917cca vs 只把两个生产文件的 hunk 反向 apply 后的同一棵树,即 merge-base 1919ff97f5),配一台 mock OpenAI 服务器,这样模型真正收到的字符串可以在网络层直接观测。

PR 声称的效果全部复现。判定逻辑可证明未变,变的只是规则引用。下面有两个小问题和一处 PR 描述需要更正,都不阻塞合入。

1. 问题复现 + 修复生效 —— 真实交互式 TUI

settings.jsonpermissions.deny: ["Bash(npm view *)", "Read(//**/node_modules/**)"],隔离 HOME,tmux 提供真实 pty,两个 arm 跑同样两条命令。

BEFOREmain @ 1919ff97f5):正是 #11405 里的那条消息,不带规则,读起来像"这个工具没了"(见上方英文版 fig1)。
AFTER(PR head):两次都带上了规则,包括通过 Read(...) 规则被拒的 cat …/node_modules/…(fig2)。

2. 模型实际收到的内容(网络层抓取)

真正影响模型行为的不是 TUI 渲染,而是回传给 provider 的 tool result 字符串。从 headless 运行(-p … --approval-mode yolo)中 mock 收到的最后一个请求体里抓取(fig3)。这一轮里有两点值得注意:

  • [3] 次调用(npm view bar,普通非复合匹配)在 PR 之前就已经带规则了 —— 所以这个 bug 确实只局限在复合命令 / 虚拟文件操作这两种形态,与描述一致。
  • [4] 次调用(echo hello-from-allowed-command)在同一个会话里成功执行、exit 0。所以"Other uses of this tool are still permitted"在这里是一句事实陈述,不只是安慰话术。同时也验证了:作用域级 deny 不会被 --approval-mode yolo 绕过。

3. 引用一致性 —— findMatchingDenyRule 现在是否覆盖了 evaluate() 所有的 deny?

这是 PR 真正要恢复的不变式,所以我做了全扫而不是抽查:13 条 deny 规则 × 29 条 shell 命令 = 377 组,全部走真实 PermissionManager(tsx 直接跑 packages/core/src),断言 evaluate(ctx) === 'deny' ⇒ findMatchingDenyRule(ctx) !== undefined

arm 组合数 deny 次数 丢失引用
BEFORE 1919ff97f5 377 91 26(28.6% 的 deny 丢了规则)
AFTER f69d917cca 377 91 0

两个 arm 的 deny 次数完全相同(91 = 91) —— 整个扫描里 PR 从未改变任何一次判定,只是把引用找回来了。另一份手工构造的 23 用例矩阵(含 read_file/web_fetch 直接调用、monitor、嵌套 bash -lc、带 cd 追踪的写入)结论一致:两 arm 都是 20 次 deny,gap 从 9 降到 0。

其他检查:

  • 只传 { toolName }(另外两个调用点 coreToolScheduler.ts:305:2553 用的形态)在两个 arm 下行为完全一致 —— 两个新增 pass 都在 command !== undefined 之后。
  • 引用顺序与 evaluate() 一致:当一条 Bash(...) 规则和一条 Read(...) 虚拟操作规则同时命中同一条命令时,两个函数都先解析虚拟操作规则,所以引用到的是真正做出判定的那条规则,而不是随便一条命中的规则。
  • 开销可忽略且有界 —— 201 段的复合命令 2 ms 完成引用(而且该函数只在 deny 路径上执行)。

4. ACP / daemon 路径同样被覆盖(PR 描述说没有)

用真实 qwen --acp stdio + ClientSideConnection 客户端驱动,观测点是 session/updatetool_call_update 帧(fig7)。

Session.ts:12208 调用的是同一个 evaluatePermissionFlow(),解构的是同一个 denyMessage,所以 ACP/daemon 这个面是顺带修好的。建议改一下"Not validated / out of scope"那条 —— 目前是少报了。! 前缀预检确实是独立路径(shell-utils.ts:2117Command '<cmd>' is blocked by permission rules),但那个面是用户自己敲的命令、从不进入模型上下文,留作后续处理是合理的。

5. 新增测试有没有牙齿?

反事实 + 6 个变异体,全部通过 vitest 跑(fig5):

  • 反事实(PR 的测试跑在改动前的生产代码上):4 个新测试里有 3 个失败,且各自失败在它要守护的断言上。
  • 6/6 变异体全部被杀 —— 禁用任一新增 pass、把 isToolWideDenyRule() 双向强制成常量、回退消息前缀、以及废掉 '//**' 路径判断,每个都恰好杀死一个测试。

6. 回归扫描

PR head 上跑完整 packages/core 套件:24030 通过,3 失败session-writer-leasegit-branchesskill-curator)。这 3 个在反向 apply 后的 base arm 上同样失败 —— 是典型的 root 身份运行导致的环境问题(用 chmod 注入的失败被 root 直接绕过),与本改动无关。仓库里没有任何测试固定了旧措辞,因此不需要额外改动。

发现的问题(两个小问题,都不阻塞)

1. Bash(**) 实际是工具级全禁,却仍然给出"其他用法仍被允许"的安慰语。
我用实测方式判定"是否工具级"—— 该规则是否拒绝了该工具所有采样调用 —— 再与消息是否安慰做对照(fig6)。Bash(**) 拒绝了 4/4 条采样命令(matchesCommandPattern** 当作全匹配),但 isToolWideDenyRule 对 command 类型只判断 specifier === '*',于是模型被告知其他用法仍可用,而实际上什么都跑不了。表中其他写法分类都正确,包括工程根作用域的 Read(/**) / Read(**)(正确地判为工具级 —— 工程根之外的文件仍可读)。想修的话一行即可:

// command / domain catch-all
return rule.specifier === '*' || rule.specifier === '**';

2. WebFetch(*) 这条 catch-all 分支及其测试用例实际不可达。
matchesDomainPattern()rule-parser.ts:1378)只支持精确匹配和子域名匹配,完全没有通配符分支。所以 WebFetch(*)WebFetch(domain:*) 什么都拒绝不了(两个 arm 下采样 3 个 URL 均为 0/3),这意味着 domain 规则永远走不到 specifier === '*' 那个分支。isToolWideDenyRule 上方的注释('*' is the documented catch-all for command and domain specifiers)对 domain 而言目前不成立;而 does not reassure for tool-wide catch-all deny rules 里的 WebFetch(*) 用例因为 mock 掉了 findMatchingDenyRule 而是空跑通过的。把那一项换成裸 WebFetch(它确实 3/3 全拒)测试才有实际断言力。

Verification environment / 验证环境
worktree     /var/tmp/pr11411-wt @ f69d917cca (PR head)
base arm     same tree, `git apply -R` of the 2 production hunks only  -> 1919ff97f5 behaviour
build        npm run build:packages + node esbuild.config.js  (esbuild compiles core from src,
             so the A/B is: revert hunks -> rebundle -> swap dist/)
model        mock OpenAI server on 127.0.0.1, scripted run_shell_command tool_calls,
             every request body persisted -> the tool-result strings above are on-the-wire, not rendered
surfaces     interactive TUI (tmux, real pty) | headless -p | qwen --acp (ClientSideConnection)
node         v22.22.2, Linux x86_64, running as root

@wenshao
wenshao added this pull request to the merge queue Sep 9, 2026
Merged via the queue into QwenLM:main with commit f1ff69f Sep 9, 2026
93 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.3.

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.

Denied tool with a pattern, forces model to not use the tool at all

5 participants