Skip to content

fix(core): treat only space/tab/newline as word separators in isAsyncOperator - #11865

Draft
yiliang114 wants to merge 1 commit into
mainfrom
fix/11851-async-operator-word-separators
Draft

yiliang114 wants to merge 1 commit into
mainfrom
fix/11851-async-operator-word-separators

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

isAsyncOperator in packages/core/src/permissions/rule-parser.ts decides whether a bare & is the async (background) operator or part of a redirection by scanning backward from the & and skipping whitespace. It skipped characters using JavaScript's /\s/, which also matches \r, \v, \f, \u00a0 and other Unicode whitespace — none of which bash treats as word separators (bash's default IFS is space, tab and newline only). The scan now skips only those three characters, via a new module-local BASH_WORD_SEPARATORS constant.

Why it's needed

This was a fail-open permission bypass. For echo x >\r& rm -rf ~/important, bash runs two commands (a backgrounded echo x writing to a file literally named \r, then a foreground rm), but the splitter kept both halves in one segment — so the second command never got its own permission rule check, and an allow rule matching only the first command (Bash(echo x)) covered the rm. Full analysis and reproduction are in the linked issue.

Reviewer Test Plan

How to verify

  1. npx vitest run src/permissions/permission-manager.test.ts --root packages/core — 433/433 pass, including 5 new cases pinning \r, \v, \f, \u00a0 (and the spaced variant echo x > \r & rm …) between > and &, asserting two segments. All 5 fail if the one-line fix is reverted (verified locally).
  2. Behavior witnesses I ran on this branch (before → after the fix, against built packages/core/dist):
    • splitCompoundCommandSegments('echo x >\r& echo DANGER'): 1 segment → 2 segments (same for \v, \f, \u00a0, spaced variant).
    • With allow rule Bash(echo x), PermissionManager.isCommandAllowed('echo x >\r& rm -rf /tmp/…'): allowask (fail-closed). Plain-space control echo x & rm … stays ask; benign echo x >out & echo done stays allow.
    • Real bash (xtrace, Linux): echo x >\r& echo DANGER_CR runs echo DANGER_CR as its own foreground command and creates a 2-byte file named \r; the real-space control echo x > & echo y is a bash syntax error — confirming these characters are not IFS whitespace.
  3. Confirm the documented exclusions still hold (covered by pre-existing tests): &>/&>> short-circuit before the scan, 2>&1/>&2/exec 3<&4 hit the redirection char adjacently, and escaped \>/\< still background.

Evidence (Before & After)

N/A (non-UI logic change; witnesses are the command outputs above)

Tested on

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

Environment (optional)

Local vitest + node 24 against packages/core/dist; bash 5.x xtrace for the shell ground truth.

Risk & Scope

  • Main risk or tradeoff: over-splitting. Mitigated by bash semantics: >&/<& descriptor duplication requires adjacency (echo x > &1 is a syntax error), so no legitimate redirection can have one of these characters between the operator and the & — narrowing the skip set is monotonically fail-closed. Existing tests for &>, 2>&1, >&2, exec 3<&4, quoted &, and arithmetic & all still pass (542/542 across permission-manager.test.ts + shell-semantics.test.ts, 172/172 in shell-utils.test.ts; tsc --noEmit clean for packages/core).
  • Not validated / out of scope: the same \s-as-bash-separator pattern in packages/core/src/utils/shell-utils.ts (e.g. its isCommentStart/isWordBoundary) — flagged in the issue's triage comment for a separate audit; the sibling isCommentStart instance in rule-parser.ts is fix(core): treat a word-initial # as a comment when splitting shell commands #11821's scope. Sequencing note: this PR adds BASH_WORD_SEPARATORS adjacent to where fix(core): treat a word-initial # as a comment when splitting shell commands #11821 inserts isCommentStart, so whichever lands second needs a trivial rebase; the constant is here for it to reuse.
  • Breaking changes / migration notes: none. BASH_WORD_SEPARATORS is module-local (not exported); fix(core): treat a word-initial # as a comment when splitting shell commands #11821 can derive its whitespace half from it when it lands.

Linked Issues

Fixes #11851

中文说明

这个 PR 做了什么

packages/core/src/permissions/rule-parser.ts 里的 isAsyncOperator 通过从 & 向前回扫并跳过空白字符,来判断裸 & 是异步(后台)操作符还是重定向的一部分。它之前用 JavaScript 的 /\s/ 跳过字符,而 \s 还会匹配 \r\v\f\u00a0 等 Unicode 空白——bash 并不把这些当作词分隔符(bash 默认 IFS 只有空格、tab、换行)。现在回扫只跳过这三个字符(新增模块内常量 BASH_WORD_SEPARATORS)。

为什么需要

这是一个 fail-open 权限绕过。对于 echo x >\r& rm -rf ~/important,bash 实际执行两条命令(后台的 echo x 写入名为 \r 的文件,然后前台执行 rm),但切分器把两半留在同一个 segment 里——第二条命令没有经过自己的权限规则检查,只匹配第一条命令的 allow 规则(Bash(echo x))就覆盖了 rm。完整分析与复现见关联 issue。

评审验证计划

见上方英文部分:核心是看 5 条新回归测试(钉住 \r/\v/\f/\u00a0 及带空格变体),修复前它们全部失败、修复后全部通过;端到端 witness 从 allow 变为 ask

风险与范围

…Operator

JavaScript's \s also matches \r, \v, \f and  , none of which bash
treats as word separators (its default IFS is space, tab and newline).
When one of those characters sat between a redirection operator and a
bare &, the backward scan in isAsyncOperator skipped past it, concluded
the & belonged to a redirection, and kept both halves of something like
'echo x >\r& rm -rf ~/important' in one segment — letting the first
command's allow rule cover the second (fail-open).

Skip only [' ', '\t', '\n'] (BASH_WORD_SEPARATORS) instead, and pin all
four characters, the spaced variant, and the 2>&1 exclusion with
regression tests.

Fixes #11851

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 14, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 14, 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!

Template looks good ✓ — every required heading is filled in, including a real Tested-on table and a complete Chinese translation.

Problem: observed, not theoretical. #11851 carries bash xtrace traces showing echo x >\r& echo DANGER_CR really does run two commands (and leaves a 2-byte file named $'\r'), plus the control that makes the argument: echo x > & echo DANGER_SP is a bash syntax error, which is precisely why a plain space and a CR are not interchangeable here. One gap, honestly disclosed in the issue: \v and \f were inferred from shape rather than traced. The inference holds — neither is in bash's default IFS — but the traces cover \r and \u00a0 only.

Direction: aligned, and the reference signal is unusually strong. Claude Code's CHANGELOG shows this class of shell-splitter permission bypass being fixed repeatedly, in the same fail-closed direction — "Fixed a Bash permission bypass where a crafted command could hide parts of itself from permission checks", "fixed trailing & background job bypass", and "Fixed a Bash tool permission-check bypass where zsh could execute hidden commands …; affected commands now prompt for permission". A splitter that hides the second command from its own rule check is the same bug shape.

Size: core path touched (packages/core/src/permissions/rule-parser.ts). Breakdown — production logic 16 lines (15 added, 1 removed), tests 25 lines (permission-manager.test.ts), generated/schema 0. Far below the 500-line threshold, so no Tier 1 concern and no large-PR advisory. The author holds admin on this repo, so this is maintainer-authored; Tier 2's confidence bar is met with every downstream consumer named in the Stage 2 review.

Approach: the scope is right and I would not cut anything. Before reading the diff I sketched the same fix — narrow the backward scan's skip set to exactly space/tab/newline, pin the four characters with a regression test, leave sibling \s sites alone. That is what landed, at 16 production lines. The reuse question resolves correctly too: #11821 is still OPEN and its COMMENT_WORD_BOUNDARIES does not exist on main, so there was nothing to reuse yet — introducing BASH_WORD_SEPARATORS module-local now, with the sequencing conflict against #11821 called out in the description, is the right order.

Risk: no Stage 1e high-risk path match. Two things still deserve reviewer attention, neither blocking:

  • This is the permission engine, i.e. a security boundary, and the issue was filed by the same person who wrote the fix (review/self-reported). main requires two approvals, so a second human pair of eyes is the normal path here rather than an extra precaution.
  • The deferral is honest but currently lives only in an issue comment: packages/core/src/utils/shell-utils.ts still has three /\s/ sites (around lines 288, 1128, 1325) that are candidates for the same mistake. Worth a tracked follow-up issue so the audit does not depend on someone remembering security: isAsyncOperator treats \r/\v/\f/\u00a0 as bash word separators, so a Bash allow rule can cover a second command #11851's thread.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需标题都已填写,包括真实的 Tested-on 表格和完整的中文翻译。

