Skip to content

fix(core): keep both cwds when one quote reading alone ends a cd - #12363

Closed
TianYuan1024 wants to merge 25 commits into
QwenLM:mainfrom
TianYuan1024:fix/ambiguous-cd-terminator-cwd
Closed

TianYuan1024 wants to merge 25 commits into
QwenLM:mainfrom
TianYuan1024:fix/ambiguous-cd-terminator-cwd

Conversation

@TianYuan1024

Copy link
Copy Markdown
Contributor

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 a Write(...) deny rule holds whichever way bash read the quotes.

Why it's needed

With allow: ["Bash(cd *)", "Bash(echo *)"] and deny: ["Write(.qwen/settings.json)"], the command cd .qwen ; cd 'x\'';echo ' & echo {} > settings.json evaluated to allow and overwrote the protected file with no prompt (#12246).

bash concatenates 'x\'' and ';echo ' into one word, so the operator that ends the second cd is the & after it: that cd runs 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, the cd was 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

cd packages/core && npx vitest run src/permissions/ --reporter=dot

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-cd cases.
  • permission-manager.test.ts — the issue's exact rule config returns deny for the exploit, and the two controls (cd .qwen ; cd x & … and cd .qwen ; echo {} > …) keep their deny.

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 against PermissionManager.evaluate under the issue's rule config (syntactically invalid shapes are identified with bash -n and excluded from the over-strict count):

tree shapes bash writes the protected file but the verdict is allow valid shapes denied although bash never writes it
main 34 10
#11765 19 15
this branch 10 18

The 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 a cd whose effect is undecided. The 10 residual fail-open shapes are a different defect that neither this PR nor #11765 claims: the analysis assumes a cd succeeds, so cd .qwen ; cd x ; echo {} > settings.json (when x does not exist) and cd .qwen ; cd x || echo {} > settings.json (where || means the cd failed) 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 → deny for the issue's command, with every other permissions test unchanged.

Tested on

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

packages/core permissions suite 1051/1051 pass on macOS arm64; tsc --noEmit, eslint and prettier clean.

Environment (optional)

Unit tests only.

Risk & Scope

  • Main risk or tradeoff: a cd whose 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.
  • Not validated / out of scope: the 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).
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #12246.

Stacked on #11765 — its commits are the first part of this branch, so the diff against main includes the dual-reading splitter that this change flags boundaries in. Review or land that one first; this PR rebases away to the two commits fix(core): keep both cwds when one reading alone ends a cd and test(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 的影响。

如何验证

cd packages/core && npx vitest run src/permissions/ --reporter=dot

两个新增回归用例在去掉本 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 识别并排除在过严统计之外):

分支 bash 真写了受保护文件但裁决为 allow 合法命令被误判为 deny
main 34 10
#11765 19 15
本分支 10 18

新增的 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 跟踪。

风险与范围

关联 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 两个提交。

…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.
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.
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.
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.
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.
…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).
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

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

Copy link
Copy Markdown
Contributor Author

Superseded by #12685. This PR was stacked on #11765, which has since merged, so the branch no longer applies. #12685 starts from current main and also fixes R1-16 and R1-17 reported here. Both reproduce on main after #11765 merged.

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.

permissions: a ; seen only by the pre-fix quote reading marks a backgrounded cd as foreground, so a protected write resolves to the wrong path

2 participants