Skip to content

fix(core): read a backslash inside single quotes as literal when splitting - #11765

Open
TianYuan1024 wants to merge 13 commits into
QwenLM:mainfrom
TianYuan1024:fix/permission-single-quote-backslash-split
Open

TianYuan1024 wants to merge 13 commits into
QwenLM:mainfrom
TianYuan1024:fix/permission-single-quote-backslash-split

Conversation

@TianYuan1024

@TianYuan1024 TianYuan1024 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Fixes how splitCompoundCommandSegments tracks quotes, so permission rules see every command bash actually runs.

  • Inside plain '…' a backslash is literal, so 'a\' closes the string. Inside ANSI-C $'…' it still escapes. \$, $$ and "$" don't open ANSI-C, and $\'…' does.
  • The splitter doesn't model # comments, backtick bodies or heredocs, and quotes inside them can mislead the new reading. So the input is scanned under both the new and the pre-fix reading, and split wherever either finds an operator. Segmentation never loses a boundary main had.

Why it's needed

With only Bash(echo *) allowed, echo 'a\' ; rm -rf x was a single segment, so it was auto-approved and the rm ran without a prompt. A deny rule on the second command was never checked.

Reviewer Test Plan

How to verify

Allow Bash(echo *) and run echo 'a\' ; touch /tmp/qwen-poc. It should now ask; with deny: ["Bash(touch *)"] it should deny. bash -xc "echo 'a\' ; touch /tmp/qwen-poc" traces two commands.