问题: 是已观测到的 bug,不是理论性加固。#11851 附有 bash xtrace 追踪,证明 echo x >\r& echo DANGER_CR 确实执行两条命令(并留下一个名为 $'\r' 的 2 字节文件);还有一个关键对照:echo x > & echo DANGER_SP 在 bash 里是语法错误——这正好说明为什么普通空格和 CR 在这里不可互换。有一处作者已如实说明的缺口:\v\f 是按形态推断的,没有实际追踪。推断成立(两者都不在 bash 默认 IFS 中),但追踪只覆盖了 \r\u00a0

方向: 对齐,而且参考信号异常明确。Claude Code 的 CHANGELOG 显示这类 shell 切分器权限绕过被反复修复,且方向一致(fail-closed)——"Fixed a Bash permission bypass where a crafted command could hide parts of itself from permission checks"、"fixed trailing & background job bypass"、以及 "Fixed a Bash tool permission-check bypass where zsh could execute hidden commands …; affected commands now prompt for permission"。切分器把第二条命令藏在同一段里、绕过它自己的规则检查,正是同一种 bug 形态。

规模: 触及核心路径(packages/core/src/permissions/rule-parser.ts)。明细——生产逻辑 16 行(新增 15、删除 1),测试 25 行permission-manager.test.ts),生成/schema 0 行。远低于 500 行阈值,因此不涉及 Tier 1,也不触发大 PR 建议。作者在本仓库持有 admin 权限,属于维护者自撰 PR;Tier 2 的信心要求已满足,所有下游消费者在 Stage 2 审查中逐一点名。

方案: 范围合理,我不会砍任何部分。在读 diff 之前我先勾勒了自己的修法——把回扫的跳过集合收窄到恰好空格/tab/换行、用回归测试钉住那四个字符、不动同形态的其他 \s 站点。落地的正是这个方案,生产代码 16 行。复用问题也处理正确:#11821 仍是 OPEN,它的 COMMENT_WORD_BOUNDARIESmain 上还不存在,所以当下无可复用——先落地模块内常量 BASH_WORD_SEPARATORS,并在描述里点明与 #11821 的先后冲突,是正确的顺序。

风险: 未命中 Stage 1e 高风险路径。有两点仍值得评审者注意,均非阻塞:

进入代码审查 🔍

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

I sketched a fix from the title and the "Why it's needed" section before opening the diff: narrow the backward scan's skip set to exactly space/tab/newline, pin the four characters with a regression test, leave sibling \s sites alone. That is what this PR does, in 16 production lines. No simpler path occurred to me afterwards either.

The change is strictly monotonic toward splitting, and that is what makes it safe to reason about. isAsyncOperator differs only in which characters its loop skips, so narrowing the set can only make the loop stop earlier, at a character it used to skip. Such a character is by definition not > or < (if it were, the old code would not have skipped it), so the loop hits return true. Every other path already returned true before the change. The single behavioural delta is: a case that used to return false now returns true — more splits, never fewer.

More splits can only tighten the verdict. I traced both consumers rather than assuming:

  • permission-manager.ts — all four call sites (lines 336, 956, 1112, 1210) feed evaluateCompoundCommand, which scores segments deny: 3 > ask: 2 > allow: 0 and short-circuits on deny. Line 956's findMatchingDenyRule iterates segments so a deny rule matching any segment decides. Splitting a hidden second command out can therefore raise the verdict but never lower it.
  • shell-semantics.ts:2266walkCompoundCommand extracts operations per segment and reads terminator === '&' to know a segment was backgrounded. Finer segmentation means more accurate per-command attribution, and the attack command's first half is now correctly tagged backgrounded.

No legitimate bash construct regresses. The scan returns false only on reaching an unescaped > or <. For & to genuinely belong to a redirection it must be part of the >& / <& token, and bash lexes those as single tokens with the target word following the operator — so > and & are adjacent. A non-IFS character between them is a word constituent, i.e. the redirection's filename, which leaves the & as the async operator. I checked each documented exclusion against the code, not just the test list:

  • &> / &>> — short-circuits on command[index + 1] === '>', before the loop. Untouched.
  • 2>&1, >&2, exec 3<&4, cat <&3, > out.txt 2>& 1> / < adjacent, so the loop's first iteration finds it with no skipping involved. Untouched.
  • echo a \> & rm -rf /tmp/x — the separator is a plain space, which is in BASH_WORD_SEPARATORS, so the scan still reaches the escaped \> and still returns true. Untouched.
  • echo a \\>& 2 — adjacent >, even backslash count → false. Untouched.
  • $(( a & b )) — gated by arithmeticDepth at the call site (rule-parser.ts:941) before isAsyncOperator is consulted. Untouched.

