Repository navigation
fix(core): keep both cwds when one quote reading alone ends a cd - #12363
Closed
TianYuan1024 wants to merge 25 commits into
Closed
TianYuan1024 wants to merge 25 commits into
TianYuan1024 wants to merge 25 commits into
Conversation
…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.
…-quote-backslash-split
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.
…-quote-backslash-split
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.
…-quote-backslash-split
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.
…-quote-backslash-split
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.
…-quote-backslash-split
…-quote-backslash-split
…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.
The two backslash readings can only differ at a backslash, so a command without one needs a single scan. Restores main's timing for backslash-free input; commands containing a backslash still pay ~2x. Also pin the terminator the merge loop reports, and restore the ANSI-C rows that keep the coarse `!inSingle` guard above single digits (11 red).
…-quote-backslash-split
String.trim() also strips \r, \v, \f and \u00a0, which bash treats as ordinary word characters, so trimming a segment deleted a redirection target such as `echo x >\u00a0` and the virtual write op vanished from a verdict that was a deny. Use the same BASH_WORD_SEPARATORS pair QwenLM#11865 adds, dropping a \r only as the first half of a CRLF. Also pin the boundary sort, which no case covered, and move the terminator case under describe('splitCompoundCommandSegments').
…-quote-backslash-split
…-quote-backslash-split Resolve the conflict with QwenLM#11865 in favour of the two-scan split, and move its CR_IS_WHOLE_REDIRECT_TARGET carve-out into the merge loop so a `\r` that is the whole redirect target still survives a `\n` terminator. Drop this branch's copy of the trim tests that QwenLM#11865's rows now cover, keeping only the last-segment trim case.
…-quote-backslash-split QwenLM#12096 pinned three `echo 'a\' # …` rows as `allow` and marked them to flip when this branch lands: the splitter now closes `'a\'` as bash does and splits at the operator inside the comment, so they resolve to `ask`.
A `$` opens ANSI-C only when the quote follows it directly. Without the per-character reset, `echo $x'a\' ; touch /tmp/x` collapsed into one segment and the suite stayed green. Also point the stale "last row" comment at the row it describes.
bash runs one command in `git commit -m 'x' # saved to 'C:\'⏎rm draft`, but the pre-fix reading splits inside the comment and a `Bash(rm *)` deny refuses it. Pin that, the `monitor` spelling, and the single-line spelling QwenLM#12096's comment fast path allows again.
…-quote-backslash-split
A boundary only one backslash reading finds is split on either way, so a command is never hidden, but it is not evidence of what bash ran. Reading such a terminator as `&` (or as foreground) decides on its own whether a `cd` moved the shell, and a following relative write is then attributed to a directory bash never entered — `Write(...)` deny rules miss it (QwenLM#12246). Flag those boundaries in the splitter and, when one ends a `cd`, carry both the moved and the unmoved cwd through the rest of the walk, emitting the following operations under each. The permission layer aggregates to the most restrictive verdict, so the deny holds whichever way bash read the quotes.
TianYuan1024
requested review from
LaZzyMan,
doudouOUC,
tanzhenxin,
wenshao and
yiliang114
as code owners
September 20, 2026 16:51
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
A compound command is split by scanning it under both backslash readings, so a boundary is never missed. This PR records which boundaries the two readings agree on, and stops a boundary that only one of them found from deciding what the shell did: when such an operator ends a
cd, the walk no longer picks one of foreground or background, it carries both the moved and the unmoved working directory through the rest of the command and reports the following relative paths under each. The permission layer already aggregates to the most restrictive verdict, so aWrite(...)deny rule holds whichever way bash read the quotes.Why it's needed
With
allow: ["Bash(cd *)", "Bash(echo *)"]anddeny: ["Write(.qwen/settings.json)"], the commandcd .qwen ; cd 'x\'';echo ' & echo {} > settings.jsonevaluated toallowand overwrote the protected file with no prompt (#12246).bash concatenates
'x\''and';echo 'into one word, so the operator that ends the secondcdis the&after it: thatcdruns in a background subshell and cannot move the parent shell, and the write lands in.qwen. The escape-everywhere reading pairs the quotes differently and sees a;between the two fragments instead. Because the segment took its terminator from whichever boundary came first, thecdwas read as foreground, the effective cwd moved to.qwen/x, and the write was attributed to a directory bash never entered — so the deny rule never saw it.The direction is the one the issue asks for: a boundary that only one reading found is still split on, because a missed boundary hides a command, but it is not evidence of what bash ran, so it must not be what decides a
cd's effect on the cwd.Reviewer Test Plan
How to verify
Two new pins, both failing without the production change in this PR:
shell-semantics.test.ts— the issue's command attributes the write to both/repo/.qwen/settings.json(what bash does) and/repo/.qwen/x/settings.json, next to the existing backgrounded-cdcases.permission-manager.test.ts— the issue's exact rule config returnsdenyfor the exploit, and the two controls (cd .qwen ; cd x & …andcd .qwen ; echo {} > …) keep theirdeny.Beyond the suite, I ran a differential harness that executes 102
cd/write shapes in real bash inside a sandbox directory, records which file each one actually creates, and compares that againstPermissionManager.evaluateunder the issue's rule config (syntactically invalid shapes are identified withbash -nand excluded from the over-strict count):allowmainThe 8 newly denied shapes are all attack-shaped quoting (
cd 'x\'';echo ' && …) where the artefact directory does not exist, so bash writes nothing; denying them is the conservative reading of acdwhose effect is undecided. The 10 residual fail-open shapes are a different defect that neither this PR nor #11765 claims: the analysis assumes acdsucceeds, socd .qwen ; cd x ; echo {} > settings.json(whenxdoes not exist) andcd .qwen ; cd x || echo {} > settings.json(where||means thecdfailed) still resolve the write to the moved directory. I will open a separate issue for it.Evidence (Before & After)
N/A — no user-visible or TUI change. Before and after are the verdicts pinned in the two tests above:
allow→denyfor the issue's command, with every other permissions test unchanged.Tested on
packages/corepermissions suite 1051/1051 pass on macOS arm64;tsc --noEmit, eslint and prettier clean.Environment (optional)
Unit tests only.
Risk & Scope
cdwhose terminator only one reading found now contributes two candidate working directories instead of one, so the segments after it report one operation per candidate. That can only add operations, never remove them, so a verdict can become stricter but never more permissive. Candidates are deduped and capped at 8; past the cap the remaining paths are reported as cwd-unknown rather than branching further. Commands whose boundaries both readings agree on — everything without a backslash inside single quotes — walk exactly as before.cd-may-fail class described above; comment (#) and backtick bodies, which neither reading models (splitCompoundCommandSegments splits on an operator inside a trailing # comment #11815); converging the two segmentations (shell comment semantics: gate on the reported shell, and converge the two compound-command splitters #11882).Linked Issues
Fixes #12246.
Stacked on #11765 — its commits are the first part of this branch, so the diff against
mainincludes the dual-reading splitter that this change flags boundaries in. Review or land that one first; this PR rebases away to the two commitsfix(core): keep both cwds when one reading alone ends a cdandtest(core): pin the deny for a cd ended by one quote reading alone.中文说明
这个 PR 做了什么
切分器本来就用两种反斜杠读法各扫一遍,保证不漏边界。本 PR 记录下两种读法是否都看到某个边界,并且不再让只有一种读法找到的边界决定 shell 实际做了什么:当这样的操作符结束一个
cd时,walk 不再在前台/后台之间二选一,而是同时带着「移动后的 cwd」和「未移动的 cwd」继续走完整条命令,后续相对路径在每个候选 cwd 下各解析一次。权限层本来就按最严裁决聚合,所以无论 bash 怎么读这些引号,Write(...)拒绝规则都成立。为什么需要
在
allow: ["Bash(cd *)", "Bash(echo *)"]、deny: ["Write(.qwen/settings.json)"]配置下,cd .qwen ; cd 'x\'';echo ' & echo {} > settings.json裁决为allow,受保护文件被无提示覆盖(#12246)。bash 把
'x\''和';echo '拼成同一个词,所以结束第二个cd的是后面的&:该cd在后台子 shell 中运行,无法移动父 shell 的 cwd,写入落在.qwen。而 escape-everywhere 读法的引号配对不同,会在两个片段之间看到一个;。由于 segment 取的是最先出现的那个边界作为终结符,cd被当成前台,有效 cwd 被搬到.qwen/x,写入被归到一个 bash 从未进入的目录,拒绝规则自然没有命中。修法就是 issue 指出的方向:只有一种读法找到的边界仍然照切(漏掉边界会隐藏命令),但它不能作为 bash 实际行为的证据,因此不能由它决定
cd对 cwd 的影响。如何验证
两个新增回归用例在去掉本 PR 的生产代码后都会失败:
shell-semantics.test.ts:issue 里的命令同时把写入归到/repo/.qwen/settings.json(bash 的真实行为)和/repo/.qwen/x/settings.json,位置紧挨已有的后台cd用例。permission-manager.test.ts:用 issue 给出的原始规则配置,漏洞串返回deny,两个对照串保持deny。此外我跑了一个差分对照脚本:102 个
cd/写入形态在沙箱目录里用真 bash 执行,记录实际创建的文件,再和PermissionManager.evaluate在 issue 配置下的裁决对比(语法非法的形态用bash -n识别并排除在过严统计之外):allowdenymain新增的 8 条误判全是攻击形态的引号串(
cd 'x\'';echo ' && …),其中的引号产物目录并不存在,bash 什么也没写;对一个效果未定的cd取保守解读就会得到deny。剩下的 10 条 fail-open 属于另一类缺陷,本 PR 和 #11765 都没有声称覆盖:分析假设cd一定成功,所以cd .qwen ; cd x ; echo {} > settings.json(x不存在时)和cd .qwen ; cd x || echo {} > settings.json(||意味着cd失败)仍会把写入解析到移动后的目录。我会另开 issue 跟踪。风险与范围
cd现在会产生两个候选工作目录,其后的 segment 每个候选各报一条操作。这只会增加操作、不会减少,因此裁决只会更严、不会更松。候选去重并限制在 8 个以内,超出后剩余路径按 cwd 未知上报,而不是继续翻倍。两种读法一致的命令(所有单引号内没有反斜杠的命令)走法完全不变。cd可能失败这一类;两种读法都未建模的注释(#)与反引号内容(splitCompoundCommandSegments splits on an operator inside a trailing # comment #11815);两套切分的收敛(shell comment semantics: gate on the reported shell, and converge the two compound-command splitters #11882)。关联 issue
修复 #12246。
基于 #11765 叠加提交——它的提交构成本分支的前半部分,所以与
main的差异中包含本次打标记所依赖的双读法切分器。建议先评审/合入那个 PR;之后本 PR rebase 后只剩fix(core): keep both cwds when one reading alone ends a cd和test(core): pin the deny for a cd ended by one quote reading alone两个提交。