cd packages/core && npx vitest run src/permissions/permission-manager.test.ts: 454 pass (428 on main, and main's test file also passes against this parser). The permissions, shell tool, monitor and shell-utils suites pass too (2316 tests). Each guard is pinned by a mutation that turns tests red:

mutation red
backslash always escapes 13
coarse !inSingle (no ANSI-C) 3
drop the pre-fix-reading scan 8
$$ opens ANSI-C 1
continuation drops the pending $ 1
continuation keeps the newline 2
merge without skipping overlaps 29

Evidence (Before & After)

PermissionManager, allow Bash(echo *) / then with deny Bash(touch *):

                                          before         after
echo 'a' ; touch /tmp/qwen-poc            ask / deny     ask / deny   (control)
echo 'a\' ; touch /tmp/qwen-poc           ALLOW / ALLOW  ask / deny
echo 'a\' && touch /tmp/qwen-poc          ALLOW / ALLOW  ask / deny
echo 'c\' $'a\'' ; touch /tmp/qwen-poc    ALLOW / ALLOW  ask / deny
echo "a\" ; touch /tmp/qwen-poc"          allow / allow  allow / allow (one command)

The comment, backtick and heredoc carriers (e.g. echo done # note 'a\''touch …) are ask/deny both before and after, and are pinned.

Tested on

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

Environment (optional)

macOS 26.5.2 (arm64), Node v22.22.1, GNU bash 3.2.57.

Risk & Scope

Linked Issues

Fixes #11764

中文说明

本 PR 做了什么

修正 splitCompoundCommandSegments 的引号跟踪,让权限规则能看到 bash 实际执行的每一条命令。

  • 普通 '…' 内反斜杠是字面字符,'a\' 是闭合的;ANSI-C $'…' 内仍是转义。\$$$"$" 不开启 ANSI-C,$\'…' 会开启。
  • 切分器不认识 # 注释、反引号和 heredoc,这些区域里的引号会误导新读法。所以按新旧两种读法各扫描一次,任一次找到分隔符就切分,切分点不会比 main 少。

为什么需要

只授权 Bash(echo *) 时,echo 'a\' ; rm -rf x 被当成一段,于是自动放行,rm 在没有确认提示的情况下就执行了;针对第二条命令的 deny 规则也不会被检查。

Reviewer Test Plan

授权 Bash(echo *) 后执行 echo 'a\' ; touch /tmp/qwen-poc,现在会弹出确认;加上 deny: ["Bash(touch *)"] 后会被拒绝。permission-manager.test.ts 454 个用例通过(main 上 428 个,main 的测试文件在新解析器上也能通过);permissions、shell 工具、monitor 与 shell-utils 相关测试共 2316 个通过。每处保护逻辑都有对应的变异测试,见上表。

风险与范围

…tting

A backslash is an ordinary character inside single quotes in bash, so a
command ending in one closes its string and an operator after it separates
two real commands. The compound-command splitter read that backslash as an
escape, consumed the closing quote, and stayed inside the quote to the end
of the input, returning the whole line as a single segment.

Permission rules are matched per segment, so the carrier command's allow
rule then covered whatever followed the operator: with only Bash(echo *)
allowed, `echo 'a\' ; rm -rf x` was auto-approved and the rm ran with no
prompt. An explicit deny rule was lost the same way, because the command it
names never became a segment to match against.

The escape now applies outside quotes and inside double quotes only, which
is what bash does.
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 13, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 13, 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 filing #11764 with the shell's own trace before writing any code. That made this straightforward to judge.

Template looks good ✓

Problem: observed, not theoretical. The issue carries a real reproduction and an A/B table where a single backslash is the only difference between ask and allow. I re-traced the scanner by hand against main and the failure mode is exactly as described: the backslash sets escaped, the closing ' is then consumed as an escaped character, inSingle never clears, every operator for the rest of the line is skipped, and the whole line comes back as one segment — so Bash(echo *) ends up covering whatever followed. It fails open, with no prompt and no warning, and the same shape defeats an explicit deny rule because the command it names never becomes a segment.

Direction: squarely in scope. Permission-rule matching is a trust boundary, and a saved allow rule that silently reaches past the command it names is about the worst failure mode this subsystem has. The reference CHANGELOG shows this is a recognised bug class upstream too — "Fixed a case where a Read or Edit deny rule did not apply when an env -C, eval or similar command the permission checker cannot analyze was on the same line" — so the direction is neither novel nor contested.

Size: 8 production lines (7 added / 1 removed), 58 test lines, 0 generated or schema. Of those 7 added production lines, 6 are the explanatory comment — the behavioural change is a single condition. It touches packages/core/src/**, so Stage 0 Tier 2 applies; it is a fix rather than a refactor and nowhere near the 500-line threshold, so no Tier 1 block and no large-PR advisory.

Approach: minimal, and I could not write a smaller one. Bash gives single quotes no escape mechanism at all — POSIX is explicit that they preserve the literal value of every character within them, and that a single quote cannot occur between single quotes even when preceded by a backslash — so the escape branch simply has to be off while inSingle is set. The strongest signal that this is right rather than merely plausible: every other quote-tracking scanner in this repo already carries exactly this && !inSingle guard, and this one function was the lone outlier. More detail in the code review below. No unrelated changes, no drive-by refactor, nothing to split out.

Risk: no elevated risk signals — the Stage 1e revert-correlated path check matched nothing. Worth saying plainly anyway that this is the permission decision path, so I reviewed it at full depth regardless: I named every consumer of the changed function and checked each one for whether more segments could ever produce a more permissive verdict. None can.

Moving on to code review. 🔍

中文说明

感谢这个 PR——也感谢你在写任何代码之前,先在 #11764 里附上了 shell 自身的执行追踪。这让判断变得很直接。

模板完整 ✓

问题: 是已观测到的 bug,不是理论性加固。issue 里有真实复现,以及一张 A/B 表格——唯一差别就是一个反斜杠,判定结果却从 ask 变成 allow。我对照 main 手工重新走了一遍扫描器,失效方式与描述完全一致:反斜杠置上 escaped,随后闭合的 ' 被当作转义字符吞掉,inSingle 永远不清零,该行余下的所有操作符都被跳过,于是整行只返回一个片段——Bash(echo *) 就覆盖了后面跟着的任何命令。它是失效即放行:没有确认提示,也没有警告;同样的形态还会击穿显式的 deny 规则,因为该规则指向的命令从未成为一个片段。

方向: 完全在范围内。权限规则匹配是一条信任边界,而一条已保存的授权规则悄悄越过它所指定的命令,是这个子系统里最糟的失效模式。参考 CHANGELOG 表明这在上游也是公认的 bug 类别——「修复了当同一行上存在 env -Ceval 等权限检查器无法分析的命令时,Read 或 Edit 的 deny 规则不生效的情况」——所以这个方向既不新颖,也没有争议。

规模: 生产代码 8 行(新增 7 / 删除 1),测试 58 行,生成或 schema 文件 0 行。新增的 7 行生产代码里有 6 行是解释性注释——真正的行为改动只有一个条件表达式。它触及 packages/core/src/**,因此适用 Stage 0 Tier 2;类型是 fix 而非 refactor,且远未达到 500 行阈值,所以不触发 Tier 1 硬性拦截,也不触发大 PR 提示。

方案: 最小化,我写不出更小的改动。bash 根本不为单引号提供转义机制——POSIX 明确说明单引号保留其中每个字符的字面值,且单引号之间不能再出现单引号,即便前面有反斜杠也不行——所以转义分支在 inSingle 为真时就必须关闭。证明这个改法是正确而不只是「看起来合理」的最强信号是:本仓库中其他每一个跟踪引号状态的扫描器都已经带着这个 && !inSingle 守卫,只有这一个函数是例外。细节见下方代码审查。没有无关改动,没有顺手重构,没有需要拆分的内容。

风险: 无升级风险信号——Stage 1e 的 revert 相关路径检查没有命中任何文件。不过还是要直说:这是权限判定路径,所以我无论如何都按最大深度审查了——我点名了被改函数的每一个调用方,并逐一检查「产生更多片段」是否可能得出更宽松的判定。答案是不可能。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

My independent proposal first. Reading only the title and the "why", before looking at the diff: bash has no escape mechanism inside single quotes, so a scanner that tracks quoting must disable its escape branch while inSingle is set — one added condition on the existing if (ch === '\\') test, plus regression tests for both the single-quote case and a double-quote over-correction guard. That is what the PR does. It matches my proposal exactly, and I did not find a simpler or safer path.

The fix is correct. Tracing echo 'a\' ; touch /tmp/x through splitCompoundCommandSegments:

  • before — the \ sets escaped; the closing ' is consumed by the if (escaped) branch instead of toggling inSingle; the scanner stays inside the quote to end-of-input; the ; is skipped; one segment, and Bash(echo *) covers the touch.
  • after — the \ falls through to the if (inSingle || inDouble) continue; guard and is treated as the literal character bash treats it as; the ' closes the string; the ; splits. Segments are echo 'a\' and touch /tmp/x.

The double-quote case still escapes, because inSingle is false there — echo "a\" ; touch /tmp/x" stays one segment, matching bash. SHELL_OPERATORS is ordered longest-match-first (&& before &, |& before |), so the && and | variants split on the right boundary.

It cannot break a valid command. This is the part that made me comfortable rather than merely unconvinced: because bash forbids a backslash-escaped single quote inside single quotes altogether, no well-formed bash command depends on the old reading. The familiar 'it\'s' spelling is not valid bash — it parses as it\ plus an unterminated quote. So the change strictly moves the splitter toward bash on every input, with no set of legitimate commands that regress. Behaviour only becomes more restrictive, as the PR claims.

Every consumer is monotonically safe. I named all of them and checked whether producing more segments could ever yield a more permissive verdict:

  • evaluateCompoundCommand (via evaluate) walks all segments and keeps the most restrictive of deny > ask > allow, short-circuiting on deny — more segments can only tighten it.
  • findMatchingDenyRule returns the first segment matching a deny rule — more segments means more chances to catch the deny, which is the point of the issue's second table.
  • hasRelevantRules and hasMatchingAskRule use .some() — more segments makes them more likely true, which hides "Always allow" more often. Cautious direction.
  • walkCompoundCommand in the shell-semantics extraction yields more ShellOperations, and the merge in evaluate lets a virtual-op verdict only escalate the bash verdict.

No consumer has an "any segment allowed → allow" shape, so there is no path where the extra segment gets waved through.

It also removes an inconsistency. Six other quote-tracking scanners in this repo already guard their escape branch on single-quote state — two in the shell-semantics module, three in the shell utilities, the read-only checker, and the shell tool's comment stripper. splitCompoundCommandSegments was the only one that did not, which is a reasonable explanation for how the bug existed at all. After this change the codebase is uniform on the rule.

Tests. Six new cases across four blocks: the three split shapes (;, &&, |), a double-quote over-correction guard, and the allow/deny decisions through PermissionManager. The over-correction guard is the right instinct and matches the existing style in that file, which already carries two "over-correction guard" tests from the earlier async-operator work. Placing them in permission-manager.test.ts follows the file's existing organisation — the whole splitCompoundCommand block already lives there. The comments explain why rather than restating the code, which is what AGENTS.md asks for.

No Critical blockers and no AGENTS.md violations. The scope declaration in the PR is honest about what it does not touch — command substitution inside an argument and environment assignments that change what an allowed program loads are separate problems, and this PR does not claim to fix them.

Not verified: I could not execute a shell probe to reproduce the bash -x trace myself — the attempt was denied by this environment's permission rules, so the "bash really runs these as two commands" claim rests on the POSIX single-quote rule and the trace in #11764 rather than on anything I ran. Relatedly, the author's Tested-on table is macOS ✅ with Windows and Linux ⚠️, so the behavioural claim rests on one platform and the author's own run.

CI test evidence

From the PR's own checks on the reviewed commit, fetched via the API — I did not build or run any PR code.

Nothing is red. The load-bearing check is still in flight: Test (ubuntu-latest, Node 22.x) is the job that runs the unit suite containing the six new cases, and it had not concluded when this pass was written, so I cannot cite a green unit suite for this change. Lint & Static and Integration Tests (no-AK, No Sandbox) are likewise still running. macOS and Windows unit jobs are skipped on this PR, so ubuntu is the only platform the suite will have covered. Reporting the state as it is rather than guessing the outcome.

Check Conclusion
Test (ubuntu-latest, Node 22.x) in progress
Lint & Static (ubuntu-latest, Node 22.x) in progress
Integration Tests (no-AK, No Sandbox) in progress
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
Classify PR success
precheck-pr / precheck (×2) success
authorize (×2), assign, label success
Test (macos-latest, Node 22.x) skipped
Test (windows-latest, Node 22.x) skipped
Integration Tests (CLI, No Sandbox) skipped
Bot orchestration jobs (review-pr, verify, tmux-testing, ack-review-request, …) skipped

Sandboxed verification would settle what CI cannot: @qwen-code /verify — whether the six new tests actually pin the fix is not observable from the diff, and a suite that passes identically with the && !inSingle guard removed is green and worthless. The author reports mutation-checking all five decision cases locally, which is the right method, but that is the author's claim and not independently re-run here. Since the author does not have write access, /verify would be a sponsored run: a maintainer's @qwen-code /verify comment approves the head it was written against, and that run carries a pre-execution risk screen plus a full workspace wipe before any PR code runs. @qwen-code /tmux is not available on this PR. Maintainers should read the resulting report with the same skepticism as the fork's own CI logs — the code under verification is adversarial input, and a crafted PR can shape what a report says even though the sandbox bounds what it can do.

中文说明

先说我自己的独立方案。 只看标题和「为什么需要」、还没看 diff 时,我的判断是:bash 在单引号内根本没有转义机制,所以跟踪引号状态的扫描器必须在 inSingle 为真时关闭它的转义分支——也就是在现有的 if (ch === '\\') 上加一个条件,再配上单引号场景和双引号「矫枉过正」守卫的回归测试。这正是本 PR 的做法,与我的方案完全一致,我也没有找到更简单或更安全的路径。

改法是正确的。echo 'a\' ; touch /tmp/x 走一遍 splitCompoundCommandSegments:改动前,\ 置上 escaped,闭合的 'if (escaped) 分支吃掉而没有翻转 inSingle,扫描器一直留在引号内直到输入结束,; 被跳过,只得到一个片段,Bash(echo *) 就覆盖了 touch。改动后,\ 落到 if (inSingle || inDouble) continue; 守卫上,被当作 bash 所认为的字面字符;' 正常闭合字符串;; 完成切分,得到 echo 'a\'touch /tmp/x 两个片段。

双引号场景仍然转义,因为那时 inSingle 为假——echo "a\" ; touch /tmp/x" 保持为一个片段,与 bash 一致。SHELL_OPERATORS 按最长匹配优先排序(&&& 之前,|&| 之前),所以 &&| 变体会在正确的边界切分。

它不可能破坏任何合法命令。 这一点让我不只是「没被说服」,而是真正放心:既然 bash 完全禁止在单引号内使用反斜杠转义的单引号,那么没有任何形式正确的 bash 命令依赖旧的读法。常见的 'it\'s' 写法并不是合法 bash——它会被解析成 it\ 加上一个未闭合的引号。所以这个改动在所有输入上都严格地把切分器推向 bash 的行为,不存在会退化的合法命令集合。行为只会变得更严格,与 PR 的声明一致。

每一个调用方都是单调安全的。 我点名了全部调用方,并检查「产生更多片段」是否可能得出更宽松的判定:

  • evaluateCompoundCommand(经由 evaluate)遍历所有片段,保留 deny > ask > allow 中最严格的那个,并在 deny 上短路——更多片段只会收紧结果。
  • findMatchingDenyRule 返回第一个命中 deny 规则的片段——片段更多意味着更容易抓到 deny,这正是 issue 第二张表格的要点。
  • hasRelevantRuleshasMatchingAskRule 使用 .some()——片段更多使其更可能为真,从而更频繁地隐藏「始终允许」。方向是更谨慎。
  • shell-semantics 抽取里的 walkCompoundCommand 产出更多 ShellOperation,而 evaluate 中的合并逻辑只允许 virtual-op 判定升级 bash 判定。

没有任何调用方是「任一片段被允许即允许」的形状,因此不存在多出来的那个片段被顺手放行的路径。

它还消除了一处不一致。 本仓库中另外六个跟踪引号状态的扫描器都已经在转义分支上带了单引号守卫——shell-semantics 模块两个、shell 工具函数三个、只读检查器一个、shell 工具的注释剥离器一个。splitCompoundCommandSegments 是唯一没有的,这也算是对「这个 bug 为什么会存在」的合理解释。改动之后,整个代码库在这条规则上是一致的。

测试。 四个代码块中新增六个用例:三种切分形态(;&&|)、一个双引号矫枉过正守卫,以及经由 PermissionManager 的 allow/deny 判定。矫枉过正守卫的思路是对的,也与该文件既有风格一致——那里已经有两个来自早前 async 操作符工作的「over-correction guard」测试。把它们放在 permission-manager.test.ts 符合该文件既有的组织方式:整个 splitCompoundCommand 代码块本来就在那里。注释解释的是为什么而不是复述代码,这正是 AGENTS.md 的要求。

无 Critical 阻塞项,无 AGENTS.md 违规。PR 对范围的声明是诚实的——参数中的命令替换、以及改变被授权程序加载行为的环境变量赋值,是另外的问题,本 PR 并未声称修复它们。

未验证: 我无法自己执行 shell 探针来复现那份 bash -x 追踪——该尝试被本环境的权限规则拒绝,因此「bash 确实把它们当作两条命令执行」这一论断依赖的是 POSIX 单引号规则和 #11764 中的追踪,而不是我实际跑出来的结果。与此相关,作者的测试平台表格是 macOS ✅、Windows 和 Linux ⚠️,所以行为论断只基于单一平台和作者自己的运行。

CI 测试证据(上方表格):通过 API 读取该 commit 上 PR 自己的检查结果,我没有构建或运行任何 PR 代码。没有红色检查。关键是承载性的那个检查仍在进行中:Test (ubuntu-latest, Node 22.x) 才是运行包含六个新用例的单测套件的 job,在本次审查写下时它尚未结束,所以我无法为这个改动引用一个绿色的单测结果。Lint & StaticIntegration Tests (no-AK, No Sandbox) 同样仍在运行。macOS 和 Windows 的单测 job 在本 PR 上是 skipped,因此 ubuntu 将是套件唯一覆盖到的平台。这里如实报告状态,而不是猜测结果。

沙箱验证可以解决 CI 解决不了的问题:@qwen-code /verify——六个新测试是否真的钉住了这个修复,从 diff 上是看不出来的;一个在移除 && !inSingle 守卫后仍然同样通过的套件,虽然是绿的,却毫无价值。作者报告说在本地对全部判定用例做了变异验证,方法是对的,但那是作者的说法,此处未经独立复跑。由于作者没有写权限,/verify 将是一次受赞助的运行:maintainer 的 @qwen-code /verify 评论会批准它所针对的那个 head,且该运行带有执行前风险筛查,以及在运行任何 PR 代码之前的完整工作区擦除。本 PR 无法使用 @qwen-code /tmux。maintainer 应当以与看待 fork 自身 CI 日志同样的怀疑态度阅读由此产生的报告——被验证的代码是对抗性输入,一个精心构造的 PR 可以影响报告什么,尽管沙箱限定了它能什么。

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — a one-condition fix to a fail-open permission bug, correct by construction against bash's quoting rules, with every consumer checked and no path to a more permissive verdict.

Stepping back: this is what a good security PR looks like. The author found a rule that silently authorised a command it never named, filed the issue with the shell's own trace proving two commands really run, changed one condition, and added a test that stops the fix from over-correcting in the double-quote direction. Six production lines of that change are a comment explaining the constraint; the behavioural edit is && !inSingle.

The thing that moved me from "looks right" to confident is that the fix cannot break a legitimate command. Bash forbids an escaped single quote inside single quotes outright, so there is no valid command whose correct segmentation depends on the old reading — the change is a strict move toward bash on every input. And the second thing: six sibling scanners in this repo already had this exact guard, so this reads less like a new design decision and more like closing an oversight in the one place that was missed. That is also the most likely explanation for how the bug shipped.

I checked the direction of every consequence rather than just the happy path. More segments flows into four call sites in the permission manager and one in the shell-semantics extraction; each takes the most restrictive verdict, the first matching deny, a .some() that only makes the UI more cautious, or an escalation-only merge. None can turn an extra segment into an approval. So the "behaviour only becomes more restrictive" claim in the description holds up against the code, not just against the author's reading of it.

If I were maintaining this in six months I would thank the author: the comment states the bash rule and the concrete failing input, so the guard will not get "simplified" away by someone who has not read the issue.

Two honest caveats, neither a reason to hold this back. The unit job that actually runs the six new cases was still in flight when I wrote this, so my confidence is in the reasoning and the hand-trace, not in a green suite I have seen — approval is therefore deferred rather than posted now. And the mutation-testing evidence that the tests pin the fix is the author's claim; /verify as a sponsored run is the way to make it independent, named in the review above. The scope the PR explicitly leaves alone — command substitution in an argument, environment assignments that change what an allowed program loads — is real and worth a follow-up issue, but it is out of scope here and the description says so rather than implying a complete fix.

Approval deferred until CI lands green on dd80fb998b40b55d79c6003373ed37807a2285f2. If the unit suite comes back green on that commit, this is ready to ship; if anything lands red or the head moves, the deferred approval is withheld and the status comment will say so.

中文说明

信心度:5/5 —— 用一个条件表达式修复了一个「失效即放行」的权限 bug;对照 bash 的引号规则,它在构造上就是正确的;每一个调用方都已核查,不存在得出更宽松判定的路径。

退一步看整体:这是一个优秀安全 PR 的样子。作者发现了一条会悄悄授权它从未指定的命令的规则,在 issue 里附上了 shell 自身的追踪以证明确实执行了两条命令,只改了一个条件,并补上一个防止修复在双引号方向上矫枉过正的测试。这次改动中的六行生产代码是解释约束的注释,真正的行为改动是 && !inSingle

让我从「看起来对」变成「有信心」的关键在于:这个修复不可能破坏任何合法命令。bash 完全禁止在单引号内出现被转义的单引号,因此不存在任何合法命令的正确切分依赖旧的读法——这个改动在所有输入上都是严格地向 bash 靠拢。第二点是:本仓库中另外六个扫描器早已带着这个完全相同的守卫,所以这与其说是一个新的设计决定,不如说是补上了唯一被漏掉的那一处。这大概也是这个 bug 当初为什么会被提交进去的最合理解释。

我核查的是每一个后果的方向,而不只是正常路径。更多片段会流入权限管理器的四个调用点和 shell-semantics 抽取的一个调用点;它们分别取最严格的判定、第一个命中的 deny、一个只会让 UI 更谨慎的 .some(),或者一个只允许升级的合并。没有任何一个能把多出来的片段变成放行。所以描述里那句「行为只会变得更严格」是站得住脚的——依据是代码本身,而不只是作者对它的解读。

如果六个月后由我来维护这段代码,我会感谢作者:注释写明了 bash 规则和那个具体的失败输入,因此没有读过 issue 的人不会把这个守卫当成多余的东西「简化」掉。

两点如实说明,都不构成压住这个 PR 的理由。真正运行那六个新用例的单测 job 在我写下这段时仍在进行中,所以我的信心来自推理和手工走查,而不是我亲眼看到的绿色套件——因此批准是延后的,而不是现在提交。另外,「测试确实钉住了这个修复」的变异验证证据是作者的说法;把它变成独立证据的方式是受赞助的 /verify 运行,已在上面的审查中点名。本 PR 明确不处理的那部分范围——参数中的命令替换、改变被授权程序加载行为的环境变量赋值——是真实存在的问题,值得开一个后续 issue,但它不在本次范围内,而且描述里是这样说的,并没有暗示这是一个完整的修复。

批准延后至 CI 在 dd80fb998b40b55d79c6003373ed37807a2285f2 上全绿。如果单测套件在该 commit 上返回绿色,这个 PR 就可以合入;如果有任何检查变红或 head 发生移动,延后的批准将被撤回,状态评论会说明原因。

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

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

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Critical: the !inSingle guard re-opens the same bypass through bash's $'…' ANSI-C quotes — a form that split correctly before this change

Where: packages/core/src/permissions/rule-parser.ts:886, at head dd80fb998b40b55d79c6003373ed37807a2285f2

if (ch === '\\' && !inSingle) {

First, the part that is right: the fix does exactly what it claims for plain single quotes. I reproduced the reported case against both arms and it is genuinely closed there — that is not in question.

The problem

Bash has two single-quote forms with opposite backslash semantics:

  • '…' — a backslash is an ordinary character (the case this PR fixes).
  • $'…' — ANSI-C quoting, where a backslash is an escape, including before the closing quote. So $'a\'' is the two-character word a', and it terminates there.

!inSingle applies plain-quote semantics to both. Inside $'…' the backslash is no longer an escape, so the scanner treats the escaped quote as the closing one, then reads the real closing quote as opening a new string — and stays inside it across the operator that bash really does split on.

Trigger

With permissionsAllow: ["Bash(echo *)"] and no deny rule:

echo $'a\'' ; rm -rf /tmp/x

&& and | behave identically. It is the PR's own carrier with one extra character.

Measured, not derived

I extracted splitCompoundCommandSegments from both the merge-base blob (9907978965de) and the head blob (da4500ba33d2), ran the two arms side by side, and used bash's own bash -x trace as the oracle:

input bash runs base head
echo 'a\' ; touch … (this PR's case) 2 cmds 1 — BYPASS 2 — fixed
echo $'a\'' ; touch … 2 cmds 2 — ok 1 — BYPASS
echo $'a\'' && touch … 2 cmds 2 — ok 1 — BYPASS
echo $'a\'' | cat 2 cmds 2 — ok 1 — BYPASS
echo $'a\\b' ; touch … 2 cmds 2 — ok 2 — ok
echo a ; touch … 2 cmds 2 — ok 2 — ok
echo 'a && b' 1 cmd 1 — ok 1 — ok
echo "a\" ; touch … syntax error n/a n/a

Counting only the dangerous direction (fewer segments than bash really runs): base = 1, head = 3. Bash's trace for the ANSI-C row is + echo 'a'\''' followed by + touch …, and the marker file is created, so the second command genuinely executes.

Why that reaches an auto-approval

The consumer chain, all read at head:

  1. permission-manager.ts:336splitCompoundCommand(command) returns 1 segment.
  2. permission-manager.ts:337if (subCommands.length > 1) is false, so evaluateCompoundCommand's per-segment most-restrictive evaluation is skipped and evaluateSingle(ctx) runs against the whole line.
  3. matchesRule (rule-parser.ts:1593) calls matchesCommandPattern('echo *', <whole line>) — I executed it: true. matchesCommandPattern('rm *', <whole line>): false.
  4. The cross-command virtual-op pass at permission-manager.ts:325 does not rescue it: extractShellOperationsAcrossCommand yields Read/Edit/Write/WebFetch virtual ops, so for a Bash(...)-only config it returns default; :363-368 requires !== 'default' to escalate, and :369 returns bashDecisionallow.

Result: auto-approved, no confirmation prompt, and the rm runs. That is precisely the impact the PR description states for the bug it fixes.

The per-segment deny pass at permission-manager.ts:956-964 is gated on the same length > 1, so an explicit deny: ["Bash(rm *)"] is lost the same way. Your two new PermissionManager tests assert exactly ask and deny for the plain-quote carrier; substituting $'a\'' for 'a\' in either one yields allow at this head.

Why I am calling this Critical rather than a suggestion

I ran the base arm, so this is a delta and not a pre-existing hole the PR merely fails to close: at the merge base this input split into 2 segments and was handled correctly. The diff introduces the bypass. It is a regression on a security boundary, in the same class the diff claims to close, and the net count moves the wrong way (1 → 3).

Nothing in the suite pins it either — grep -c "\$'" packages/core/src/permissions/permission-manager.test.ts returns 0 at head, and the diff adds no ANSI-C case, so all five new tests pass over it.

This contradicts the triage bot's approve-on-green at the same head — the partition

qwen-code-ci-bot stage 2 (5652164414, 08:16:44Z) and stage 3 (5652165969, 08:17:06Z) both reviewed dd80fb998b40b55d79c6003373ed37807a2285f2 — the identical commit — and concluded "No Critical blockers" at confidence 5/5, with an approve-on-green marker on that sha. Two of its load-bearing claims are what this report disputes, and I want to be explicit about which parts of its review survive, because most of it does.

Void — the premise is true of one quote form and was applied to both. Stage 2: "because bash forbids a backslash-escaped single quote inside single quotes altogether, no well-formed bash command depends on the old reading"; stage 3: "there is no valid command whose correct segmentation depends on the old reading — the change is a strict move toward bash on every input." Bash forbids \' inside plain '…'. It does not forbid it inside $'…', which is a different construct — ANSI-C quoting — where the backslash is an escape. $'a\'' is a well-formed word, and its correct segmentation does depend on the old reading. That is the whole finding.

Void — the direction of the consequence. Stage 2 audited every consumer and concluded "no path where the extra segment gets waved through"; stage 3, "None can turn an extra segment into an approval." That audit is correct and I do not dispute it. But it only covers segments the diff adds. On the ANSI-C form the diff removes one (base 2 → head 1), which is the direction that reaches evaluateSingle on the whole line. A monotonicity argument over "more segments" is silent about "fewer".

Unaffected — everything else. The consumer enumeration itself, the SHELL_OPERATORS longest-match-first ordering, the double-quote over-correction guard, the observation that six sibling scanners already carry this guard, and the test-placement reasoning all stand and are not touched by this report.

Why the gap is explicable rather than careless. Stage 2 discloses it directly: "I could not execute a shell probe to reproduce the bash -x trace myself — the attempt was denied by this environment's permission rules, so the 'bash really runs these as two commands' claim rests on the POSIX single-quote rule." Reasoning from the POSIX single-quote rule is exactly what yields the premise above; the ANSI-C form is only visible by running the shell. Every number in my table was executed, not derived — including the three-arm run of the proposed fix.

The practical consequence is time-sensitive: Test (ubuntu-latest, Node 22.x) was still in flight at both reads, and stage 3 states the deferred approval posts automatically once that lane greens on this sha. A green suite will not detect this — grep -c "\$'" permission-manager.test.ts is 0 at head, so all six new cases pass over the bypass.

Fix direction (verified)

!inSingle is too coarse; the scanner needs to know which quote opened the string. Record whether the opening quote was preceded by $, and suppress backslash escaping only for the plain form:

let inAnsiC = false;
// …
if (ch === '\\' && !(inSingle && !inAnsiC)) {
  escaped = true;
  continue;
}
if (ch === "'" && !inDouble) {
  inAnsiC = !inSingle ? command[i - 1] === '$' : false;
  inSingle = !inSingle;
  continue;
}

I ran this variant over the same table: 0 under-splits across all 8 executable cases, including the three ANSI-C rows and this PR's own reported case, with the five controls unchanged. $"…" needs no separate handling — bash gives it double-quote backslash semantics, which the existing inDouble path already matches.

Please also extend the it.each table, since those rows are what make the fix durable:

["echo $'a\\'' ; touch /tmp/x",  ["echo $'a\\''", 'touch /tmp/x']],
["echo $'a\\'' && touch /tmp/x", ["echo $'a\\''", 'touch /tmp/x']],

plus the two PermissionManager cases with $'a\'' as the carrier, expecting ask and deny.


Scope note: this is the only finding. Everything else in the diff I checked held up — the change is confined to the one guard, the base/head blobs differ nowhere else, and the double-quoted regression test correctly pins the form that must not be split. As of the repository state read immediately before posting, this comment carries no approval, and it is filed as CHANGES_REQUESTED in deliberate opposition to the bot's deferred approve-on-green on the same sha — a maintainer should decide between the two, and the executable evidence above is what decides it.

The previous commit suppressed the backslash escape for the whole of an open
single-quoted string. That is right for a plain string, where a backslash is
an ordinary character, but wrong for bash's ANSI-C form: inside $'…' the
backslash does escape, including before the closing quote, so $'a\'' is a
complete word and an operator after it still separates two commands. Reading
it as plain swallowed the real closing quote and glued the line back into one
segment — the same fail-open the previous commit closed, reached from the
other side, and a regression against the merge base rather than a gap it
merely left open.

The scanner now records which form opened the string and suppresses the
escape only for the plain one. A `$` opens the ANSI-C form only when it is
itself unquoted, unescaped and not already spent as the second half of `$$`,
so `\$'a\'`, `$$'a\'` and `"$"'a\'` stay plain, as bash reads them; treating
those as ANSI-C would reintroduce the original bypass through a different
door.

Eight cases pin the behaviour across the three directions, each verified
against `bash -x` as the oracle and each red under the corresponding mutant.
@TianYuan1024

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in a122d7a0b4. You are right on every point, including that it is a regression introduced by this PR rather than a pre-existing hole — I reproduced both arms before touching anything.

Reproduced. With allow: ["Bash(echo *)"], at the previous head echo $'a\'' ; rm -rf /tmp/x split into 1 segment and evaluated allow; at the merge base it split into 2 and evaluated ask. Adding deny: ["Bash(rm *)"] gave allow at head and deny at base. bash -xc traces + echo 'a'\''' then + touch … and the marker file is created, exactly as you reported. Base = 1 dangerous under-split, previous head = 3.

One correction to the proposed fix, which I did not adopt as written. command[i - 1] === '$' asks whether the preceding character is a dollar, but not whether that dollar is available to open an ANSI-C string. Bash reads all three of these as plain '…', so the backslash stays literal and the quote closes:

echo \$'a\' ; echo B      # escaped $ — literal dollar, plain quote
echo $$'a\' ; echo B      # $$ is the PID expansion; both characters are spent
echo "$"'a\' ; echo B     # the $ is inside double quotes

I ran your variant over these: the first two come back as 1 segment against bash's 2 — an under-split, in the same direction and with the same consequence as the finding you filed. The third is already safe because the inDouble branch consumes that $ before the check.

So the shipped version tracks the introducer instead of peeking at the previous character: a $ sets a pending flag only when it is unquoted, unescaped, and not already spent as the second half of $$; the flag is cleared by every other character and consumed by an opening quote. $'…' then escapes and plain '…' does not, on all of the above.

Measured against bash as the oracle. A 19-line corpus — your three ANSI-C rows, this PR's own case, the three $-disambiguation rows above, $'a\\b', $'a\nb', x$'a\'', 'a'\''b', and the controls — run through bash -x for the real top-level command count and through the splitter for the segment count:

arm under-splits (dangerous) over-splits
merge base 1 0
previous head (!inSingle) 3 0
proposed variant 2 0
shipped 0 0

Tests. Eight new cases in three groups — ANSI-C escapes, the $-disambiguation trio, and two PermissionManager cases with $'a\'' as the carrier expecting ask and deny. Each group is pinned by its own mutant: reverting to !inSingle reds the 5 ANSI-C cases, the command[i - 1] === '$' variant reds the \$ and $$ cases, and dropping the $$ pairing reds the $$ case. grep -c "\$'" permission-manager.test.ts is now 8. Full run: 1495 tests green across permissions, shell, monitor and shell-utils; typecheck, eslint and prettier clean.

On the partition with the bot's approve-on-green: your reading of it is the correct one. Its premise — that bash forbids \' inside single quotes, so no well-formed command depends on the old reading — is true only of the plain form, and I built the change on that same premise, which is why the ANSI-C form never entered my corpus. bash -c "echo 'a\''" is indeed a syntax error; bash -c "echo \$'a\''" is not. Thank you for running the shell instead of reasoning from the rule.

The differential run against bash turned up shapes the existing cases do not
reach: a plain and an ANSI-C string side by side in either order, `$$$'…'`
where the PID expansion is followed by a real ANSI-C string, and `$'\''`
whose entire body is an escaped quote. bash runs each of these as two
commands; three of the four go red when the escape is suppressed for every
single-quoted string, so they pin the same guard the ANSI-C cases do from a
different direction.

No production change — the split already handled all four.
@TianYuan1024

Copy link
Copy Markdown
Contributor Author

The red Test (ubuntu-latest, Node 22.x) lane is #10490, not this change. Everything else on this run is green: Lint & Static, Integration Tests (no-AK), both Desktop Shell lanes, web-shell E2E Smoke and Classify PR.

Two runs of this PR failed on two disjoint tests — packages/cli/src/ui/use-box-metrics-loop-guard.test.tsx in the first, packages/channels/feishu/src/adapter.test.ts > FeishuChannel > dispatches both media and ordinary text in the second — which is exactly the pattern #10490 records.

Three things rule out this change as the cause of the current failure:

  • @qwen-code/channel-feishu depends only on @larksuiteoapi/node-sdk and @qwen-code/channel-base. It does not depend on @qwen-code/qwen-code-core, and this PR touches only packages/core/src/permissions/. There is no path from the change to that test.
  • The same test fails on pristine origin/main (faa395885e), three runs out of three, in the same environment with this PR's two files absent — same assertion, expected "spy" to be called 1 times, but got 0 times.
  • On main's own CI the file passes: ✓ src/adapter.test.ts (168 tests) 3279ms.

The failing assertion is a vi.waitFor poll, which carries its own 1000 ms default independent of testTimeout; the 3026 ms in the log is three retries of that. I have added the runner-level detail to #10490 — in short, this fork PR ran on a GitHub-hosted runner where the repo's ecs-qwen--gated timeout bump does not apply, and where the suite measured about twice as slow as on the self-hosted pool.

A re-run should clear the lane. The branch is current with main as of faa395885e, and the change set is unchanged at two files.

中文

红着的 Test (ubuntu-latest, Node 22.x) 属于 #10490,与本改动无关。本次运行其余全绿:Lint & StaticIntegration Tests (no-AK)、两条 Desktop Shellweb-shell E2E SmokeClassify PR

本 PR 的两次运行失败在两个互不相干的测试上 —— 第一次是 packages/cli/src/ui/use-box-metrics-loop-guard.test.tsx,第二次是 packages/channels/feishu/src/adapter.test.ts > FeishuChannel > dispatches both media and ordinary text —— 正是 #10490 记录的模式。

三条证据排除本改动:

  • @qwen-code/channel-feishu 的依赖只有 @larksuiteoapi/node-sdk@qwen-code/channel-base,并不依赖 @qwen-code/qwen-code-core;而本 PR 只改动 packages/core/src/permissions/。依赖图上不存在从本改动到该测试的路径。
  • 同一个测试在纯净的 origin/mainfaa395885e)上、同一环境、本 PR 的两个文件不在场的情况下,三次运行三次失败,断言逐字相同。
  • 该文件在 main 自己的 CI 上是通过的:✓ src/adapter.test.ts (168 tests) 3279ms

失败的断言是一个 vi.waitFor 轮询,它有独立于 testTimeout 的 1000 毫秒默认超时;日志里的 3026 毫秒正是重试三次的结果。runner 层面的细节我补在 #10490 了 —— 简单说,这个 fork PR 跑在 GitHub 托管 runner 上,仓库中按 ecs-qwen- 门控的超时放宽对它不生效,而该 suite 在这类机器上实测比自托管池慢约一倍。

重跑一次应当就能转绿。分支已跟进到 faa395885emain,改动集仍是两个文件。

@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 1b": running the repo's own packages/core vitest ( permission-manager.test.ts ) — this worktree has no packages/core/dist , so the globalSetup guard stopped the r…; "agent 5": a full packages/core suite run in this worktree reports 11 failing files / 77 failing tests (the one visible failure is an atomicWriteFile secrets-path asse….

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

中文说明

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

未探索到全部深度(达到工具调用预算):"agent 1b"running the repo's own packages/core vitest ( permission-manager.test.ts ) — this worktree has no packages/core/dist , so the globalSetup guard stopped the r…"agent 5"a full packages/core suite run in this worktree reports 11 failing files / 77 failing tests (the one visible failure is an atomicWriteFile secrets-path asse…

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

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

Comment thread packages/core/src/permissions/rule-parser.ts
Comment thread packages/core/src/permissions/rule-parser.ts
Comment thread packages/core/src/permissions/rule-parser.ts Outdated
Comment thread packages/core/src/permissions/rule-parser.ts
Comment thread packages/core/src/permissions/permission-manager.test.ts
A `$` separated from its opening quote by a backslash-newline lost its
pending state, so `$\<newline>'a\''` was read as a plain `'…'` string.
The plain reading then swallowed the real closing quote and held the
scanner inside the string to the end of input, returning one segment for
a line bash runs as two commands — the bypass the surrounding exception
exists to close, re-entered through a continuation.

bash removes a backslash-newline before it decides what `$'` means, so
treat the pair as elided and carry the pending `$` across it. The newline
is consumed with the backslash because `'\n'` is itself a command
separator here; leaving it to the `escaped` flag would split every
`echo a\<newline>b` continuation instead.

Also cover the separators and shapes the earlier cases did not reach: a
bare `&` and a newline as the separator for both quote forms, the line
continuation that must not split, and the re-opened quote
`echo 'a\'' ; rm x'`, which bash runs as a single command and which the
corrected scanner keeps in one segment.

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

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

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

Comment thread packages/core/src/permissions/rule-parser.ts Outdated
Comment thread packages/core/src/permissions/rule-parser.ts Outdated
Comment thread packages/core/src/permissions/permission-manager.test.ts
Two comments stated the mechanism wrongly, in the direction that invites
someone to undo the fix.

The line-continuation branch was justified by `'\n'` being a command
separator, as though leaving the pair to the `escaped` flag would split
the line. It would not: the `escaped` check precedes the operator loop,
so that route consumes the newline too, and deleting the branch keeps
`echo a\<newline>b` whole. What the branch is actually for is the
top-of-loop `dollarPending` reset, which the `escaped` route runs through
and which drops the pending `$`.

The ANSI-C comment put the closing quote one position early: `$'a\''`
holds three quotes and closes at the third, the second being the escaped
one that belongs to the string. Read literally, the old wording is the
blanket-escape reading that glues the line back into one segment.

Also pin the even backslash run. The introducer rule turns on the parity
of the run before the `$` — `\$'` is a plain string, `\\$'` is ANSI-C —
and only the odd side was covered, so a refactor reading an even run as
escaping the `$` would reopen the bypass with the suite green.

@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 — "Integration Tests (CLI, No Sandbox)" and the macos-latest / windows-latest test-matrix lanes were skipped in CI at this commit and their suites did not run locally.

Not explored to full depth (tool budget reached): "agent 6b": did not execute the pre-fix scanner to measure walkCompoundCommand attribution before/after; the pre-fix one-segment answer is inferred from the diff's remove…; "agent 6b": did not typecheck packages/core ( npm run typecheck ); only eslint on the two changed files plus their two vitest suites were run.; "agent reverse-audit (round 1)": base-vs-head differential sweeps ran to payload length 3 for the operator and expansion alphabets; the length 4–6 sweeps were head-only (they compared HEAD agai…; "agent reverse-audit (round 1)": the backtick and heredoc merges were measured with a trailing echo MARK rather than a destructive payload; exploitability follows from the merged segment matc…; "agent reverse-audit (round 2)": base-commit scanner behaviour for the heredoc carrier was derived from the diff's removed if (ch === '\\') { line and its added test comment, not executed aga…, and 3 more.

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

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

  • packages/core/src/permissions/rule-parser.ts:900 — [review] the ANSI-C half of the quote rule is in only one of the two command splitters — splitCommands still merges echo $'a\'' ; rm -rf /tmp/x
  • packages/core/src/permissions/permission-manager.test.ts:569 — [review] no test pins the cwd/resolved-path attribution walkCompoundCommand derives from the segmentation this diff changes

Convergence: round 3 posted 2 inline comment(s), 1 of them reported for the first time; the previous round posted 4 (3 new). Findings keep coming back to the same files: packages/core/src/permissions/rule-parser.ts (findings in rounds 1, 2; 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.)

中文说明

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

未审查(原文为英文):build-and-test — "Integration Tests (CLI, No Sandbox)" and the macos-latest / windows-latest test-matrix lanes were skipped in CI at this commit and their suites did not run locally.

未探索到全部深度(达到工具调用预算):"agent 6b"did not execute the pre-fix scanner to measure walkCompoundCommand attribution before/after; the pre-fix one-segment answer is inferred from the diff's remove…"agent 6b"did not typecheck packages/core ( npm run typecheck ); only eslint on the two changed files plus their two vitest suites were run."agent reverse-audit (round 1)"base-vs-head differential sweeps ran to payload length 3 for the operator and expansion alphabets; the length 4–6 sweeps were head-only (they compared HEAD agai…"agent reverse-audit (round 1)"the backtick and heredoc merges were measured with a trailing echo MARK rather than a destructive payload; exploitability follows from the merged segment matc…"agent reverse-audit (round 2)"base-commit scanner behaviour for the heredoc carrier was derived from the diff's removed if (ch === '\\') { line and its added test comment, not executed aga…,另有 3 条。

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

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

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

— deepseek-v4.1-flash via Qwen Code /review (v0.23.3)

Comment thread packages/core/src/permissions/rule-parser.ts Outdated

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

Re-derived the scanner's behaviour for the changed states against bash, class by class. No blocking issues found, and the test-mutation table in the PR body (13/14/4/5/16/3 red per reverted guard) matches what the code does.

  • Plain '…': backslash literal — echo 'a\' ; rm x splits at the ;. ANSI-C $'…': backslash escapes — $'a\'' only closes at its third quote. The !(inSingle && !inAnsiC) condition separates exactly these two, and nothing else.
  • dollarPending: $$ spends both $ (second $ clears the pending flag via dollarPending = !ansiCIntroducer), \$ is escaped before the flag is read, "$" never reaches the flag because the inSingle || inDouble continue runs first — all three spellings fall back to a plain string, matching bash. $$$ correctly becomes PID expansion + ANSI-C string.
  • Line continuation: consuming \⏎ in the escape branch and re-arming dollarPending is what keeps $\⏎'…' ANSI-C; routing it through escaped would reset the flag at the top of the next iteration and mis-read the string as plain — the comment at the branch states this and the code matches. Inside a plain '…' the !(inSingle && !inAnsiC) guard keeps \⏎ literal, which is also bash.
  • Re-opened quote: echo 'a\'' ; rm x' correctly produces one segment — the fix changes segmentation in the other direction here and the test pins it, so a later edit that re-splits this line gets a visible failure rather than a silent spurious prompt.

Heads-up, coordination only: there are sibling splitter PRs open on this same function — #11821 (word-initial # as comment, where I have an open finding about <(/>( process-substitution bodies) and #11865 (draft, isAsyncOperator separators). Your change is in the quote/escape region, theirs in comment/separator regions, but they will collide in rule-parser.ts and whoever lands second should re-run both suites against the merged scanner; my 11821 finding is about a separate guard that does not exist in this PR, so nothing here needs changing for it.

…litting

The plain-quote rule reads a backslash inside '…' as literal, which is
right for a real quoted string but wrong in the regions this scanner does
not model: a # comment, a backtick body and a heredoc body, where bash does
not read the quote characters as quotes at all. There `'a\''` closed at its
second quote, re-opened a string at the third and swallowed the next
command into an allow-covered segment, so `echo done # note 'a\''` followed
by a newline and `rm -rf x` was auto-approved where main asked, and an
explicit deny rule was never reached.

Scan the input under both readings and split wherever either finds an
operator, so the plain-quote fix never removes a boundary the scanner found
before it. Pin the three carriers, and put the plain `'c\'` first in the
ANSI-C, even-run and continuation rows so only bash's reading can split
them and the coarse `!inSingle` guard still turns them red.
Fold the per-case splitter tests and the per-carrier PermissionManager
tests into it.each tables and shorten the explanatory comments. Coverage
is unchanged: every guard mutation still turns at least one case red.

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

Reviewed. Suggestions are inline.

Not explored to full depth (tool budget reached): "agent 1c": none — the walk completed within budget; no check was cut short..

Convergence: round 4 posted 4 inline comment(s), 3 of them reported for the first time; the previous round posted 2 (1 new). Findings keep coming back to the same files: packages/core/src/permissions/rule-parser.ts (findings in rounds 1, 3; 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. No Critical finding is open on this round, so merging and moving the remaining Suggestion threads to a follow-up issue is available as an ending — a merged pull request cannot diverge further. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

已审查。 建议见行内评论。

未探索到全部深度(达到工具调用预算):"agent 1c"none — the walk completed within budget; no check was cut short.

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

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

* See {@link splitCompoundCommand} for the string-only form and for examples;
* this is the same split, and that function is a projection of this one.
*
* Scanned twice and split wherever either scan finds an operator: comments,

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: The two-scan union also splits where main found no boundary at all — inside a # comment, a backtick body or a heredoc body — so a command main allowed can now come back as a bare deny that names no sub-command. Neither this doc comment nor any test records that direction.

The sentence above this line explains the union by the direction where it recovers a boundary: quotes inside an unmodelled region "can fool bash's backslash reading where the pre-fix reading still splits". The union runs the other way too. With allow ['Bash(cat *)','Bash(echo *)','Bash(git *)'] and deny ['Bash(rm *)'], the command git commit -m 'x' # saved to 'C:\' ; rm draft was 1 segment and allow at the merge base and is 2 segments and deny here — bash runs only the git commit, since everything after # is a comment, and the split-off rm draft segment now matches the deny rule. evaluateCompoundCommand returns the bare literal 'deny' (permission-manager.ts:592-596) naming no sub-command, and deny is not YOLO-overridable (permission-manager.ts:605-611), so a user whose commit message mentions a Windows path gets a refusal nothing in the message explains. The same shape reaches a quoted heredoc: cat > fixture.sh <<'EOF' / echo 'a\' ; rm -rf /tmp/x / EOF goes from 2 segments (allow) at the merge base to 4 (deny) here, one of which is exactly rm -rf /tmp/x, for a command where bash runs only cat with the body as literal stdin because <<'EOF' is quoted. Writing shell fixtures through a heredoc is an ordinary agent action.

The trigger is narrow — it needs a backslash inside single quotes within an unmodelled region, and apostrophe-only heredoc bodies do not move — and the direction is fail-closed, so the cost is blocked work rather than exposure. What ships unnamed is the class itself: the Risk & Scope sentence "Some lines bash runs as one command stay split … That is fail-closed and matches main" does not hold for these two inputs, and the test comment "The last row is one command to bash but stays split, as on main" is scoped to a row where it is true (echo 'a\'' ; rm x' splits identically on both arms), so nothing in the repo says the union can add a boundary main never found.

Witness:

two-arm A/B at the reviewed commit (base blob 238060f0b30a vs head d58ce05780cd;
permission-manager.ts and shell-semantics.ts byte-identical between arms, so the
difference is the splitter alone)
  "git commit -m 'x' # saved to 'C:\' ; rm draft"
    BASE n=1 verdict=allow   HEAD n=2 verdict=deny
    HEAD segments ["git commit -m 'x' # saved to 'C:\'", "rm draft"]
  "cat > fixture.sh <<'EOF'\necho 'a\' ; rm -rf /tmp/x\nEOF"
    BASE n=2 verdict=allow   HEAD n=4 verdict=deny
bash as oracle:
  bash -c "...git commit -m 'x' # saved to 'C:\' ; echo MARKER-SECOND-RAN" -> marker absent
  quoted heredoc -> body written literally into fixture.sh; MARKER-HEREDOC-BODY-RAN absent
sizing (trigger needs a backslash inside single quotes in an unmodelled region):
  "cat > deploy.sh <<'EOF'\n# don't forget...\nrm -rf /tmp/cache ; echo done\nEOF" -> ask on BOTH arms
controls unchanged: echo "it's fine" && rm -rf /tmp/x -> deny on both arms

Extend the comment at rule-parser.ts:887-889 to name both directions, and correct the Risk & Scope sentence:

 * Scanned twice and split wherever either scan finds an operator: comments,
 * backtick bodies and heredocs are not modelled, and quotes inside them can
 * fool the 'bash' reading where the 'escape-everywhere' reading still splits.
 * The union can also split where bash executes nothing - a backslash inside
 * single quotes within one of those regions - so a command bash runs as one
 * can newly ask or deny. Deliberate and fail-closed, but not what main did.

A comment-stripping alternative must not lose the boundary the retained rows pin: "echo done # note 'a\''\nrm -rf /tmp/x" must still split to ["echo done # note 'a\''", 'rm -rf /tmp/x'] and stay 'deny' under ['Bash(rm *)'], because bash really does run that rm and dropping everything after # would turn a correct deny into an allow. shell-semantics.ts:2266 already splits stripHeredocBodies(command), and stripHeredocBodies is module-private (shell-semantics.ts:2180) with shell-semantics.ts:38 importing from ./rule-parser.js, so a heredoc fix must not pull that helper into this file without inverting that import.

Please pin the tradeoff with a row asserting splitCompoundCommand("git commit -m 'x' # saved to 'C:\\' ; rm draft") equals ["git commit -m 'x' # saved to 'C:\\'", 'rm draft'], plus a %j with deny %j is %s row asserting 'deny' for it under allow ['Bash(git *)'] / deny ['Bash(rm *)'] — then drop the plain-quote reading and confirm both go red, so the rows pin the tradeoff rather than today's output.

中文说明

两次扫描的并集也会在 main 根本找不到边界的地方切分——即 # 注释、反引号体或 heredoc 体内部——于是 main 本来放行的命令,现在可能得到一个不指明子命令的 deny。这条文档注释和测试都没有记录这个方向。

上面那句话是用「并集找回边界」的方向来解释的:未被建模区域里的引号「会误导 bash 的反斜杠读法,而修正前的读法仍会在此切分」。但并集也会朝另一个方向走。在 allow ['Bash(cat *)','Bash(echo *)','Bash(git *)']、deny ['Bash(rm *)'] 下,命令 git commit -m 'x' # saved to 'C:\' ; rm draft 在合并基线是 1 个片段、判定 allow,在这里是 2 个片段、判定 deny——bash 只执行那个 git commit# 之后全是注释),而被切出来的 rm draft 片段现在命中了 deny 规则。evaluateCompoundCommand 返回裸的字面量 'deny'(permission-manager.ts:592-596),不指明任何子命令,而 deny 无法被 YOLO 覆盖(permission-manager.ts:605-611),所以提交信息里提到 Windows 路径的用户会得到一个消息本身无法解释的拒绝。同样的形态也出现在带引号的 heredoc 上:cat > fixture.sh <<'EOF' / echo 'a\' ; rm -rf /tmp/x / EOF 从合并基线的 2 个片段(allow)变成这里的 4 个(deny),其中一段正好是 rm -rf /tmp/x;而因为 <<'EOF' 带引号,bash 只执行 cat,正文是字面标准输入。通过 heredoc 写 shell 脚本文件是 agent 的常见动作。

触发条件很窄——需要在未被建模的区域里出现「单引号内的反斜杠」,只有撇号的 heredoc 正文并不会改变判定——而且方向是失效即收紧,所以代价是工作被挡住,而不是暴露风险。真正没有被记录下来的是这个类别本身:Risk & Scope 里那句「有些行 bash 实际只执行一条命令,这里仍会切开……这属于失效即收紧,与 main 的行为一致」对这两个输入并不成立;测试注释「最后一行对 bash 是一条命令,但仍保持切开,与 main 一致」只适用于它确实成立的那一行(echo 'a\'' ; rm x' 在两侧切分结果相同),所以仓库里没有任何地方说明并集也会新增 main 从未找到的边界。

修复方向与证据见上方英文部分(两臂 A/B 测量、bash 作为判定基准、以及建议改写的注释文本)。

约束:任何改为剥离注释的做法都不能丢掉现有用例行钉住的边界——"echo done # note 'a\''\nrm -rf /tmp/x" 仍必须切成 ["echo done # note 'a\''", 'rm -rf /tmp/x'],并在 ['Bash(rm *)'] 下保持 'deny',因为 bash 确实会执行那个 rm,把 # 之后全部丢弃会把一个正确的 deny 变成 allow。另外 shell-semantics.ts:2266 已经对 stripHeredocBodies(command) 做切分,而 stripHeredocBodies 是模块私有的(shell-semantics.ts:2180),且 shell-semantics.ts:38 是从 ./rule-parser.js 导入的,所以 heredoc 的修复不能在不做反向依赖的情况下把该辅助函数搬进本文件。

请补上用例把这个取舍钉住:断言 splitCompoundCommand("git commit -m 'x' # saved to 'C:\\' ; rm draft") 等于 ["git commit -m 'x' # saved to 'C:\\'", 'rm draft'],并在 %j with deny %j is %s 表中加一行断言 allow ['Bash(git *)'] / deny ['Bash(rm *)'] 下为 'deny';然后去掉普通引号读法,确认两行都变红,这样用例钉住的是这个取舍而不只是当前输出。

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

): CompoundCommandSegment[] {
const boundaries = [
...findOperatorBoundaries(command, 'bash'),
...findOperatorBoundaries(command, 'escape-everywhere'),

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 second scan runs unconditionally, so every command pays roughly 2x the splitter cost — including the overwhelming majority that contain no backslash, where the two readings are provably identical.

reading has exactly one read site in this file (rule-parser.ts:956, inside the ch === '\\' condition at 955), so on input with no backslash the 'bash' and 'escape-everywhere' scans cannot differ and the second pass is pure repetition. It is not paid once per check either: permission-manager.ts:336, 956, 1112 and 1210 each call splitCompoundCommand, and shell-semantics.ts:2266 calls splitCompoundCommandSegments, so one evaluation multiplies it. Measured on backslash-free input: a 7 KB heredoc goes 664.9 -> 1564.2 us (2.35x), 79 KB with 10,000 ; operators goes 6586 -> 15400 us (2.34x), and an 800 KB heredoc goes 109.4 -> 243.2 ms (2.22x, +134 ms per call). An agent writing a file through a large run_shell_command heredoc pays that at each of those call sites. For a typical short command the delta is ~5 us and immaterial — this is about the large-payload tail, not the common case.

Witness:

grep -n "reading" rule-parser.ts
  -> 285 (prose), 889 (prose), 931 (parameter decl), 956 (the sole read)
single-scan arm vs shipped two-scan arm, same probe, backslash-free input:
  7 KB heredoc        664.9 us -> 1564.2 us   2.35x
  79 KB / 10k ';'      6586 us -> 15400 us    2.34x
  800 KB heredoc      109.4 ms -> 243.2 ms    2.22x  (+134 ms per call)
redundancy proved, not assumed:
  20,000-input backslash-free differential sweep -> 0 differences
  positive control: the same comparator reports 4 of 8 differing rows when
  backslashes are present, so it is not a dead comparator
candidate fix applied to a throwaway copy, same probe re-run:
  A/B/D sections IDENTICAL; every backslash-bearing fixture row IDENTICAL;
  20,000-input sweep IDENTICAL; 7 KB 1564->585 us, 800 KB 243->92 ms
  (60-63% recovered, approximately single-scan cost); copy then restored
Suggested change
...findOperatorBoundaries(command, 'escape-everywhere'),
// `reading` is consulted only inside the `ch === '\\'` branch, so with no
// backslash in the input the two scans are identical.
...(command.includes('\\')
? findOperatorBoundaries(command, 'escape-everywhere')
: []),

The predicate has to key on a backslash anywhere in the whole input, not on quote or ANSI-C state, because rule-parser.ts:956 is the only place reading is read — a narrower predicate is not what makes the two scans equal. Worth naming the risk the guard introduces: it makes the boundary set depend on a second predicate, so a wrong one would silently lose the "never fewer boundaries than main" invariant that four independent differential sweeps totalling ~9.6M inputs establish. The command.includes('\\') form above was confirmed behaviour-preserving on every backslash-bearing fixture and on the 20,000-input sweep.

No test goes red if this guard is later removed, since it is a pure optimisation, so there is nothing to pin in that direction — what must stay green are the backslash-bearing rows this diff already adds (["echo 'a\\' ; touch /tmp/x", ...] and ["echo 'a\\' ; rm -rf /tmp/x", [], 'ask']), which go red if the skip is ever written too broadly, so please run those after any edit here.

中文说明

第二次扫描是无条件执行的,因此每条命令都要付出大约 2 倍的切分开销——包括绝大多数根本不含反斜杠、两种读法可以证明完全相同的命令。

reading 在本文件中只有一个读取点(rule-parser.ts:956,位于第 955 行的 ch === '\\' 条件内),所以在不含反斜杠的输入上,'bash''escape-everywhere' 两次扫描不可能不同,第二趟纯属重复。而且这个开销不是每次检查只付一次:permission-manager.ts:336、956、1112、1210 各自都会调用 splitCompoundCommand,shell-semantics.ts:2266 还会调用 splitCompoundCommandSegments,因此一次判定会把它放大若干倍。在不含反斜杠的输入上实测:7 KB heredoc 从 664.9 us 变为 1564.2 us(2.35 倍);79 KB、含 10,000 个 ; 的输入从 6586 us 变为 15400 us(2.34 倍);800 KB heredoc 从 109.4 ms 变为 243.2 ms(2.22 倍,每次调用多 134 ms)。通过大段 run_shell_command heredoc 写文件的 agent,会在上述每个调用点各付一次。对典型的短命令,差值约 5 us,无关紧要——本条针对的是大载荷的长尾,不是常见情况。

证据(唯一读取点的 grep、两臂计时、20,000 输入的差异扫描与正向对照、以及在一次性副本上应用候选修复后的复测)见上方英文部分。

建议的改法就是上面的一键 suggestion:只有当整条命令中确实含有反斜杠时才执行第二次扫描。

约束:这个判断条件必须以「整个输入中任意位置是否存在反斜杠」为准,而不能以引号或 ANSI-C 状态为准,因为 rule-parser.ts:956 是 reading 唯一的读取点——更窄的条件并不是让两次扫描相同的那个原因。同时也要说明这个守卫带来的风险:它让边界集合多依赖一个判断条件,因此条件写错会悄悄丢掉「切分出的边界不会比 main 少」这一不变量,而该不变量正是四次共约 960 万输入的独立差异扫描所确立的。上面的 command.includes('\\') 写法已在所有含反斜杠的 fixture 以及 20,000 输入扫描上确认行为不变。

由于这是纯优化,日后删掉这个守卫不会有用例变红,所以这个方向上没有可钉的东西——需要保持绿色的是本 diff 已新增的那些含反斜杠用例行(["echo 'a\\' ; touch /tmp/x", ...]["echo 'a\\' ; rm -rf /tmp/x", [], 'ask']),一旦跳过条件写得太宽它们就会变红,因此改动此处后请运行这些用例。

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

}
const segment = command.substring(lastSplit, start).trim();
if (segment) {
segments.push({ command: segment, terminator: operator });

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: This merge loop now decides which scan's operator becomes a segment's terminator, and terminator === '&' alone drives the backgrounded-cd cwd rule — yet every test this diff adds asserts splitCompoundCommand, the projection that throws the terminator away, so nothing pins it.

The behaviour is correct today and was verified end to end: cd 'a\' & echo {} > settings.json yields [["cd 'a\\'","&"],["echo {} > settings.json",""]], and extractShellOperationsAcrossCommand(cmd, '/repo') resolves write_file /repo/settings.json — the protected path — while the && variant correctly resolves to /repo/a/settings.json. Only the union makes that & discoverable at all: at the merge base the quote stays open, there is no boundary, the command is one segment and ops is []. So a protected-path deny rule now depends on this merge keeping the &, and a later edit to it — dropping the second scan, changing the .sort tie-break, or replacing if (start < lastSplit) continue; with something that keeps a shorter overlapping boundary — would make a backgrounded cd look foreground and resolve the following segments' relative writes against the cd target instead of the real cwd, so a protected .qwen/settings.json write stops matching its deny rule while the whole suite stays green. The only terminator assertions in the repo are three pre-existing unquoted rows (permission-manager.test.ts:768-783, present at the merge base as 714-729); shell-semantics.test.ts is untouched by this diff and its backgrounded-cd block (749-756) uses only unquoted cd /tmp &, cd .qwen &, cd "$TARGET" &.

Witness:

"cd 'a\' & echo {} > settings.json"
  BASE segments [["cd 'a\' & echo {} > settings.json",""]]   BASE ops []
  HEAD segments [["cd 'a\\'","&"],["echo {} > settings.json",""]]
  HEAD ops [{"virtualTool":"write_file","filePath":"/repo/settings.json"}]
"cd 'a\' && echo {} > settings.json"
  HEAD ops [{"write_file","/repo/a/settings.json"}]   BASE ops []
control "cd /tmp & echo {} > settings.json"
  BASE == HEAD == write_file /repo/settings.json
read at HEAD: shell-semantics.ts:2278 is `const backgrounded = terminator === '&';`
  fed from :2272's destructure of splitCompoundCommandSegments (:2266)

Add one row to the existing it.each at shell-semantics.test.ts:749-756 for the quoted-backslash form, expecting the write at /repo/settings.json:

"cd 'a\\' & echo {} > settings.json",

shell-semantics.ts:2278 is an exact string compare against '&', so the row must use & and not &&; and the over-correction guard already at shell-semantics.test.ts:771 pins 'cd /tmp && echo {} > settings.json' as foreground, which the new row must not weaken.

Please confirm the row really pins this merge by keeping only findOperatorBoundaries(command, 'escape-everywhere') and checking it goes red — the & boundary disappears, the segment's terminator is no longer '&', and the write no longer resolves to /repo/settings.json.

中文说明

这个合并循环现在决定了「哪一次扫描找到的操作符会成为片段的 terminator」,而 terminator === '&' 单独驱动着「后台执行的 cd」的 cwd 规则——但本 diff 新增的所有用例断言的都是 splitCompoundCommand,也就是那个会丢掉 terminator 的投影,因此这一点没有任何用例钉住。

当前行为是正确的,并且已端到端验证:cd 'a\' & echo {} > settings.json 得到 [["cd 'a\\'","&"],["echo {} > settings.json",""]]extractShellOperationsAcrossCommand(cmd, '/repo') 解析出 write_file /repo/settings.json(受保护路径),而 && 的变体正确解析为 /repo/a/settings.json。而这个 & 完全是因为并集才可被发现:在合并基线上引号一直处于打开状态,没有任何边界,整条命令是 1 个片段,ops[]。也就是说,受保护路径的 deny 规则现在依赖这个合并循环保留 &;而日后对它的任何改动——去掉第二次扫描、改变 .sort 的并列次序处理、或把 if (start < lastSplit) continue; 换成会保留较短重叠边界的写法——都会让后台执行的 cd 看起来像前台执行,从而把后续片段的相对写入解析到 cd 的目标目录而不是真实 cwd,于是一次对受保护 .qwen/settings.json 的写入不再命中它的 deny 规则,而整个测试套件仍然是绿的。仓库中唯一对 terminator 的断言是三行既有的、不带引号的用例(permission-manager.test.ts:768-783,在合并基线上是 714-729);shell-semantics.test.ts 未被本 diff 触及,其后台 cd 用例块(749-756)只用了不带引号的 cd /tmp &cd .qwen &cd "$TARGET" &

证据(两臂分段与 ops 输出、&& 变体、对照组,以及在 HEAD 读到的 shell-semantics.ts:2278/2272/2266)见上方英文部分。

建议在 shell-semantics.test.ts:749-756 既有的 it.each 中加一行带引号反斜杠的形态,断言写入解析到 /repo/settings.json

约束:shell-semantics.ts:2278 是与 '&' 的精确字符串比较,所以用例行必须用 & 而不是 &&;并且 shell-semantics.test.ts:771 已有的过度修正守卫钉住了 'cd /tmp && echo {} > settings.json' 属于前台执行,新增行不能削弱它。

请确认这一行确实钉住了这个合并循环:只保留 findOperatorBoundaries(command, 'escape-everywhere'),检查该用例是否变红——& 边界会消失,片段的 terminator 不再是 '&',写入也不再解析到 /repo/settings.json

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

// but escapes inside ANSI-C `$'…'` (so `$'a\''` closes at the third quote).
if (
ch === '\\' &&
(reading === 'escape-everywhere' || !(inSingle && !inAnsiC))

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: still stands, and its blast radius grew this round. Open PR #9417 still rewrites these very lines with the coarse !inSingle guard this thread proved insufficient — and it now also predates the two-scan union, so whichever PR merges second can silently drop the ANSI-C discrimination and the second scan that closed R3-1.

The guard on this line is (reading === 'escape-everywhere' || !(inSingle && !inAnsiC)), at what is now rule-parser.ts:955-956; this thread is anchored at the pre-refactor position 936, so the code moved. #9417 is still open and was updated today, and its diff still adds + if (ch === '\\' && !inSingle) {, renames splitCompoundCommandSegments to splitCompoundCommandSegmentsRaw and rewrites it, with no occurrence of findOperatorBoundaries, escape-everywhere or inAnsiC anywhere in it. So merging #9417 second reverts this PR to the coarse guard (reopening #11764's ANSI-C sibling) and deletes the second scan (reopening R3-1's comment, backtick and heredoc carriers); merging this PR second loses #9417's heredoc work. Nothing in this repository's tests catches either direction, because each suite pins only whichever scanner survived. The reconciliation requirement is recorded in this thread — "A reconciliation with #9417 must keep both scans as well as inAnsiC and dollarPending. Dropping either scan reopens R3-1 or #11764" — which states the constraint correctly, but it lives on this PR only, so the merge order can still decide the outcome silently.

Witness:

gh pr view 9417 --repo QwenLM/qwen-code --json state,title,updatedAt
  -> {"state":"OPEN",
      "title":"fix(core): keep heredoc bodies out of permission rule splitting",
      "updatedAt":"2026-09-15T03:38:26Z"}
gh pr diff 9417 | grep -nE "inSingle|splitCompoundCommandSegments|findOperatorBoundaries|escape-everywhere|inAnsiC"
  1259:-export function splitCompoundCommandSegments(
  1260:+function splitCompoundCommandSegmentsRaw(
  1282:+    if (ch === '\\' && !inSingle) {        <- the coarse guard this thread proved insufficient
  rewrites at 1301 / 1320 / 1328 / 1345
  zero hits for findOperatorBoundaries, escape-everywhere, inAnsiC

Please post the reconciliation requirement on #9417 itself rather than only here, naming all three things a merged scanner must keep — the inAnsiC / dollarPending discrimination, both findOperatorBoundaries scans, and #9417's heredoc-body handling — so that whoever lands second sees it on the PR they are about to merge. Better still, land the shared-scanner form so there is one implementation to reconcile instead of two that must be kept in step by hand.

中文说明

R1-3 在当前 commit 下仍然成立,而且本轮它的影响范围变大了。仍处于开启状态的 PR #9417 依然会改写这几行,用的正是本线程已证明不够用的粗粒度 !inSingle 守卫——而它现在还早于两次扫描的并集,因此两个 PR 中后合并的那一个可能同时悄悄丢掉 ANSI-C 判别关闭 R3-1 的那第二次扫描。

这一行的守卫是 (reading === 'escape-everywhere' || !(inSingle && !inAnsiC)),现在位于 rule-parser.ts:955-956;本线程锚定在重构前的位置 936,所以代码已经移动。#9417 仍然开启,且今天有更新,它的 diff 仍然新增 + if (ch === '\\' && !inSingle) {,把 splitCompoundCommandSegments 改名为 splitCompoundCommandSegmentsRaw 并重写,其中完全没有出现 findOperatorBoundariesescape-everywhereinAnsiC。因此:#9417 后合并会把本 PR 退回粗粒度守卫(重新打开 #11764 的 ANSI-C 同类问题)并删掉第二次扫描(重新打开 R3-1 的注释、反引号与 heredoc 载体);本 PR 后合并则会丢掉 #9417 的 heredoc 处理。仓库里的测试抓不到任何一个方向,因为每套用例只钉住了最终存活的那个扫描器。统一要求已记录在本线程中——「与 #9417 的协调必须同时保留两次扫描以及 inAnsiCdollarPending;丢掉任一次扫描都会重新打开 R3-1 或 #11764」——这个约束本身表述正确,但它只存在于本 PR 上,所以合并顺序仍然可能在无人察觉的情况下决定结果。

证据(gh pr view 9417 的状态与更新时间,以及 gh pr diff 9417inSingle 相关行的 grep 结果,显示 findOperatorBoundaries / escape-everywhere / inAnsiC 零命中)见上方英文部分。

请把这条统一要求也发到 #9417 本身,而不只是留在这里,并写明合并后的扫描器必须保留的三件事——inAnsiC / dollarPending 判别、两次 findOperatorBoundaries 扫描、以及 #9417 的 heredoc 正文处理——这样后落地的人在即将合并的那个 PR 上就能看到它。更好的做法是落地共享扫描器的形态,这样只需要协调一份实现,而不是靠人工让两份保持同步。

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

@wenshao

wenshao commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — built and run locally against the real CLI

I verified this branch end to end on a real build, not through unit tests alone: npm run build && npm run bundle at f56192fcd3, then the bundled dist/cli.js driven in a pty with --approval-mode default, a scripted fake OpenAI backend issuing one run_shell_command, and the permission rules in the workspace .qwen/settings.json. The main arm is the same tree with only packages/core/src/permissions/rule-parser.ts reverted to the merge base (9f8cb17ca6) and core + bundle rebuilt — confirmed by findOperatorBoundaries being present in dist/ for one arm and absent for the other. main has not touched packages/core/src/permissions/ since the merge base, so the single-file A/B is the whole difference.

Environment: macOS 26.5 (arm64), Node v24.18.1, npm 11.16.0, GNU bash 5.3.15(1).

Bottom line: the bug is real, the fix works, and every claim in the Reviewer Test Plan reproduced — except one sentence in "Breaking changes", which is wrong in a way worth correcting before merge. Details below, with the three open [Suggestion] findings re-measured.


1. The bypass, reproduced in the real CLI

echo 'a\' ; touch poc.flag with permissions.allow: ["Bash(echo *)"]. On main the touch runs with a green ✓ and no prompt; poc.flag exists afterwards. On this branch the CLI stops and asks.

allow-only

The same line with permissions.deny: ["Bash(touch *)"] added. On main the deny rule is never reached and poc.flag is still created — this is the part that matters, because a deny rule is the one control a user cannot be talked out of:

with-deny

scenario (allow Bash(echo *)) main this PR
echo 'a' ; touch poc.flag (control) asks asks
echo 'a\' ; touch poc.flag ran silently, poc.flag created asks, poc.flag absent
…plus deny Bash(touch *) ran silently, poc.flag created denied by Bash(touch *), poc.flag absent

2. Ground truth from the shell itself

Every split was checked against bash --norc --noprofile -xc on GNU bash 5.3.15, counting the commands bash actually traces:

line bash runs main segments this PR
echo 'a\' ; touch … 2 (touch executes) 1 2 ✓
echo 'a\' && touch … 2 (touch executes) 1 2 ✓
echo 'c\' $'a\'' ; touch … 2 (touch executes) 1 2 ✓
echo \$'a\' ; touch … 2 (touch executes) 1 2 ✓
echo $$'a\' ; touch … 2 (touch executes) 1 2 ✓
echo "$"'a\' ; touch … 2 (touch executes) 1 2 ✓
echo "a\" ; touch …" 1 1 ✓ 1 ✓
echo done # note 'a\''touch … 2 (touch executes) 2 ✓ 2 ✓

The last three $-disambiguation rows (\$, $$, "$") are three further fail-open inputs this change closes that the Evidence table does not spell out; they are worth a line there.

3. Verdicts through the compiled PermissionManager

allow Bash(echo *) / then with deny Bash(touch *) added:

main this PR
echo 'a' ; touch … (control) ask / deny ask / deny
echo 'a\' ; touch … allow / allow ask / deny
echo 'a\' && touch … allow / allow ask / deny
echo 'c\' $'a\'' ; touch … allow / allow ask / deny
echo \$'a\' ; touch … allow / allow ask / deny
echo $$'a\' ; touch … allow / allow ask / deny
echo "$"'a\' ; touch … allow / allow ask / deny
echo "a\" ; touch …" allow / allow allow / allow (one command)
echo $'a\'' ; touch … ask / deny ask / deny
comment / backtick / heredoc carriers (R3-1) ask / deny ask / deny

R3-1 is closed: all three carriers keep main's boundaries.

4. Suites, mutations, and one thing the last commit cost

packages/core, permissions/ + shell.test.ts + shell-utils.test.ts + monitor.test.ts: 1522 passed / 13 files, with permission-manager.test.ts at 454 as claimed. main's own copy of permission-manager.test.ts run against the new parser: 428 passed — no pinned behaviour regressed. eslint on both changed files is clean and the full tsc build is at 0 errors.

The mutation table reproduces exactly, 7 for 7:

mutation red (claimed) red (measured)
backslash always escapes 13 13
coarse !inSingle (no ANSI-C) 3 3
drop the pre-fix-reading scan 8 8
$$ opens ANSI-C 1 1
continuation drops the pending $ 1 1
continuation keeps the newline 2 2
merge without skipping overlaps 29 29

One thing to look at before merging. f56192fcd3 ("condense splitter comments and quote-handling tests") removes 291 test lines (472 → 454 cases). The parser is byte-identical across that commit — the diff is comments only, I checked — so the same mutants can be scored against both test files, and every kill count drops:

mutation at 550a71bab0 at f56192fcd3 (head)
backslash always escapes 20 13
coarse !inSingle (no ANSI-C) 9 3
drop the second scan 8 8
$$ opens ANSI-C 1 1
continuation drops the pending $ 3 1
continuation keeps the newline 7 2
merge without skipping overlaps 39 29

Every mutant is still killed, so the net holds and this is not a blocker. But the guard that went from 9 to 3 is the exact shape R1-3 asks to keep pinned against #9417. Three cases is a thin margin for the one line a future merge is most likely to flatten.

5. R1-3 / #9417 — measured, and less dire than stated

I actually ran the merge rather than reasoning about the diffs. git merge probe/pr9417 onto this head conflicts: two hunks in rule-parser.ts, the second being this PR's entire backslash-guard block against an empty side — the shape where "take theirs" looks reasonable. Resolving both hunks in #9417's favour leaves exactly one compiler complaint, TS6133: 'inAnsiC' is declared but its value is never read; deleting those two lines is the obvious fix and the tree then compiles clean. The resulting parser:

case main this PR naive merge
echo 'a\' ; touch … allow ask ask
echo 'c\' $'a\'' ; touch … allow ask allow
echo $'a\'' ; touch … ask ask allow ← worse than main
echo done # note 'a\''touch … ask ask allow ← worse than main
echo `echo 'a\''` ; touch … ask ask allow ← worse than main
echo 'a\'' ; rm x' deny deny allow ← worse than main

So the hazard is real and the reconciliation note is worth keeping. But the claim that "nothing in this repository's tests catches either direction" does not hold: the two test files auto-merge without conflict, and that merged file (508 cases) goes 16 red on this resolution, naming each reopened class by its own title. The gate exists as long as whoever merges keeps both test files, which git does by default. I would keep the note on both PRs and drop the "undetectable" framing.

6. R4-1 — confirmed, and it is the one user-visible regression

false-deny

git commit -m 'x' # saved to 'C:\' ; rm draft with allow Bash(git *) and deny Bash(rm *). bash runs exactly one command — git commit -m x — and draft is still on disk after both runs. On main the commit lands. On this branch it is refused.

Two corrections to the finding as written: the refusal does name the matching rule ("Matching deny rule: Bash(rm *)"), so it is not a bare deny; and the user still cannot explain it, because there is no rm anywhere in what they asked for. Fail-closed and narrow, but this is the trade the description should state in the Evidence table rather than only in prose — one row, main allow → this PR deny.

7. R4-2 — reproduced independently

Median of 7 runs, per call, on backslash-free input:

input main this PR ratio
git status --porcelain 1.5 µs 3.0 µs 2.0×
7 KB heredoc 508.6 µs 1005.4 µs 1.98×
79 KB, 10,000 ; 8.67 ms 18.05 ms 2.08×
800 KB heredoc 74.7 ms 150.6 ms 2.02×

Consistently ~2×, a little under the reported 2.2–2.35× on this machine but the same shape. Immaterial for ordinary commands; the large-payload tail is real and multiplies across the call sites. Not a merge blocker — a command.includes('\\') guard around the second scan would recover it exactly, since the two readings are provably identical without a backslash.

8. R4-3 — confirmed, and the union is what creates the protection

cd 'a\' & echo {} > settings.json, against /repo:

  • main: one segment, extractShellOperationsAcrossCommand returns [] — no write is seen at all.
  • this PR: [{ command: "cd 'a\\'", terminator: '&' }, { command: 'echo {} > settings.json', terminator: '' }], and the op resolves to write_file /repo/settings.json, the protected path. The && spelling correctly resolves to /repo/a/settings.json.

So this change adds protected-path coverage here. The gap is real though: the diff adds 0 assertions mentioning terminator and 0 calling splitCompoundCommandSegments, so the merge loop that now decides the terminator is pinned by nothing.

9. Differential fuzz — 350,000 inputs

Two generators against both arms with allow Bash(echo *) Bash(cat *) Bash(git *) and deny Bash(rm *) Bash(touch *): 200,000 random token strings, and 150,000 grammar-built lines filtered to those bash -n accepts.

  • this PR produced fewer segments than main: 0 times — the boundary-set invariant holds.
  • a deny verdict was lost: 0 times.
  • verdict transitions on the 150,000 valid lines: allow→deny 7,767 · ask→deny 2,085 · allow→ask 2,010 · ask→allow 1,698.

That last column means "Breaking changes: none. No input becomes more permissive than on main" is not accurate as written. 1.1% of valid generated commands move from ask to allow. The mechanism is benign, and I checked it against bash: these are lines main could not parse, so it merged real command boundaries into one blob that matched no allow rule and fell through to ask; this PR resolves the boundaries and each resulting command matches an allow rule on its own. For example ls "$"'a\' ; cat "$(date)" $'a\'' — bash runs it as two commands, ls and cat, and both are covered by the configured rules. The split is the correct one. But "used to prompt, now runs unattended" is still a permission change, and it should be in the description rather than discovered later.

Suggested replacement for that line:

Breaking changes: a deny is never lost and the boundary set is never smaller than main's. Where the recovered boundaries are correct, a line main could not parse and therefore sent to ask may now be auto-approved, if every sub-command it really runs matches an allow rule.


Verdict

The defect is real, the fix is correct against bash's own reading, and it holds up under a real build, a real pty session, a bash-traced ground truth, a 7-mutant matrix and 350k differential samples. Nothing here blocks the merge.

Before merging I would ask for two description edits and would take one optional change:

  1. Required — fix the "Breaking changes" line (§9). It is the only claim that does not survive measurement.
  2. Required — add the R4-1 row to the Evidence table (§6): git commit -m 'x' # saved to 'C:\' ; rm draft goes allowdeny. It is disclosed in prose today, but the table is what a reader scans.
  3. Optional, recommended — restore a few of the rows dropped in f56192fcd3 for the !inSingle guard (§4), enough to put it back above single digits. That is the line fix(core): keep heredoc bodies out of permission rule splitting #9417 will collide with.

findOperatorBoundaries/escape-everywhere/inAnsiC and the two-scan union should stay in whichever of #11765 and #9417 lands second; §5 shows exactly what a "take theirs" resolution costs and that CI will catch it.

中文说明

维护者验证 —— 本地真实构建实测

我没有只跑单测,而是在真实构建上做了端到端验证:在 f56192fcd3 上执行 npm run build && npm run bundle,然后用 pty 驱动打包产物 dist/cli.js,加 --approval-mode default,用脚本化的伪 OpenAI 后端只下发一次 run_shell_command,权限规则写在工作区的 .qwen/settings.json 里。main 那条臂是同一棵树,只把 packages/core/src/permissions/rule-parser.ts 回退到合并基 9f8cb17ca6,重建 core 并重新打包 —— 用 dist/ 里有没有 findOperatorBoundaries 做了双向确认。合并基之后 main 没有动过 packages/core/src/permissions/,所以这个单文件 A/B 就是全部差异。

环境:macOS 26.5(arm64)、Node v24.18.1、npm 11.16.0、GNU bash 5.3.15(1)。

结论:缺陷真实存在,修复有效,Reviewer Test Plan 里的每一条都复现了 —— 只有「破坏性变更」那一句不成立,建议合并前改掉。 下面是细节,三条未决 [Suggestion] 也一并重新实测。

1. 真实 CLI 里复现绕过

echo 'a\' ; touch poc.flagpermissions.allow: ["Bash(echo *)"]maintouch 打着绿色 ✓ 直接执行、没有任何确认,事后 poc.flag 存在;本分支会停下来询问。(图 1)

同一行再加 permissions.deny: ["Bash(touch *)"]main 上 deny 规则根本没被触达,poc.flag 照样被创建 —— 这才是关键,因为 deny 是用户唯一不可被绕过的控制。(图 2)

场景(allow Bash(echo *) main 本 PR
echo 'a' ; touch poc.flag(对照) 询问 询问
echo 'a\' ; touch poc.flag 静默执行,poc.flag 被创建 询问,poc.flag 不存在
再加 deny Bash(touch *) 静默执行,poc.flag 被创建 Bash(touch *) 拒绝,poc.flag 不存在

2. 以 shell 自身为基准

每个切分都用 GNU bash 5.3.15 的 bash --norc --noprofile -xc 对照,数 bash 实际 trace 出来的命令数:echo 'a\' ; touch&& 变体、$'…' 混合、以及 \$'a\'$$'a\'"$"'a\' 六种写法,bash 都跑两条命令、touch 确实执行,而 main 只切出 1 段(失效即放行),本 PR 切出 2 段。echo "a\" ; touch …" 两边都是 1 段(bash 也只跑一条)。

其中 \$$$"$" 这三行是本改动额外堵上的绕过,Evidence 表里没有单独列出,建议补一行。

3. 编译后的 PermissionManager 判决

allow Bash(echo *) / 再加 deny Bash(touch *):上述六种写法在 main 上全是 allow / allow,本 PR 上全是 ask / deny;对照行与「一条命令」行两边一致;注释、反引号、heredoc 三种载体(R3-1)两边都是 ask / deny,说明 R3-1 已关闭且没有把 main 的切分点弄丢。

4. 测试、变异,以及最后一个 commit 的代价

packages/corepermissions/ + shell.test.ts + shell-utils.test.ts + monitor.test.ts13 个文件、1522 个用例全通过,其中 permission-manager.test.ts 正是 454。把 main 自己的 permission-manager.test.ts(428)跑在新解析器上:428 全通过,已钉住的行为没有回归。两个改动文件 eslint 干净,完整 tsc 构建 0 错误。

变异矩阵 7 条全部复现,数字完全一致(13 / 3 / 8 / 1 / 1 / 2 / 29)。

合并前值得看一眼的一点。 f56192fcd3("condense splitter comments and quote-handling tests")删掉了 291 行测试(472 → 454 个用例)。这个 commit 前后解析器逐字节相同(diff 只有注释,我核对过),所以可以用同一批变异体对两份测试文件打分,结果每一项击杀数都下降:

变异 550a71bab0 f56192fcd3(head)
反斜杠恒为转义 20 13
粗粒度 !inSingle(丢掉 ANSI-C) 9 3
去掉第二遍扫描 8 8
$$ 开启 ANSI-C 1 1
续行丢掉待定的 $ 3 1
续行保留换行 7 2
合并时不跳过重叠 39 29

所有变异体仍然会被杀掉,安全网还在,这不构成阻塞。但从 9 掉到 3 的那一条,恰好就是 R1-3 要求对着 #9417 钉死的那一行。3 个用例的余量,对一条最可能被未来合并抹平的代码来说偏薄。

5. R1-3 / #9417 —— 实测过,没有说的那么严重

我直接跑了合并,而不是读 diff 推断。git merge probe/pr9417 到本 head 上会冲突rule-parser.ts 里两个 hunk,第二个正是本 PR 整块反斜杠守卫对上空白的一侧 —— 也就是「take theirs」看起来最合理的形状。两个 hunk 都按 #9417 解决后,编译器只报一条 TS6133: 'inAnsiC' is declared but its value is never read;把那两行删掉是最自然的修法,删完就编译通过了。合并结果:

用例 main 本 PR 粗暴合并
echo 'a\' ; touch … allow ask ask
echo 'c\' $'a\'' ; touch … allow ask allow
echo $'a\'' ; touch … ask ask allow ← 比 main 还差
echo done # note 'a\''touch … ask ask allow ← 比 main 还差
echo `echo 'a\''` ; touch … ask ask allow ← 比 main 还差
echo 'a\'' ; rm x' deny deny allow ← 比 main 还差

所以这个风险是真的,那条调和说明值得保留。但「本仓库的测试抓不住任何一个方向」这句不成立:两个测试文件是无冲突自动合并的,合并后的文件(508 个用例)在这种解决方式下红 16 个,每个用例名字就写着被重开的那一类。只要合并的人保留两份测试文件(git 默认就会),这道门就在。建议两个 PR 上都留说明,但去掉「无法检测」的定性。

6. R4-1 —— 确认,且是唯一一处用户可见的退步(图 3)

git commit -m 'x' # saved to 'C:\' ; rm draft,allow Bash(git *)、deny Bash(rm *)。bash 只跑一条命令 git commit -m x,两条臂跑完 draft 都还在。main 上提交成功,本分支上被拒绝。

对这条 finding 有两点修正:拒绝信息确实给出了命中的规则("Matching deny rule: Bash(rm *)"),不是裸 deny;但用户依然无法解释它,因为自己写的命令里根本没有 rm。属于失效即收紧、触发面窄,但这个取舍应该进 Evidence 表(main allow → 本 PR deny 一行),而不是只在正文里提。

7. R4-2 —— 独立复现

7 次取中位数、单次调用、无反斜杠输入:git status --porcelain 1.5 → 3.0 µs(2.0×);7 KB heredoc 508.6 → 1005.4 µs(1.98×);79 KB / 10000 个 ; 8.67 → 18.05 ms(2.08×);800 KB heredoc 74.7 → 150.6 ms(2.02×)。本机稳定在 ~2×,比报告的 2.2–2.35× 略低,但形状一致。日常命令可忽略,大 payload 尾部是实打实的,而且会按调用点数量翻倍。不阻塞合并 —— 在第二遍扫描外面加一个 command.includes('\\') 判断即可完全消掉,因为没有反斜杠时两种读法可证等价。

8. R4-3 —— 确认,而且这份保护正是 union 带来的

cd 'a\' & echo {} > settings.json,相对 /repomain 只切 1 段,extractShellOperationsAcrossCommand 返回 [],根本看不到这次写入;本 PR 切成 cd 'a\'(terminator &)+ echo {} > settings.json,解析出 write_file /repo/settings.json(受保护路径),&& 写法则正确解析到 /repo/a/settings.json。也就是说这个改动在这里增加了保护。但缺口确实存在:本 diff 新增的断言里提到 terminator 的有 0 处,调用 splitCompoundCommandSegments 的也有 0 处,现在决定 terminator 的合并循环没有任何测试钉住。

9. 差分 fuzz —— 35 万条输入

两个生成器对打两条臂,allow Bash(echo *) Bash(cat *) Bash(git *)、deny Bash(rm *) Bash(touch *):20 万条随机 token 串,15 万条语法生成并用 bash -n 过滤后的合法命令行。

  • 本 PR 切出的段数少于 main 的次数:0 —— 切分点不会变少这条不变式成立。
  • 丢掉 deny 判决的次数:0
  • 15 万条合法命令上的判决迁移:allow→deny 7767 · ask→deny 2085 · allow→ask 2010 · ask→allow 1698

最后一列说明「破坏性变更:无。没有任何输入比 main 更宽松」这句不成立。 1.1% 的合法命令从 ask 变成了 allow。我对着 bash 核过机制,是良性的:这些行 main 解析不了,于是把真实的命令边界糊成一段、匹配不上任何 allow 规则,落到 ask;本 PR 把边界解出来后,每一条真实命令各自都命中了 allow 规则。例如 ls "$"'a\' ; cat "$(date)" $'a\'',bash 就是跑 lscat 两条,两条都在配置的规则内 —— 切分是对的。但「原本会弹确认、现在直接跑」终究是权限行为的变化,应该写进说明,而不是留给以后被人发现。

建议把那一句改成:

破坏性变更: 不会丢失任何 deny,切分点也不会比 main 更少。在新恢复出来的边界是正确的地方,某些 main 解析不了、因而落到 ask 的行,如果它真正执行的每一条子命令都命中 allow 规则,现在会被自动放行。

结论

缺陷真实,修复方向与 bash 自身的读法一致,并且在真实构建、真实 pty 会话、bash trace 基准、7 个变异体和 35 万条差分样本下都站得住。没有任何一条阻塞合并。

合并前我希望改两处说明,另有一处可选改动:

  1. 必须 —— 修正「破坏性变更」那一句(§9),这是唯一一条经不起实测的断言。
  2. 必须 —— 把 R4-1 那一行补进 Evidence 表(§6):git commit -m 'x' # saved to 'C:\' ; rm draftallowdeny。现在只在正文里披露,但读者扫的是表。
  3. 可选,建议 —— 把 f56192fcd3 删掉的部分行为 !inSingle 守卫补回来(§4),让它重回两位数。这正是 fix(core): keep heredoc bodies out of permission rule splitting #9417 会撞上的那一行。

无论 #11765#9417 哪个后合,findOperatorBoundaries / escape-everywhere / inAnsiC 和两遍扫描的 union 都必须保留在后合的那一侧;§5 给出了「take theirs」具体会付出什么代价,以及 CI 会抓住它。

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: a Bash allow rule authorises a second command when the first ends with a backslash inside single quotes

6 participants