I also grepped every permissions/*.test.ts for \r, \v, \f, \u00a0 and CRLF: the only hits are the five new cases. Nothing existing pinned the old behaviour, so there is no test this could silently contradict.

The new tests are load-bearing — I verified this statically rather than taking the description's word for it. Tracing the pre-fix code on echo x >\r& rm -rf /tmp/x: command[index + 1] is a space, the loop skips \r under /\s/, lands on >, precedingBackslashCount is 0, 0 % 2 === 1 is false → returns false → no split → one segment. The test asserts two, so it fails without the fix. Same trace for the spaced variant echo x > \r & …, which skips space/\r/space before landing on >. All five cases pin the change.

The expected first segment 'echo x >' is correct and the reason is worth the comment the author attached to it: segments are trimmed, and String.prototype.trim() strips these same four characters, so the dangling > is what survives. Without that note a future reader would "fix" the assertion. The dangling > is harmless — the security property is that rm -rf /tmp/x becomes its own segment and gets its own rule check — and it is not a new segment shape either, since echo a \> already yields a segment ending in a redirection character on main today.

Reuse ladder exhausted correctly. There is no existing shared constant to extend: COMMENT_WORD_BOUNDARIES belongs to #11821, which is still OPEN, and I confirmed it is absent from main's rule-parser.ts. No standard-library API expresses "bash's default IFS" — JS \s is precisely the wrong proxy, which is the bug. A three-element module-local const, not exported as claimed, is the minimal correct form.

Conventions: kebab-case.ts, ESM, no any, tests collocated into the existing describe('splitCompoundCommand') block where every sibling case lives (there is no rule-parser.test.ts). The new doc comment explains the why — bash IFS versus JS \s — instead of restating the code. No drive-by refactors, no formatting churn, nothing unrelated in the diff.

No blocking findings. One non-blocking note: once #11821 lands, this file will hold both BASH_WORD_SEPARATORS and COMMENT_WORD_BOUNDARIES with overlapping whitespace halves. The PR already flags the trivial rebase; whoever lands second should collapse them into one notion of "bash word separator", which is what the issue asked for. Doing it in this PR would couple a security fix to an unmerged one, so deferring is the right call.

I skipped the sequence diagram and the changed-files table — two files and a single predicate swap do not need either.

Test evidence

This was an unattended CI run (GITHUB_EVENT_NAME=pull_request_target), so per the gate's rules I did not build, run, or test any PR-derived code. Every local file read happened in a clean worktree checked out at main, which does not contain this PR's changes; the diff itself came from the GitHub API. The evidence below is the PR's own CI, read through the API for the reviewed commit — real check names and conclusions, fetched once, with no polling.

Nothing is red at the time of writing. The two checks that matter most for this diff — Test (ubuntu-latest, Node 22.x), which carries the five new cases, and Lint & Static — were still in progress, so their outcome is not verified: CI had not reported yet. Integration Tests (no-AK, No Sandbox) and both Desktop Shell jobs are green.

Worth noting for the Tested-on table: Test (macos-latest, …) and Test (windows-latest, …) are skipped in this configuration, so the unit suite runs on Linux only. The author's "Linux ✅ / macOS ⚠️ / Windows ⚠️" rows therefore match CI's own coverage — there is no platform gap here that CI would have closed anyway, and the changed logic is pure string scanning with no OS-dependent behaviour.

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

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

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

Sandboxed verification would settle the one claim the suite does not cover: @qwen-code /verify — the five new tests pin splitCompoundCommand's segment output, but the end-to-end permission witness (with allow rule Bash(echo x), isCommandAllowed('echo x >\r& rm -rf /tmp/…') flipping allowask, while the plain-space control and the benign echo x >out & echo done stay put) is the author's claim from a run against their local dist build, not independently re-run here. That flip is the actual security property; the segment count is only its mechanism. The author has write access, so /verify runs directly rather than as a sponsored run.

Real-scenario tmux testing: N/A — this is an unattended CI run, and the change has no TUI surface.

中文说明

代码审查

在看 diff 之前,我先根据标题和"为什么需要"部分勾勒了自己的修法:把回扫的跳过集合收窄到恰好空格/tab/换行,用回归测试钉住那四个字符,不动同形态的其他 \s 站点。这个 PR 做的正是这件事,生产代码 16 行。事后我也没想到更简的路径。

这个改动在"切分"方向上是严格单调的,这正是它能被安全推理的原因。 isAsyncOperator 的区别只在于循环跳过哪些字符,因此收窄集合只可能让循环更早停下,停在一个它过去会跳过的字符上。这样的字符按定义不是 ><(如果是,旧代码也不会跳过它),于是走到 return true。其余所有路径在改动前就已经返回 true。唯一的行为差异是:过去返回 false 的情形现在返回 true——只会切得更多,绝不会更少。

切分更多只会让判定更严。 我没有靠假设,而是逐个追踪了消费者:

  • permission-manager.ts —— 四个调用点(336、956、1112、1210 行)都进入 evaluateCompoundCommand,其打分是 deny: 3 > ask: 2 > allow: 0,且遇到 deny 短路。956 行的 findMatchingDenyRule 遍历各段,任一段命中 deny 规则即为决定。因此把被隐藏的第二条命令切出来只可能抬高判定,不可能降低。
  • shell-semantics.ts:2266 —— walkCompoundCommand 按段提取操作,并读取 terminator === '&' 判断该段是否后台执行。分段更细意味着按命令归因更准确,而且攻击命令的前半段现在会被正确标记为后台。

没有任何合法 bash 构造会因此退化。 只有在遇到未转义的 >< 时扫描才返回 false。而 & 真要属于重定向,它必须是 >& / <& 这个 token 的一部分——bash 把它们作为单一 token 词法分析,目标词跟在操作符之后,所以 >& 必然相邻。两者之间夹一个非 IFS 字符时,该字符是词的组成部分,也就是重定向的目标文件名,此时 & 就是异步操作符。我对照代码逐一核查了文档中列出的每个排除项,而不只是看测试列表:

  • &> / &>> —— 在循环之前就由 command[index + 1] === '>' 短路。未受影响。
  • 2>&1>&2exec 3<&4cat <&3> out.txt 2>& 1 —— > / < 相邻,循环第一次迭代就命中,完全不涉及跳过。未受影响。
  • echo a \> & rm -rf /tmp/x —— 分隔符是普通空格,而空格 BASH_WORD_SEPARATORS 中,所以扫描仍能到达被转义的 \> 并仍返回 true。未受影响。
  • echo a \\>& 2 —— > 相邻,反斜杠数为偶数 → false。未受影响。
  • $(( a & b )) —— 在调用点(rule-parser.ts:941)由 arithmeticDepth 先行拦截,根本不会调用 isAsyncOperator。未受影响。

我还在所有 permissions/*.test.ts 中搜索了 \r\v\f\u00a0 和 CRLF:唯一命中的就是新增的五个用例。现有测试没有钉住旧行为,因此不存在会被悄悄矛盾的断言。

新增测试是"承重"的——这一点我做了静态验证,而不是采信描述里的说法。echo x >\r& rm -rf /tmp/x 追踪修复前的代码:command[index + 1] 是空格,循环在 /\s/ 下跳过 \r,落到 >precedingBackslashCount 为 0,0 % 2 === 1 为假 → 返回 false → 不切分 → 一段。测试断言两段,因此在没有修复时必然失败。带空格的变体 echo x > \r & … 追踪结果相同(依次跳过 空格/\r/空格 后落到 >)。五个用例都钉住了这个改动。

第一段期望值 'echo x >' 是正确的,而其原因正值得作者附加的那条注释:段会被 trim,而 String.prototype.trim() 同样会剥掉这四个字符,所以剩下的就是那个悬空的 >。没有这条说明,后来的读者会去"修正"这个断言。悬空的 > 无害——安全属性在于 rm -rf /tmp/x 成为独立一段并接受自己的规则检查——而且这也不是新的段形态,因为 main 上今天的 echo a \> 就已经产出以重定向字符结尾的段。

复用阶梯已正确走到尽头。 没有可扩展的既有共享常量:COMMENT_WORD_BOUNDARIES 属于 #11821,该 PR 仍是 OPEN,我已确认 mainrule-parser.ts 中不存在它。标准库也没有任何 API 能表达"bash 的默认 IFS"——JS 的 \s 恰恰是错误的代理,这正是 bug 本身。因此一个三元素的模块内常量(如所述未导出)是最小的正确形态。

约定: kebab-case.ts、ESM、无 any、测试就近放入既有的 describe('splitCompoundCommand') 块——所有同类用例都在那里(不存在 rule-parser.test.ts)。新增的文档注释解释的是 why(bash IFS 与 JS \s 的区别),而不是复述代码。没有顺手重构,没有格式抖动,diff 中没有无关内容。

无阻塞性问题。 一点非阻塞提示:#11821 合入后,本文件会同时存在 BASH_WORD_SEPARATORSCOMMENT_WORD_BOUNDARIES,两者的空白部分重叠。PR 已点明这个简单的 rebase;后合入的一方应把它们收敛为单一的"bash 词分隔符"概念,这也正是 issue 的诉求。在本 PR 里做这件事会把一个安全修复耦合到未合并的 PR 上,因此延后是正确的。

我省略了时序图和变更文件表——两个文件、一次谓词替换,两者都不必要。

测试证据

本次为无人值守 CI 运行(GITHUB_EVENT_NAME=pull_request_target),因此按门禁规则,我没有构建、运行或测试任何源自本 PR 的代码。所有本地文件读取都在一个检出到 main 的干净 worktree 中进行,该 worktree 不含本 PR 的改动;diff 本身来自 GitHub API。下方证据是本 PR 自己的 CI,通过 API 针对被审查的 commit 读取——真实的检查名与结论,只取一次,不做轮询。

撰写时无红灯。对本 diff 最关键的两项——承载五个新用例的 Test (ubuntu-latest, Node 22.x)Lint & Static——仍在进行中,因此其结果未验证:CI 尚未给出结论Integration Tests (no-AK, No Sandbox) 和两个 Desktop Shell 作业为绿。

关于 Tested-on 表格值得一提:此配置下 Test (macos-latest, …)Test (windows-latest, …)skipped,即单元测试仅在 Linux 运行。因此作者的 "Linux ✅ / macOS ⚠️ / Windows ⚠️" 与 CI 自身的覆盖范围一致——这里不存在 CI 本来能补上的平台缺口,且改动逻辑是纯字符串扫描,没有依赖操作系统的行为。

沙箱验证可以解决测试套件未覆盖的那一项主张:@qwen-code /verify —— 五个新测试钉住的是 splitCompoundCommand分段输出,但端到端的权限见证(在 allow 规则 Bash(echo x) 下,isCommandAllowed('echo x >\r& rm -rf /tmp/…')allow 翻转为 ask,同时普通空格对照组和良性的 echo x >out & echo done 保持不变)是作者针对本地 dist 构建运行后的说法,此处未独立复跑。这个翻转才是真正的安全属性,分段数只是它的机制。作者具备写权限,因此 /verify 可直接运行,无需 sponsor。

真实场景 tmux 测试:N/A —— 本次为无人值守 CI 运行,且改动没有 TUI 界面。

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the reasoning is airtight and I found no blocking issue; the missing point is CI still running plus three named non-blocking follow-ups, not doubt about the code.

Stepping back: this is a permission-engine fix where the safety argument does not depend on enumerating every input. Narrowing the scan's skip set can only make it stop earlier, and stopping earlier always yields "this & is the async operator" — so the change is monotonic toward splitting, and both consumers resolve multiple segments most-restrictively (deny > ask > allow, with a deny short-circuit). There is no input for which this PR makes a verdict more permissive. For a security-boundary edit, that is the strongest property you can ask for, and it is why I am comfortable without having executed anything.

I also satisfied myself the problem is real rather than accepting the framing. I traced the pre-fix code on echo x >\r& rm -rf /tmp/x by hand and got one segment; the bash grammar argument holds independently of the issue's traces, because >& and <& are single lexer tokens whose target word follows the operator, so a non-IFS character between > and & has to be a filename, not whitespace. The issue's control case (> & with a real space being a syntax error) is the right control — it is what proves these characters are not interchangeable with IFS whitespace.

The tests are load-bearing, which I checked statically rather than on faith: each of the five cases fails against the unfixed predicate. That matters more than usual here, because a regression test that passes both ways would have made this PR green and worthless.

On the "am I being worn down by volume" question — the same author has #11821 open with the sibling isCommentStart fix. I judged this one on its own merits, and the sequencing is a point in its favour, not against: this PR explicitly declined to widen into #11821's scope and said so in the description, which is the minimal-change discipline the repo asks for.

What keeps it at 4 rather than 5, none of it blocking:

  1. The end-to-end permission witness — allow rule Bash(echo x) with isCommandAllowed('echo x >\r& rm -rf …') flipping allowask — is the author's claim from a local dist run. The new tests pin the segment count, which is the mechanism, not the verdict. @qwen-code /verify would close that gap; I named it in Stage 2.
  2. \v and \f were inferred rather than traced (disclosed in the issue). The inference is sound — neither is in default IFS — but only \r and \u00a0 have bash output behind them.
  3. Three /\s/ sites remain in packages/core/src/utils/shell-utils.ts (around lines 288, 1128, 1325) as candidates for the same mistake. Correctly out of scope here; they deserve a tracked issue rather than living only in security: isAsyncOperator treats \r/\v/\f/\u00a0 as bash word separators, so a Bash allow rule can cover a second command #11851's thread.

One process note for the humans, not a criticism: the issue and the fix come from the same author (review/self-reported), who holds admin. main requires two approvals, so this still needs a second human pair of eyes — my approval is one vote, not a substitute for that.

CI has not settled yet (Test (ubuntu-latest, Node 22.x) and Lint & Static were still in progress; Integration Tests and both Desktop Shell jobs are green, nothing red). Approving now would attest to a test result that does not exist yet, so approval is deferred until CI lands green on 47d93007ab22457bf5a4a3562e4a58cb86e89e34 — the commit this review actually read. If anything goes red or the head moves, the deferral is withheld rather than honoured.

中文说明

信心:4/5 —— 推理严密,未发现阻塞性问题;扣掉的一分来自 CI 仍在运行以及三项已点名的非阻塞后续项,而不是对代码本身的怀疑。

退一步看整体:这是一个权限引擎修复,其安全性论证不依赖于穷举所有输入。收窄扫描的跳过集合只可能让它更早停下,而更早停下总是得出"这个 & 是异步操作符"——因此改动在切分方向上单调,而两个消费者在多段情形下都按最严格者定夺(deny > ask > allow,且 deny 短路)。不存在任何输入会让本 PR 把判定变得更宽松。对一处安全边界改动来说,这是能要求的最强属性,也是我在没有执行任何代码的情况下依然放心的原因。

我也确认了问题真实存在,而不是接受 PR 的叙述框架。我手工追踪了修复前的代码处理 echo x >\r& rm -rf /tmp/x 的路径,得到的是一段;而 bash 语法层面的论证独立于 issue 的追踪也成立——>&<& 是单一词法 token,其目标词跟在操作符之后,所以 >& 之间的非 IFS 字符只能是文件名,不可能是空白。issue 的对照实验(带真实空格的 > & 是语法错误)选得对——正是它证明了这些字符与 IFS 空白不可互换。

这些测试是"承重"的,这一点我做了静态核查而非采信:五个用例在未修复的谓词下都会失败。在这里这一点比平常更重要,因为一个两边都通过的回归测试会让本 PR 既绿又无效。

关于"我是否被数量磨平了"这个问题——同一作者还有 #11821 在处理同形态的 isCommentStart 修复。我是就本 PR 本身的价值做判断的,而这种先后安排是加分项而非减分项:本 PR 明确拒绝扩大到 #11821 的范围,并在描述中说明了这一点,这正是仓库所要求的最小改动纪律。

之所以是 4 而不是 5,均非阻塞:

  1. 端到端的权限见证——allow 规则 Bash(echo x)isCommandAllowed('echo x >\r& rm -rf …')allow 翻转为 ask——是作者针对本地 dist 运行的说法。新测试钉住的是分段数,那是机制,不是判定本身。@qwen-code /verify 可以补上这个缺口,我在 Stage 2 已点名。
  2. \v\f 是推断而非实际追踪(issue 中已披露)。推断成立(两者都不在默认 IFS 中),但只有 \r\u00a0 有 bash 输出作为支撑。
  3. packages/core/src/utils/shell-utils.ts 中仍剩三处 /\s/(约 288、1128、1325 行)是同类错误的候选点。在本 PR 中正确地不予处理,但它们值得开一个可跟踪的 issue,而不是只存在于 security: isAsyncOperator treats \r/\v/\f/\u00a0 as bash word separators, so a Bash allow rule can cover a second command #11851 的讨论串里。

一点给人类评审的流程提示,并非批评:issue 与修复出自同一作者(review/self-reported),且其持有 admin 权限。main 需要两个 approve,因此仍需第二双人工眼睛——我的 approve 是一票,不能替代那一票。

CI 尚未收敛(Test (ubuntu-latest, Node 22.x)Lint & Static 仍在进行;Integration Tests 与两个 Desktop Shell 作业为绿,无红灯)。此刻 approve 等于为一个尚不存在的测试结果背书,因此批准推迟到 CI 在 47d93007ab22457bf5a4a3562e4a58cb86e89e34——也就是本次审查真正读过的那个 commit——上转绿之后。若有任一项变红或 head 发生移动,则该推迟会被撤回而非执行。

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

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

@yiliang114
yiliang114 marked this pull request as draft September 14, 2026 14:39

@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 5": none — I did not verify by execution that the reverted /\s/ line turns the five new cases red, because that requires editing the reviewed source in this share…; "agent reverse-audit (round 1)": none — every check I named above ran to completion.; "agent 6c": whether checkArgumentSafety (packages/cli/src/services/prompt-processors/shellProcessor.ts:104-110) rejects a raw CR/VT/FF, i.e. whether a model-supplied $AR…; "agent 6c": what the other splitCommands consumers conclude for the unsplit payload — tools/shell.ts:412, 680, 820, 862, 916 and shellReadOnlyChecker.ts:349 — specifi…; "agent 6c": running the PR's five new vitest cases in packages/core ; I verified the equivalent behaviour by executing the built dist ( splitCompoundCommand on the raw ….

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

中文说明

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

未探索到全部深度(达到工具调用预算):"agent 5"none — I did not verify by execution that the reverted /\s/ line turns the five new cases red, because that requires editing the reviewed source in this share…"agent reverse-audit (round 1)"none — every check I named above ran to completion."agent 6c"whether checkArgumentSafety (packages/cli/src/services/prompt-processors/shellProcessor.ts:104-110) rejects a raw CR/VT/FF, i.e. whether a model-supplied $AR…"agent 6c"what the other splitCommands consumers conclude for the unsplit payload — tools/shell.ts:412, 680, 820, 862, 916 and shellReadOnlyChecker.ts:349 — specifi…"agent 6c"running the PR's five new vitest cases in packages/core ; I verified the equivalent behaviour by executing the built dist ( splitCompoundCommand on the raw …

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

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

for (let j = index - 1; j >= 0; j--) {
const ch = command[j]!;
if (/\s/.test(ch)) {
if (BASH_WORD_SEPARATORS.includes(ch)) {

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: [certifies-falsely] [regression] Narrowing the skip set makes the & a command boundary immediately after a redirection target made only of characters String.prototype.trim strips — and the segment .trim() at rule-parser.ts:958/:969 then deletes that target, so the virtual write op disappears and a verdict that was deny at the merge base becomes allow.

The change itself is right and its direction is fail-closed at the splitter level. This is a consequence one layer up, so the description's "narrowing the skip set is monotonically fail-closed" does not hold all the way down to the decision.

Failure scenario. With permissions.allow: ['Bash(echo *)'] and permissions.deny: ['Edit(//project/**)', 'Write(//project/**)'], cwd /project, the command echo x >\u00a0& echo y (NBSP between the > and the & — bash really does create a file named NBSP) returned deny before this change, because the unsplit command reached extractShellOperations and produced write_file "/project/\u00a0", which matched the deny rule. After it, the segments are echo x > and echo y, the NBSP target sits at the segment edge, .trim() deletes it, extractShellOperationsAcrossCommand returns [], and evaluate returns allow. An unpermitted file whose name is invisible gets created inside a write-denied directory with no prompt.

Two precisions the measurement adds. The regressed characters are NBSP, VT and FF — CR was already missed at the merge base, so CR is not part of this regression. And an allow rule is not required: a deny-only configuration also loses, from a hard deny down to an ask prompt. A target carrying one visible character (echo x >\u00a0out& echo y) keeps its op and still returns deny on both arms, so the exposure is exactly the all-invisible target.

Witness — A/B against the built merge base 87437db784 and this commit 47d93007ab, same input on both arms:

BASE cmd="echo x >\u00a0& echo y" split=["echo x >\u00a0& echo y"] ops=write_file "/project/\u00a0" evaluate=deny
PR   cmd="echo x >\u00a0& echo y" split=["echo x >", "echo y"]      ops=(none)                     evaluate=allow
BASE write-deny/VT ops=write_file "/project/\u000b" evaluate=deny   PR ops=(none) evaluate=allow
BASE write-deny/FF ops=write_file "/project/\f"     evaluate=deny   PR ops=(none) evaluate=allow
BASE write-deny/CR ops=(none) evaluate=allow                        PR ops=(none) evaluate=allow  <- no change, CR was already missed
BASE+PR CTRL visible target "echo x >\u00a0out& echo y" ops=write_file "/project/\u00a0out" evaluate=deny (both arms)
BASE deny-only-no-allow/NBSP evaluate=deny  ->  PR deny-only-no-allow/NBSP evaluate=ask

Bash ground truth that the base arm's deny was the correct certification, not an accident:

$ printf 'echo x >\xc2\xa0& echo y\n' > s1.sh && bash s1.sh ; od -c <created file>
y 0000000 302 240   <- bash really creates a file named NBSP

Suggested fix. Keep the segment faithful to what bash runs, so the redirect target survives to op extraction. Either trim with a bash-faithful set at :958 and :969 — a small trimBashWordSeparators() that strips only BASH_WORD_SEPARATORS (plus the trailing \r of a CRLF pair, if that normalization is wanted) — or leave .trim() on the rule-matching projection but give CompoundCommandSegment the untrimmed slice and have walkCompoundCommand (shell-semantics.ts:2266) extract ops from that.

The bash-faithful trim is worth more than special-casing the all-invisible one: the same .trim() also mis-names a target that merely ends in an invisible character — echo x >out\u00a0& echo y yields filePath: "/project/out" while bash writes out\u00a0. That half is identical on both arms, so it predates this PR; fixing the trim closes both.

The fix must not violate permission-manager.test.ts:595, which pins expect(splitCompoundCommand(' git status && rm -rf / ')).toEqual(['git status', 'rm -rf /']) — ordinary space trimming has to survive. The five cases this PR adds at :666-668 assert ['echo x >', 'rm -rf /tmp/x'] and need a deliberate update if the trim changes. And const backgrounded = terminator === '&'; at shell-semantics.ts:2278 is a second consumer of the segment objects, so a shape change to CompoundCommandSegment reaches it too.

Please pin this with a test that fails without the fix: a case with deny Write(//project/**) and the payload echo x >\u00a0& echo y asserting evaluate({ toolName: 'run_shell_command', command: 'echo x >\u00a0& echo y' }) is not 'allow', plus a unit assertion that extractShellOperationsAcrossCommand('echo x >\u00a0& echo y', '/project') still yields { virtualTool: 'write_file', filePath: '/project/\u00a0' } — then remove the bash-faithful trim, run those two, and confirm both go red.

中文说明

收窄跳过集合后,当重定向目标完全由 String.prototype.trim 会剥离的字符组成时,& 会紧接在该目标之后成为命令边界——而 rule-parser.ts:958/:969 的 segment .trim() 随后把这个目标删掉,于是虚拟写操作消失,合并基线上原本为 deny 的判定变成了 allow

改动本身是正确的,在切分器层面方向也是 fail-closed 的。问题出在上一层的后果,因此 PR 描述里"收窄跳过集合是单调 fail-closed"的说法,在判定层面并不成立。

失败场景。 配置 permissions.allow: ['Bash(echo *)']permissions.deny: ['Edit(//project/**)', 'Write(//project/**)'],cwd 为 /project,命令 echo x >\u00a0& echo y>& 之间是 NBSP——bash 确实会创建一个名为 NBSP 的文件)。改动前返回 deny:未切分的整条命令进入 extractShellOperations,产生 write_file "/project/\u00a0",命中 deny 规则。改动后 segment 变成 echo x >echo y,NBSP 目标正好落在 segment 边缘被 .trim() 删掉,extractShellOperationsAcrossCommand 返回 []evaluate 返回 allow。一个名字不可见、且未被授权的文件,就这样在禁止写入的目录里被创建,且不会弹出任何确认。

实测补充两点精确结论:发生回归的字符是 NBSP、VT、FF——CR 在合并基线上本来就已经漏掉,不属于本次回归;并且不需要 allow 规则,只配 deny 的场景同样会失守,从硬 deny 降级为 ask 询问。目标里只要有一个可见字符(echo x >\u00a0out& echo y),两侧都保留 op 并仍然返回 deny,所以暴露面恰好是"目标全为不可见字符"这一种。

证据——针对已构建的合并基线 87437db784 与本提交 47d93007ab 做 A/B,两侧输入完全相同(见上方英文部分的输出)。bash 侧的事实依据也确认基线的 deny 是正确判定:printf 'echo x >\xc2\xa0& echo y\n' > s1.sh && bash s1.sh 确实会创建一个名为 NBSP 的文件(od -c 显示 302 240)。

修复建议。 让 segment 忠实反映 bash 实际执行的内容,使重定向目标能存活到 op 提取阶段。两种做法:在 :958:969 用符合 bash 语义的字符集来 trim——写一个只剥离 BASH_WORD_SEPARATORStrimBashWordSeparators()(如需保留 CRLF 归一化,再加上成对 \r\n 的尾部 \r);或者保留规则匹配投影上的 .trim(),但让 CompoundCommandSegment 携带未 trim 的原始切片,由 walkCompoundCommandshell-semantics.ts:2266)从中提取 op。

采用符合 bash 语义的 trim 比只为"全不可见目标"打特例更有价值:同一个 .trim() 还会让以不可见字符结尾的目标报错文件名——echo x >out\u00a0& echo y 得到 filePath: "/project/out",而 bash 实际写的是 out\u00a0。这一半在两侧完全相同,属于本 PR 之前就存在的问题;修好 trim 可以一并解决。

修复不得违反 permission-manager.test.ts:595:它钉住了 expect(splitCompoundCommand(' git status && rm -rf / ')).toEqual(['git status', 'rm -rf /']),普通空格的 trim 必须保留。本 PR 在 :666-668 新增的五个用例断言 ['echo x >', 'rm -rf /tmp/x'],若 trim 行为改变需要有意识地同步更新。另外 shell-semantics.ts:2278const backgrounded = terminator === '&'; 是 segment 对象的第二个消费者,因此改动 CompoundCommandSegment 的形状会波及它。

请补一个"去掉修复就会失败"的测试:在 deny Write(//project/**) 配置下,用载荷 echo x >\u00a0& echo y 断言 evaluate({ toolName: 'run_shell_command', command: 'echo x >\u00a0& echo y' }) 不是 'allow';再加一条单元断言,确认 extractShellOperationsAcrossCommand('echo x >\u00a0& echo y', '/project') 仍然产出 { virtualTool: 'write_file', filePath: '/project/\u00a0' }。然后把符合 bash 语义的 trim 去掉、跑这两条,确认都变红。

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

// Spaced variant: real separators around the non-IFS character.
['echo x > \r & rm -rf /tmp/x'],
])(
'splits %s, where a non-IFS "whitespace" sits between the > and the &',

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: The it.each title interpolates the raw command via %s, so all five new test names embed a literal CR / VT / FF / NBSP — and the \v and \f rows collapse onto a single JUnit name=.

Failure scenario. Vitest strips U+000B and U+000C because they are illegal in XML 1.0, while U+000D survives. In the emitted junit.xml the \v case and the \f case therefore come out byte-identical, so a red \v case is indistinguishable from a red \f case in the CI test report — .github/workflows/ci.yml:807-809 feeds packages/*/junit.xml to a java-junit reporter that keys on name. In console output the CR rows rewind the cursor to column 0, so the remainder of the title overwrites the pass/fail glyph and the file path in the CI log. The cost is diagnosis time on a security regression rather than a wrong verdict — but this is precisely the block whose five rows exist to be told apart from one another.

Witness — real junit-reporter run against the reviewed tree (433 testcases emitted; same reporter and serialization as packages/core/vitest.config.ts:27,41, output redirected outside the worktree):

non-IFS cases: 5 distinct name= attributes: 4  DUPLICATED: 1
  x2 b'splitCompoundCommand &gt; splits echo x &gt;&amp; rm -rf /tmp/x, where a non-IFS &quot;whitespace&quot; sits between the &gt; and the &amp;'
     <- the \v row and the \f row, one identifier
  b'...splits echo x &gt;\r&amp; rm -rf /tmp/x...'        <- raw 0x0D survives inside name="..."
  b'...splits echo x &gt;\xc2\xa0&amp; rm -rf /tmp/x...'  <- raw NBSP survives
  b'...splits echo x &gt; \r &amp; rm -rf /tmp/x...'      <- spaced CR row
file-level: 0x0b present: False  0x0c present: False  0x0d present: True

Suggested fix. Give each row a printable label column and interpolate that instead of the raw command, keeping the control characters only in the input. The it.each table at :654-660 and the title at :662 change together:

it.each([
  ['\\r', 'echo x >\r& rm -rf /tmp/x'],
  ['\\v', 'echo x >\v& rm -rf /tmp/x'],
  ['\\f', 'echo x >\f& rm -rf /tmp/x'],
  ['\\u00a0', 'echo x >\u00a0& rm -rf /tmp/x'],
  // Spaced variant: real separators around the non-IFS character.
  ['\\r (spaced)', 'echo x > \r & rm -rf /tmp/x'],
])(
  'splits a command with %s between the > and the &',
  async (_label, command) => {
    // ...body unchanged
  },
);

(This is a two-line-range change, so it is written out rather than offered as a one-click suggestion block anchored on the title line alone.) The repo already has this shape: packages/core/src/agents/runtime/workflow-meta-literal.test.ts:88-94 uses a two-column it.each with a ['CR', '\r']-style label precisely so titles stay printable, and the neighbouring it.each blocks in this same describe keep the interpolated column printable. Interpolating JSON.stringify(command) (or %j) also works if you would rather not add a column.

The fix must keep names XML-legal and unique per case: packages/core/vitest.config.ts:27 sets reporters: ['default', 'junit'] with junit: 'junit.xml' at :41, so every core test run writes a JUnit report that .github/workflows/ci.yml:807-809 collects via path: 'packages/*/junit.xml' into a java-junit reporter.

中文说明

it.each 的标题用 %s 直接插值了原始命令,因此五个新测试名里都嵌入了字面的 CR / VT / FF / NBSP——并且 \v\f 两行在 JUnit 里塌缩成同一个 name=

失败场景。 vitest 会剥离 U+000B 和 U+000C(它们在 XML 1.0 中非法),而 U+000D 会保留。于是生成的 junit.xml\v 用例与 \f 用例的名字逐字节相同:在 CI 测试报告中,\v 变红与 \f 变红无法区分——.github/workflows/ci.yml:807-809 正是把 packages/*/junit.xml 交给按 name 索引的 java-junit reporter。在控制台输出中,CR 行会把光标退回第 0 列,标题的剩余部分覆盖掉通过/失败标记与文件路径,CI 日志因此不可读。代价是排查一次安全回归所需的时间,而不是错误结论——但这恰恰是那个"五行存在的意义就是彼此可区分"的测试块。

证据——针对被评审代码树的真实 junit reporter 运行(输出 433 个 testcase;reporter 与序列化方式与 packages/core/vitest.config.ts:27,41 一致,输出重定向到工作树之外),详见上方英文部分的原始输出:五个 non-IFS 用例只产生 4 个不同的 name=,其中 1 组重复(\v 行与 \f 行);文件级检查为 0x0b present: False 0x0c present: False 0x0d present: True

修复建议。 给每行加一个可打印的标签列,标题插值该标签而不是原始命令,控制字符只保留在输入里。:654-660it.each 表格与 :662 的标题需要一起改(具体代码见上方英文部分)。因为这是跨两处的改动,所以直接写出代码,而没有提供只锚定标题行的 suggestion 一键应用块。仓库里已有同样写法:packages/core/src/agents/runtime/workflow-meta-literal.test.ts:88-94 用两列 it.each['CR', '\r'] 这样的标签,正是为了让标题保持可打印;同一个 describe 里相邻的 it.each 块也都让被插值的那一列保持可打印。如果不想加列,插值 JSON.stringify(command)(或用 %j)也可以。

修复必须保证测试名在 XML 中合法且每个用例唯一:packages/core/vitest.config.ts:27 设置了 reporters: ['default', 'junit']:41 设置了 junit: 'junit.xml',因此每次 core 测试都会写出 JUnit 报告,由 .github/workflows/ci.yml:807-809 通过 path: 'packages/*/junit.xml' 收集给 java-junit reporter。

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

async (command) => {
// Segments are trimmed, and `String.prototype.trim` also strips these
// characters, so the first segment ends at the bare `>`.
expect(splitCompoundCommand(command)).toEqual([

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 five new cases pin only splitCompoundCommand's returned array. Nothing asserts the PermissionManager.evaluate verdict the fix actually changes, even though this file already has an evaluate()-level compound suite to hang it on (it('semicolon compound: deny in second → deny', …) at :2328).

Failure scenario. The guarantee is decided one level up, at permission-manager.ts:336-341: splitCompoundCommandsubCommands.length > 1evaluateCompoundCommand. When the splitter returns a single segment, evaluateSingle matches the rule against the whole command string, and matchesCommandPattern compiles its regex with the s flag (rule-parser.ts:1088), so Bash(echo *) matches echo x >\r& rm -rf /tmp/x end to end and the verdict is allow — exactly the reported bypass. That is the pre-fix behaviour, and after the fix no test states the new verdict. So a change that re-joins segments, matches the unsplit command before splitting, or re-widens the separator set at one of the three other splitCompoundCommand call sites (permission-manager.ts:956, :1112, :1210) leaves all five new splitter tests green while a user's Bash(echo *) allow rule once again authorises the backgrounded rm -rf.

Witness — executed A/B; the base arm is the reverted state (if (/\s/.test(ch)) {), so this is the mutation run rather than a read of it (allow ['Bash(echo *)'], deny ['Bash(rm *)'], expected ['echo x >', 'rm -rf /tmp/x']):

BASE FAIL cmd="echo x >\r& rm -rf /tmp/x"      split=[1 segment] evaluate=allow
BASE FAIL cmd="echo x >\v& rm -rf /tmp/x"      split=[1 segment] evaluate=allow
BASE FAIL cmd="echo x >\f& rm -rf /tmp/x"      split=[1 segment] evaluate=allow
BASE FAIL cmd="echo x >\u00a0& rm -rf /tmp/x"  split=[1 segment] evaluate=allow
BASE FAIL cmd="echo x > \r & rm -rf /tmp/x"    split=[1 segment] evaluate=allow
PR   PASS x5 (all split=["echo x >","rm -rf /tmp/x"]) and evaluate=deny x5
BASE+PR quote-guard: evaluate("echo 'a && b'") with allow Bash(echo *) = allow

Suggested fix. Add one case beside :2328 with permissionsAllow: ['Bash(echo *)'], permissionsDeny: ['Bash(rm *)'] and command: 'echo x >\r& rm -rf /tmp/x', expecting 'deny'. Using the deny-rule shape rather than asserting a bare 'ask' keeps the test off resolveDefaultPermission's AST verdict for the truncated echo x > first segment.

Note this does not cover R1-1: that regression is a missing virtual write op under a Write(...) deny with an invisible-only target, and echo x >\u00a0& echo y contains no rm — measured evaluate=allow with the Bash(rm *) deny irrelevant. Catching it needs its own case.

The new case must not be satisfied by splitting more aggressively: permission-manager.test.ts:2359 (it('operators inside quotes: treated as single command', …)) requires evaluate({ command: "echo 'a && b'" }) with allow Bash(echo *) to stay 'allow'. Verified on both arms above — that guard is unaffected.

Please confirm the acceptance criterion by mutation: with the new evaluate() case in place, revert if (BASH_WORD_SEPARATORS.includes(ch)) { at rule-parser.ts:868 to if (/\s/.test(ch)) {, run it, and check it goes red — the five splitter-level cases already do, but the new one is what pins the decision rather than the intermediate array.

中文说明

五个新用例只钉住了 splitCompoundCommand 返回的数组,没有任何测试断言这个修复真正改变的 PermissionManager.evaluate 判定结果——尽管本文件里已经有可以挂上去的 evaluate() 级复合命令测试套件(:2328it('semicolon compound: deny in second → deny', …))。

失败场景。 该保证是在上一层决定的,见 permission-manager.ts:336-341splitCompoundCommandsubCommands.length > 1evaluateCompoundCommand。当切分器只返回一个 segment 时,evaluateSingle 会拿规则去匹配整条命令字符串,而 matchesCommandPattern 编译正则时带 s 标志(rule-parser.ts:1088),因此 Bash(echo *) 会端到端匹配上 echo x >\r& rm -rf /tmp/x,判定为 allow——正是被报告的绕过。这是修复前的行为,而修复之后没有测试陈述新的判定结果。所以只要有人重新合并 segment、在切分前先匹配未切分的命令、或在另外三个 splitCompoundCommand 调用点(permission-manager.ts:956:1112:1210)之一重新放宽分隔符集合,五个新的切分器测试仍然全绿,而用户的 Bash(echo *) allow 规则又会授权那条被后台化的 rm -rf

证据——已实际执行 A/B;基线一侧就是回退后的状态(if (/\s/.test(ch)) {),所以这是真正的变异运行,而不是对它的静态阅读:基线五个载荷全部 FAIL 且 evaluate=allow,PR 侧五个全部 PASS 且 evaluate=deny(原始输出见上方英文部分)。引号保护用例在两侧均为 allow,说明修复约束成立。

修复建议。:2328 旁边加一个用例,配置 permissionsAllow: ['Bash(echo *)']permissionsDeny: ['Bash(rm *)']command: 'echo x >\r& rm -rf /tmp/x',期望 'deny'。采用 deny 规则的写法而不是断言裸 'ask',可以让测试避开 resolveDefaultPermission 对被截断的首 segment echo x > 的 AST 判定。

注意这不能覆盖 R1-1:那个回归是在 Write(...) deny 下丢失虚拟写操作,且目标全为不可见字符,而 echo x >\u00a0& echo y 里根本没有 rm——实测在 Bash(rm *) deny 下 evaluate=allow,该 deny 规则完全不相干。要覆盖它需要单独的用例。

新用例不得靠"更激进地切分"来通过:permission-manager.test.ts:2359it('operators inside quotes: treated as single command', …))要求在 allow Bash(echo *)evaluate({ command: "echo 'a && b'" }) 保持 'allow'。上方 A/B 已在两侧验证该保护不受影响。

请用变异来确认验收标准:加好新的 evaluate() 用例后,把 rule-parser.ts:868if (BASH_WORD_SEPARATORS.includes(ch)) { 改回 if (/\s/.test(ch)) {,运行该用例,确认它变红——五个切分器层面的用例本来就会红,但新用例钉住的是判定结果,而不是中间数组。

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

const SHELL_OPERATORS = ['&&', '||', ';;', '|&', '|', ';', '&', '\n'];

/**
* The characters bash treats as word separators (its default `IFS`): space,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-6: This comment justifies the list by bash's default IFS, but the property isAsyncOperator actually depends on is the lexer's notion of whitespace — bash's whitespace(c) (space and tab) plus newline — which is what decides whether > and & are adjacent tokens. IFS governs field splitting of expansions after tokenization and has no effect on operator recognition, so on a security-critical list the stated rationale is the wrong rule.

Failure scenario. A maintainer who later needs to honour a shell started with a custom IFS (say IFS=$' \t\n:') reads "the characters bash treats as word separators (its default IFS)", concludes the list should track IFS, and appends ':'. Every added character widens the skip at rule-parser.ts:868, so echo x >:& rm -rf / stops splitting and the first command's allow rule again covers the second — silently reopening the exact fail-open this PR closes, with all five new regression tests still green because none of them exercises an added character.

Witness — real bash 5.x, confirming the tokenizer does not consult IFS while expansion field-splitting does:

$ printf "IFS=\$'\\r'\necho x >\\r& echo y\n" > s.sh   # literal CR byte, confirmed by od -c
$ bash s.sh ; ls -b
y            <- still two commands
\r s.sh      <- CR still an ordinary word character: the tokenizer ignores IFS
$ bash -c "IFS=':'; set -- a:b; echo words=\$#"  -> words=1   (literal words NOT split by IFS)
$ bash -c "IFS=':'; x=a:b; echo \$x"             -> a b       (expansion IS field-split by IFS)

grep -n IFS over the two changed files returns this comment (rule-parser.ts:819) plus the test at permission-manager.test.ts:659,662, so the framing appears only here.

Suggested fix. State the operative rule instead of IFS. The paragraph at :818-824 becomes something like:

/**
 * The characters bash's lexer treats as whitespace (space, tab and newline —
 * `whitespace(c)` in bash's `parse.y`). These coincide with bash's default
 * `IFS`, but `IFS` is not what matters here: operator adjacency is decided by
 * the lexer, so this list must not be widened to follow a custom `IFS` —
 * doing so re-merges `>\r&`-style payloads into one segment.
 */

Optionally rename the constant to BASH_LEXER_WHITESPACE, which makes the wrong mental model harder to reach for at the read site as well.

Worth noting in mitigation, since it bounds how much this costs: the accurate rationale ("which bash treats as ordinary word characters") already sits eight lines below at rule-parser.ts:857-861, and the test comment at permission-manager.test.ts:648-653 is accurate too. So this is the low end of Suggestion and asks for a wording change only — no behaviour.

中文说明

这段注释用 bash 的默认 IFS 来论证这个字符列表,但 isAsyncOperator 真正依赖的性质是词法分析器的空白定义——bash 的 whitespace(c)(空格与 tab)加上换行——决定 >& 是否为相邻 token 的正是它。IFS 管的是分词之后展开结果的字段切分,对操作符识别没有任何影响。因此在一个安全关键的列表上,这里给出的理由是错的规则。

失败场景。 后来若有维护者需要兼容以自定义 IFS 启动的 shell(例如 IFS=$' \t\n:'),他读到"bash 视为词分隔符的字符(即其默认 IFS)",就会认为这个列表应当跟随 IFS,于是加上 ':'。每多加一个字符都会放宽 rule-parser.ts:868 的跳过范围,于是 echo x >:& rm -rf / 不再被切分,第一条命令的 allow 规则又一次覆盖了第二条——本 PR 关闭的 fail-open 被悄悄重新打开,而五个新回归测试依然全绿,因为它们没有一个涉及新加的字符。

证据——真实 bash 5.x,确认分词器不查询 IFS,而展开的字段切分会查询(原始输出见上方英文部分):设置 IFS=$'\r'echo x >\r& echo y 仍然执行两条命令、仍然创建名为 CR 的文件;IFS=':'set -- a:b; echo $# 得到 1(字面词不被 IFS 切分),而 x=a:b; echo $x 得到 a b(展开确实被 IFS 字段切分)。对两个改动文件执行 grep -n IFS 只命中本注释(rule-parser.ts:819)与测试的 permission-manager.test.ts:659,662,说明这一表述只出现在这里。

修复建议。 直接陈述真正起作用的规则,而不是 IFS:818-824 这段可改为上方英文部分给出的写法(大意为:这些是 bash 词法分析器视为空白的字符,即 parse.y 中的 whitespace(c);它们与 bash 默认 IFS 重合,但此处起作用的不是 IFS——操作符相邻性由词法分析器决定,因此不得为跟随自定义 IFS 而放宽本列表,否则会把 >\r& 形式的载荷重新合并成一个 segment)。也可以把常量重命名为 BASH_LEXER_WHITESPACE,让读取点也更难产生错误的心智模型。

一点减轻情节的说明,用以界定其影响范围:准确的表述("bash 把它们当作普通词字符")已经写在下方八行处的 rule-parser.ts:857-861,测试注释 permission-manager.test.ts:648-653 也是准确的。因此这是 Suggestion 里最轻的一档,只要求改措辞,不涉及行为。

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

* separators — bash takes such a character as part of the neighbouring word
* instead.
*/
const BASH_WORD_SEPARATORS = [' ', '\t', '\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.

[Suggestion] R1-3: This constant is module-local, so it adds a second private definition of "bash word separator" while the twin backward scan keeps using JS \s. previousNonWhitespaceChar in packages/core/src/utils/shell-utils.ts:285-293 (if (ch && !/\s/.test(ch)) return ch;) feeds both operator branches of splitCommands (:376 for &, :384 for |), and checkCommandPermissions (shell-utils.ts:2086) additionally rewrites the character to a plain space via cmd.trim().replace(/\s+/g, ' ') before PermissionManager ever sees it. The two splitters now disagree on the same input and nothing tracks the divergence.

Failure scenario. Measured against the built dist at this commit: splitCommands('echo x >\r& rm -rf /tmp/x') returns one segment and getCommandRoots(...) returns ['echo'], so the rm is invisible to every splitCommands consumer — packages/core/src/tools/shell.ts:412, 680, 820, 862, 916, 2142, packages/core/src/utils/shellReadOnlyChecker.ts:349 (the whole-command read-only gate), getCommandRoots (shell-utils.ts:523, used at shell.ts:5333), packages/core/src/tools/monitor.ts:212, and packages/cli/src/serve/daemon-git-worktree-guard.ts:2552. Because the constant is not exported, the twin cannot pick the fix up, and the next person to grep for this bug finds only the fixed copy. The concrete cost is a silently divergent pair of shell splitters inside one permission subsystem — not a wrong verdict on the model-facing path, which this PR does fix.

Witness — A/B across the merge base 87437db784 and this commit, every line identical on both arms, which is what bounds this to a Suggestion:

BASE+PR splitCommands("echo x >\r& rm -rf /tmp/x") = ["echo x >\r& rm -rf /tmp/x"]   (twin unfixed)
BASE+PR getCommandRoots(...) = ["echo"]                                              (rm invisible)
BASE+PR normalize -> pm.isCommandAllowed("echo x > & rm -rf /tmp/x") = allow
BASE+PR isShellCommandReadOnly("echo x >\r& rm -rf /tmp/x") = false                  (read-only gate fails closed)
BASE+PR checkArgumentSafety("echo x >\r& rm -rf /tmp/x") isSafe=false
        patterns=["& background operator", "> output redirection"]                   ($ARGUMENTS route CLOSED)
git diff --stat 87437db784..47d93007ab -- packages/core/src/utils/shell-utils.ts \
    packages/core/src/tools/shell.ts packages/core/src/utils/shellReadOnlyChecker.ts \
    packages/cli/src/services/prompt-processors/shellProcessor.ts   -> empty

Three things that measurement establishes, and why they matter to severity. The model-supplied route is closed: shell-utils.ts:2455-2459 (if (/>\s|\d>/.test(args))) flags >\r because JS \s includes CR, so shellProcessor.ts:109-112 replaces the raw $ARGUMENTS with escapeShellArg(...). The legacy isCommandAllowed(command, config) export (shell-utils.ts:2388) has no production callers (tests only). And the main model-facing path is fixed by this PR — evaluate goes allow at base to deny here for echo x >\r& rm -rf /tmp/x, because tools/shell.ts:2158 hands the raw sub-command to pm.isCommandAllowed where the corrected splitter runs. All four files above are byte-identical between base and this commit, so nothing here is newly wrong or newly reachable.

Suggested fix. Do not copy the array a second time. Export one predicate or constant — export const BASH_WORD_SEPARATORS, or an isBashWordSeparator(ch) in a small shared leaf module — and have both scans use it (rule-parser.ts:868 and shell-utils.ts:288). The import direction is safe: rule-parser.ts does not currently import shell-utils.js (its imports are node:path/fs/os, picomatch, shell-quote, ../utils/debugLogger.js, ../utils/errors.js), while permission-manager.ts:23 already imports ../utils/shell-utils.js, so a shared leaf module avoids any cycle.

If touching shell-utils.ts is out of scope for this PR — the description defers that audit, and #11851's triage agrees — then file a tracked follow-up naming #11851's twin in splitCommands, so the divergence is on a record rather than living only in an issue comment. Either way, exporting the constant here costs nothing and is what lets the follow-up reuse it, which the description already anticipates for #11821.

A fix in shell-utils.ts must not break CRLF splitting: shell-utils.ts:391 (} else if (char === '\r' && nextChar === '\n') {) treats CRLF as a command separator, mirrored at :1857, and is pinned by getCommandRoots('grep pattern file\r\ncurl evil.com')['grep', 'curl'] (shell-utils.test.ts:632-635) and getCommandRoots('echo SAFE \\\r\nrm -rf /') (same file, :660). Treating a lone \r as a word character must not stop \r\n from splitting, and since previousNonWhitespaceChar also feeds the | branch at :384 (prevChar === '>' for >|), both call sites change together.

Please pin any such fix with a test that fails without it: in packages/core/src/utils/shell-utils.test.ts, beside the existing splitCommands tables (:1497) and the getCommandRoots block (:597), assert splitCommands('echo x >\r& rm -rf /tmp/x') yields two segments and getCommandRoots yields ['echo', 'rm'] — both red today — then restore /\s/ in previousNonWhitespaceChar and confirm they red again.

中文说明

这个常量是模块内私有的,因此它实际上新增了第二份"bash 词分隔符"的私有定义,而孪生的回扫仍在使用 JS 的 \spackages/core/src/utils/shell-utils.ts:285-293previousNonWhitespaceCharif (ch && !/\s/.test(ch)) return ch;)同时供给 splitCommands 的两个操作符分支(&:376|:384);此外 checkCommandPermissionsshell-utils.ts:2086)会在 PermissionManager 看到命令之前,通过 cmd.trim().replace(/\s+/g, ' ') 把该字符改写成普通空格。于是两个切分器对同一输入的判断不再一致,而没有任何机制跟踪这一分歧。

失败场景。 针对本提交已构建的 dist 实测:splitCommands('echo x >\r& rm -rf /tmp/x') 返回一个 segment,getCommandRoots(...) 返回 ['echo'],因此 rm 对每一个 splitCommands 消费者都不可见——packages/core/src/tools/shell.ts:412, 680, 820, 862, 916, 2142packages/core/src/utils/shellReadOnlyChecker.ts:349(整条命令的只读判定门)、getCommandRootsshell-utils.ts:523,在 shell.ts:5333 使用)、packages/core/src/tools/monitor.ts:212,以及 packages/cli/src/serve/daemon-git-worktree-guard.ts:2552。由于常量未导出,孪生扫描无法复用这次修复;下一个人来 grep 这个 bug 时只会找到已修好的那一份。具体代价是:同一个权限子系统内部存在一对静默分歧的 shell 切分器——而不是面向模型的主路径上出现错误判定,那条路径本 PR 确实修好了。

证据——针对合并基线 87437db784 与本提交做 A/B,每一行在两侧完全相同,这也正是把它限定为 Suggestion 的依据(原始输出见上方英文部分)。该实测确立了三件事,且都影响严重级别判定:模型可控载荷的路径是关闭的(shell-utils.ts:2455-2459if (/>\s|\d>/.test(args)) 会因为 JS \s 包含 CR 而标记 >\r,于是 shellProcessor.ts:109-112escapeShellArg(...) 替换原始 $ARGUMENTS);遗留导出 isCommandAllowed(command, config)shell-utils.ts:2388)没有生产调用方(仅测试);而面向模型的主路径确实被本 PR 修好——对 echo x >\r& rm -rf /tmp/xevaluate 从基线的 allow 变为这里的 deny,因为 tools/shell.ts:2158 把原始子命令交给 pm.isCommandAllowed,那里运行的正是修正后的切分器。上述四个文件在基线与本提交之间逐字节相同,所以这里没有任何"新变错"或"新可达"的成分。

修复建议。 不要把数组再复制一份。导出同一个谓词或常量——export const BASH_WORD_SEPARATORS,或在一个小的共享叶子模块里提供 isBashWordSeparator(ch)——让两处扫描都用它(rule-parser.ts:868shell-utils.ts:288)。导入方向是安全的:rule-parser.ts 当前并不导入 shell-utils.js(它的导入是 node:path/fs/ospicomatchshell-quote../utils/debugLogger.js../utils/errors.js),而 permission-manager.ts:23 已经导入了 ../utils/shell-utils.js,因此放在共享叶子模块可以避免任何循环依赖。

如果在本 PR 里改 shell-utils.ts 超出范围——PR 描述已把该排查延后,#11851 的 triage 也认同——那就开一个可跟踪的 follow-up,点名 splitCommands#11851 的孪生问题,让这一分歧进入正式记录,而不是只存在于某条 issue 评论中。无论哪种做法,在这里导出常量都没有成本,而且正是它让 follow-up 得以复用;PR 描述对 #11821 也已有同样的预期。

shell-utils.ts 里修复时不得破坏 CRLF 切分:shell-utils.ts:391} else if (char === '\r' && nextChar === '\n') {)把 CRLF 当作命令分隔符,:1857 有对应实现,并由 getCommandRoots('grep pattern file\r\ncurl evil.com')['grep', 'curl']shell-utils.test.ts:632-635)与 getCommandRoots('echo SAFE \\\r\nrm -rf /')(同文件 :660)钉住。把单独的 \r 视为词字符,不得导致 \r\n 不再切分;此外由于 previousNonWhitespaceChar 也供给 :384| 分支(>|prevChar === '>'),两个调用点必须一起改。

任何这类修复都请配一个"去掉修复就会失败"的测试:在 packages/core/src/utils/shell-utils.test.ts 中,紧挨现有的 splitCommands 表格(约 :1497)与 getCommandRoots 块(约 :597),断言 splitCommands('echo x >\r& rm -rf /tmp/x') 产出两个 segment、getCommandRoots 产出 ['echo', 'rm']——这两条今天都是红的——然后把 previousNonWhitespaceChar 里的 /\s/ 恢复回去,确认它们再次变红。

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

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

Verified the predicate change itself and I agree with the open R1-1 — plus one extension it does not mention (inline below).

The one-line change is correct and moves only in the tightening direction: the skip set strictly shrank (/\s/[' ', '\t', '\n']), so the backward scan can only stop earlier, and the only outcome flip is false → true — more splits, never fewer, and each new segment then gets its own rule check. &&, |&, &>, >&, <& still cannot reach the changed branch, and \> still reads as a literal argument through precedingBackslashCount. I also spot-checked R1-3/R1-4/R1-5/R1-6 — all grounded, nothing to dispute — and confirmed the sibling PRs do not collide with this one (#11821 inserts 9 lines below with no hunk overlap; #11765 reworks a different scan loop and composes either way).

The inline comment is the reason I am posting at all: the R1-1 class is slightly broader than the thread states.

for (let j = index - 1; j >= 0; j--) {
const ch = command[j]!;
if (/\s/.test(ch)) {
if (BASH_WORD_SEPARATORS.includes(ch)) {

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.

[extends R1-1] The same root cause also produces a wrong operation type, not just a lost deny, when the command consumes arguments.

Take cat >\u00a0& echo hi:

  • At this head the new boundary splits at &; the first segment's .trim() (:958/:969) strips the \u00a0, leaving cat >, and tokenize yields [cat, >].
  • In extractRedirects (packages/core/src/permissions/shell-semantics.ts:248-250), a separate-token > with no following target is never added to toRemove, so the bare > survives into cat's positional args.
  • cat dispatches to readOps (:513), and looksLikePath('>') is true (:195), so the operation mutates from the real write_file <cwd>/\u00a0 to a spurious read_file <cwd>/> — the invisible-file write is still uncertified, and now an invented read is what a Read allow could match.

The same fix should cover it (consume the dangling redirect, or keep the segment whole when its redirect has no in-segment target), but this shape is worth adding to the test matrix too: the failure is not only "no write op extracted" but "a wrong-typed op extracted".

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

Development

Successfully merging this pull request may close these issues.

security: isAsyncOperator treats \r/\v/\f/\u00a0 as bash word separators, so a Bash allow rule can cover a second command

4 participants