Skip to content

feat(core): let plan mode vouch for extra read-only shell roots - #9950

Closed
TianYuan1024 wants to merge 8 commits into
QwenLM:mainfrom
TianYuan1024:feat/plan-mode-read-only-shell-roots
Closed

TianYuan1024 wants to merge 8 commits into
QwenLM:mainfrom
TianYuan1024:feat/plan-mode-read-only-shell-roots

Conversation

@TianYuan1024

@TianYuan1024 TianYuan1024 commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

This PR has been split. Three classifier fixes that change behaviour for built-in roots even with this setting unconfigured are now separate PRs, reviewable and revertable on their own:

PR What it fixes
#10028 statements and redirects nested in a heredoc node (cat <<EOF && rm -rf build, cat <<EOF >out.txt)
#10029 substitutions hidden in pattern words and heredoc bodies
#10030 sub-commands kept in the confirmation scope after a state planter

This branch carries those three as its first three commits so its tests run, then one commit of new work — feat(core): let plan mode vouch for extra read-only shell roots. Review that commit; the rest belongs to the PRs above and will disappear from this diff once they land.

What this PR does

Adds a setting that lets you tell Plan Mode which extra root commands are read-only, so a project-specific CLI stops triggering an approval prompt on every single read.

{
  "permissions": {
    "planMode": {
      "extraReadOnlyCommands": ["ib"]
    }
  }
}

A listed root joins the classifier's built-in read-only set. The entry is consulted at the very end of the dispatch chain, after every root the classifier already understands has been matched, so it can only ever add to the read-only set — listing rm, git, or tee leaves rm -rf build, git push, and tee out.txt classified exactly as before. Redirections, command substitution, environment-assignment prefixes, and pipes into unknown commands are untouched: with ib listed, ib list runs silently while ib list > out.txt is still blocked as state-modifying and ib list $(whoami) still prompts.

What bounds the vouch

The interesting question is not "which names are allowed" but "what stops a vouch from laundering a write". Four layers, in decreasing order of how much weight they carry:

1. Only the user can vouch. The setting is read from user, system, and system-default scopes only; a workspace .qwen/settings.json is stripped during the merge and a startup warning names the key. This is the load-bearing one. A cloned repository cannot vouch for itself, which means the lists below guard against user error rather than against an adversary who picks the entry.

2. The invocation has to be one the classifier can read. A vouch says "this binary only reads"; it can never say "and so does whatever I pass it". So the vouch is honoured only when every argument is a plain literal word that names no command Qwen Code knows. ib exec rm -rf build prompts even though ib is vouched and ib exec is not otherwise special — the refusal is on shape, so a launcher nobody enumerated cannot use the vouch to smuggle a known command past the analysis.

3. A refusal floor of 183 roots. Shell and language interpreters, launchers, build and package tools, and builtins that rebind name resolution can never be vouched. Their payload is a code string, a Makefile recipe, or a package downloaded mid-command — never argv — so no argument inspection can see it. A companion regex matches versioned spellings by family (python3.12, gcc-13, luajit-2.1.0-beta3, go1.22) rather than release by release.

This list is a floor under foreseeable mistakes, not a boundary, and I want to be explicit about that rather than imply otherwise: it cannot be closed by enumeration. uv run evil.py and a custom CLI's ib get ./report.json are structurally identical, so no classifier can tell a user who vouched a payload-executor from one who vouched their own read-only tool. Layer 1 is what makes that acceptable — the wrong assertion is the user's own, in their own settings file.

4. Git gets special handling, because a vouched wrapper of git is a case this setting explicitly supports. When a vouched root's first non-flag argument is a git verb, the whole invocation is screened by git's own evaluator — write verbs, branch -D, --output, the %G… signature formats. A vouched root also inherits git's planted-config gate, extended for the wrapper path to every repository-local key that makes a read verb execute a program: diff.external, core.fsmonitor, a textconv driver, a clean/smudge filter, gpg.program, and !-prefixed shell aliases. Repositories that plant none of these — the ordinary case — are unaffected.

Scope

The setting applies only in Plan Mode, read through one accessor that returns an empty set in every other approval mode, so vouching for a CLI while planning never widens auto-approval in default, auto-edit, auto, or yolo mode. Entries are dropped in --bare and safe mode, matching permissions.autoMode.

An entry vouches for the entire binary. Qwen Code cannot see inside a custom CLI, so if it has mutating sub-commands, listing it silences the prompt for those too. That tradeoff is documented.

Two fixes that are not about this setting

Both affect built-in roots today; they are here because the vouch turns each from a prompt into an unattended run.

  • Statements nested inside a heredoc redirect were dropped from the analysis. tree-sitter parses whatever follows the opener on the same line inside the redirect node, and the redirected_statement arm filtered every redirect child out before evaluation. cat <<EOF && for ((i=0;i<1;i++)); do rm -rf build; done classified read-only with no vouch involved. Now a skip-list of inert redirect leaves, with unrecognised shapes floored at unknown so an unanticipated one prompts instead of vanishing.
  • The confirmation dialog classified each sub-command against the original cwd. cd /hostile && git status && curl x dropped git status from the scope the user approved, then ran it in the planted repository. Both call sites now stop dropping sub-commands once an earlier one has planted state (cd, export, …), mirroring PermissionManager.evaluateCompoundCommand.

Why it's needed

Plan Mode decides whether a shell command is read-only by matching its root against a hardcoded set. A binary outside that set cannot be judged, so it classifies as unknown and triggers the "could not determine whether this shell command is read-only" prompt. Plan-mode shell confirmations deliberately hide "Always allow" and accept a one-time approval only, so that prompt reappears for every invocation, forever.

For a team whose Plan Mode sessions run through a project-specific read-only CLI, every read needs a manual click while the built-in equivalents (cat, grep, git status) pass silently. There is no way out today: Plan Mode intentionally overrides permissions.allow for shell, and PreToolUse hooks run after the permission decision and can only deny or ask. A PermissionRequest hook can suppress the prompt, but only by writing a hook that re-implements the classification.

Reviewer Test Plan

How to verify

The full scripted plan is committed at .qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md. It uses a scratch QWEN_HOME so the vouch never touches your real settings, and notes the /plan step every restart needs — approval mode is session state, so a post-restart case run without it silently exercises the default mode instead.

Create a scratch workspace with a fake read-only CLI on PATH (printf '#!/bin/sh\necho ok\n' > ib && chmod +x ib), put permissions.planMode.extraReadOnlyCommands: ["ib"] in $QWEN_HOME/settings.json, and enter Plan Mode with /plan.

Ask the model to run ib domain list: it should run with no confirmation prompt. Remove the key and repeat — the prompt appears, and appears again on every identical invocation.

Confirm the guardrails hold. ib domain list > out.txt must be rejected as state-modifying, not prompted. ib domain list $(whoami) and IB_TOKEN=x ib domain list must still prompt. ib domain list | badcmd must still prompt, while ib domain list | wc -l runs silently.

Confirm the safety net cannot be switched off from settings. Add "bash", "rm", "git", "make", and "uv" and restart: bash -c 'echo hi', make, and uv run x.py must still prompt; rm -rf tmp and git push origin main must still be blocked.

Confirm a workspace cannot vouch for itself: move the settings file into the repository's own .qwen/, restart, and the prompt returns with a startup warning naming permissions.planMode.

Confirm the scope: /approval-mode default, then ib domain list — the normal shell confirmation must appear. /plan again and it stops prompting, with no restart.

Finally, confirm invalid entries are ignored rather than fatal: set the list to ["", " ", "ib list", "/usr/local/bin/ib", "ib;rm", "IB"] and restart. The CLI starts normally and ib domain list runs without a prompt from the "IB" entry alone.

Evidence (Before & After)

N/A — no TUI change. The user-visible difference is the absence of a confirmation prompt, covered by the steps above and by unit tests.

packages/core: npx vitest run src/utils/shellAstParser.test.ts src/config/config.test.ts \
  src/core/plan-mode-shell-policy.test.ts src/tools/shell.test.ts \
  src/tools/monitor.test.ts src/permissions/permission-manager.test.ts

 Test Files  6 passed (6)
      Tests  2202 passed (2202)

packages/cli: npx vitest run src/config/settings.test.ts src/config/settingsSchema.test.ts \
  src/config/config.test.ts src/config/settingsUtils.test.ts

 Test Files  4 passed (4)
      Tests  625 passed (625)

shellAstParser.test.ts carries 889 of those. The refusal floor is pinned entry by entry with a two-way ratchet — a deleted entry fails containment, an undeclared addition fails the count — verified with a mutant that drops one name and fails the suite.

Tested on

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

Risk & Scope

  • Main risk. A listed root vouches for the whole binary, including any mutating sub-commands. The classifier cannot see inside a custom CLI, and the refusal floor cannot be completed by enumeration — see layer 3 above. What makes this a supportable tradeoff rather than a hole is that only the user's own settings can make the assertion. If maintainers would rather the setting refuse to load roots it cannot verify at all, that is a reasonable product call and I am happy to implement it; it is a different feature, so I have not smuggled it in.
  • Not byte-for-byte inert. With no setting configured the vouch path is inert, but the two fixes above change classification for built-in roots — that is their point. cat <<EOF && … shapes that previously classified read-only now classify write or unknown, and confirmation dialogs after a cd/export list more sub-commands than before.
  • Costs a prompt in three places, by design. A vouched CLI whose own verb collides with a git write verb (ib add, ib tag); one that spells its config flag -c or -C; and one whose argument names a command the classifier knows (ib exec watch). All three are documented. The attached spellings (-C/hostile, -ccore.fsmonitor=…) are refused only once the invocation is already git-shaped, so a CLI with its own -cp or -Cdir flag is unaffected.
  • Out of scope. Honouring permissions.allow for unknown-classified shell commands in Plan Mode (changes Plan Mode's trust model). Sub-command scoping. The deprecated regex fallback used when tree-sitter is unavailable is left alone deliberately — it ignores the setting and keeps prompting, which fails closed. Extending getLocalGitConfigRisk's new key set to literal git is also left out: git lfs install --local writes filter.lfs.clean, so that would downgrade git diff in a large share of real checkouts and wants its own PR. A test pins the git-lfs case so this cannot drift.
  • Breaking changes: none. The setting is new and optional.
中文说明

这个 PR 做了什么

新增一个配置项,让你告诉 Plan Mode 哪些额外的根命令是只读的,这样项目专用 CLI 就不会在每次读取时都弹出确认框。

{
  "permissions": {
    "planMode": {
      "extraReadOnlyCommands": ["ib"]
    }
  }
}

列出的根命令会并入分类器内置的只读集合。该配置在分发链的最末端才被查询——排在分类器已经理解的所有根命令之后——所以它只能做加法:即使把 rm、git、tee 写进去,rm -rf build、git push、tee out.txt 的分类也完全不变。重定向、命令替换、环境变量赋值前缀、管道进入未知命令,这些规则一律不受影响:配置了 ib 之后,ib list 静默执行,而 ib list > out.txt 仍被判定为修改状态而拦截,ib list $(whoami) 仍会弹窗。

什么在约束这份背书

真正的问题不是"允许哪些名字",而是"什么阻止一次背书被用来洗白一次写操作"。四层防线,按承重程度递减:

1. 只有用户本人能背书。 该配置仅从 user、system、system-default 作用域读取;workspace 的 .qwen/settings.json 在合并阶段被剥离,并在启动时给出点名该 key 的警告。这一层最承重:被克隆的仓库无法为自己背书,也就意味着下面几层防的是用户误用,而不是能自行挑选条目的攻击者。

2. 调用形态必须是分类器读得懂的。 一次背书说的是"这个二进制只读",它永远说不了"以及我传给它的任何东西也只读"。因此只有当每个参数都是纯字面量词、且不指向任何 Qwen Code 认识的命令时,背书才生效。即便 ib 已被背书、ib exec 也没有任何特殊性,ib exec rm -rf build 依然弹窗——拒绝依据的是形态,所以一个没人枚举过的启动器无法借背书把已知命令偷渡过分析。

3. 183 个根命令的拒绝底线。 shell 与语言解释器、启动器、构建与包管理工具、以及重绑定名称解析的内建命令,永远无法被背书。它们的载荷是代码字符串、Makefile 配方,或命令执行中途下载的包——从来不是 argv——所以任何参数检查都看不见它。配套正则按家族匹配带版本号的拼写(python3.12、gcc-13、luajit-2.1.0-beta3、go1.22),而不是逐个版本追加。

这份清单是可预见误用之下的底线,不是边界,我想把这点明说而不是暗示相反:它无法靠枚举收敛。uv run evil.py 与自定义 CLI 的 ib get ./report.json 在结构上完全相同,所以没有任何分类器能区分"背书了一个载荷执行器的用户"和"背书了自己只读工具的用户"。让这件事可以接受的是第 1 层——错误的断言出自用户本人,写在他们自己的配置文件里。

4. git 有专门处理,因为"为 git 包装器背书"正是这个配置明确要支持的场景。当被背书根命令的第一个非 flag 参数是 git 子命令时,整个调用会交给 git 自己的求值器筛查——写类子命令、branch -D、--output、%G… 签名格式。被背书的根命令同时继承 git 的植入配置门禁,并为包装器路径扩展到所有"能让读类子命令执行程序"的仓库级配置键:diff.external、core.fsmonitor、textconv 驱动、clean/smudge filter、gpg.program,以及 ! 前缀的 shell 别名。不含这些配置的仓库——也就是绝大多数情况——完全不受影响。

作用范围

该配置仅在 Plan Mode 生效,通过唯一一个访问器读取,该访问器在其他任何审批模式下都返回空集合。因此在规划时为某个 CLI 背书,绝不会扩大 default、auto-edit、auto、yolo 模式下的自动批准范围。--bare 与 safe mode 下条目被丢弃,与 permissions.autoMode 保持一致。

一个条目背书的是整个二进制。Qwen Code 看不进自定义 CLI 内部,所以如果它带有修改类子命令,列出它同样会让那些子命令免于弹窗。这一取舍已写入文档。

两个与本配置无关的修复

两者今天就影响内置根命令;放在这里是因为背书会把它们各自从"弹窗"变成"无人值守执行"。

  • 嵌套在 heredoc 重定向内的语句被分析漏掉了。 tree-sitter 会把 heredoc 开启符同一行之后的内容解析到重定向节点内部,而 redirected_statement 分支在求值前把所有重定向子节点都过滤掉了。于是 cat <<EOF && for ((i=0;i<1;i++)); do rm -rf build; done 在完全不涉及背书的情况下被判为 read-only。现改为惰性重定向叶子节点的跳过清单,未识别的形态一律下压到 unknown——这样意料之外的形态会弹窗,而不是凭空消失。
  • 确认对话框按原始 cwd 分类每个子命令。 cd /hostile && git status && curl x 会把 git status 从用户批准的范围中剔除,然后在被植入的仓库里执行它。现在两处调用点在前序子命令植入状态(cd、export 等)之后都不再剔除后续子命令,与 PermissionManager.evaluateCompoundCommand 保持一致。

为什么需要它

Plan Mode 判断一条 shell 命令是否只读,靠的是拿它的根命令去匹配一个硬编码集合。集合之外的二进制无从判断,于是被归为 unknown 并触发"无法确定该 shell 命令是否只读"的弹窗。而 Plan Mode 的 shell 确认框刻意隐藏了"始终允许"、只接受一次性批准,所以这个弹窗会在每一次调用时重新出现,永远如此。

对于 Plan Mode 会话要走项目专用只读 CLI 的团队,每一次读取都要手动点一下,而内置的等价物(cat、grep、git status)却静默通过。今天没有绕过的办法:Plan Mode 有意对 shell 覆盖 permissions.allow,而 PreToolUse hook 在权限决策之后才运行、且只能拒绝或询问。PermissionRequest hook 确实能压掉弹窗,但代价是写一个把分类逻辑重新实现一遍的 hook。

风险与范围

  • 主要风险。 列出的根命令背书的是整个二进制,包含其修改类子命令。分类器看不进自定义 CLI,且拒绝底线无法靠枚举收敛(见上文第 3 层)。让这成为可支撑的取舍而非漏洞的,是"只有用户自己的配置才能做出这个断言"。如果维护者更希望该配置对无法验证的根命令直接拒绝加载,那是一个合理的产品决策,我很乐意实现;但那是另一个功能,所以我没有夹带进来。
  • 并非逐字节无影响。 不配置该项时背书路径是惰性的,但上述两个修复确实会改变内置根命令的分类——那正是它们的目的。此前被判 read-only 的 cat <<EOF && … 形态现在会被判为 write 或 unknown;cd/export 之后的确认对话框会列出比以前更多的子命令。
  • 有三处按设计会多一次弹窗。 自身子命令与 git 写类子命令重名的 CLI(ib add、ib tag);把自己的配置 flag 拼作 -c 或 -C 的 CLI;参数指向分类器已知命令的 CLI(ib exec watch)。三者均已写入文档。贴写形式(-C/hostile、-ccore.fsmonitor=…)只在调用已呈 git 形态时才拒绝,所以带 -cp、-Cdir 这类自有 flag 的 CLI 不受影响。
  • 明确不在范围内。 让 Plan Mode 对 unknown 分类的 shell 命令遵循 permissions.allow(会改变 Plan Mode 的信任模型)。子命令级作用域。tree-sitter 不可用时的已废弃正则回退路径刻意不动——它忽略该配置并继续弹窗,属于 fail-closed。把 getLocalGitConfigRisk 新增的键集扩展到字面量 git 也刻意排除:git lfs install --local 会写入 filter.lfs.clean,那样会在相当比例的真实检出中降级 git diff,应当单开一个 PR。已有测试钉住 git-lfs 这一情形,防止漂移。
  • 破坏性变更: 无。该配置是全新且可选的。

@TianYuan1024

Copy link
Copy Markdown
Contributor Author

Thanks — both classifier findings are fixed in 2f68dc7c50, and I ran the verification the review asked for.

1. Attached git short options — verified, then hardened anyway.

Real git rejects the attached form. handle_options in git.c matches these with strcmp, not a prefix, so against git 2.50.1:

$ git -C./r1 status
unknown option: -C./r1
$ git -ccore.fsmonitor=./evil.sh config core.pager
unknown option: -ccore.fsmonitor=./evil.sh          (exit 129)

So there was no live bypass: a wrapper that forwards argv unchanged (exec git "$@", the e2e plan's own example) hands git a token git refuses. But the screen's premise is only that the wrapper forwards roughly as-is, and a wrapper that normalises its own argv would hand git back the spaced form that does plant the config. So the attached forms are now refused.

Where they are refused matters. I did not widen GIT_REDIRECTING_GLOBAL_OPTION to prefix-match -c/-C: that list runs over every argument of every vouched root, so prefix-matching there costs a prompt for any CLI spelling -cp, -classpath, -Cdir, or -count=5 — a cost paid by CLIs that have nothing to do with git. The check went into vouchedGitShapeIsSafe instead, screening the options that sit before the verb, which is the one place the invocation is already known to be git-shaped. Net effect: gitw -C/hostile status and gitw -ccore.fsmonitor=./evil.sh status are refused; ib -cp lib get and ib -Cdir get still run silently. Git frontends lose nothing real, since git rejects the form itself.

Pinned both directions, per your ask that either outcome be pinned: the five attached-form witnesses joined the wrapper refusal table, a new test asserts the non-git CLIs keep their -c…/-C… flags, and a third pins that literal git -C. status is never read-only — so the classifier is documented as no more permissive than the binary it models.

2. hash — fixed, and unset with it.

You were right, and unset had the same hole. hash -p ./evil/git git && git status repoints which binary the name git resolves to; unset PATH && … repoints all of them. Neither touches the directory or the environment variables the planted-config gate probes, so the later part still classifies read-only on its own text and was dropped from the scope the user approves — while executing under the planted resolution. Both are now in plantsStateForLaterCommands, with hash -p … and unset PATH … pinned in shell.test.ts alongside the existing cd/export witnesses.

That predicate is duplicated in monitor.test.ts's module mock (deliberately — a stub returning false would hide the defect the mock exists to expose), which makes silent drift a real risk. So the list also got a two-way ratchet in the parser suite, matching the refusal-floor pattern: all 15 planters must be recognised, and prefix collisions (cdr, exports, hashcat, unsetenv, setup) must not be.

Both fixes were mutant-tested — reverting either fails exactly the six new assertions and nothing else.

3. Splitting the two bundled classifier fixes — your call, not mine to make unilaterally.

Still happy to split on request. I'd only note the ordering cost: the confirmation-scope fix is what this PR's hash/unset change extends, so splitting means the vouch work lands on top of the split PR or duplicates it. Say the word and I'll do it either way.

On the red CI leg: Test (ubuntu-latest, Node 22.x) failed on src/serve/acp-http/transport.test.ts > keeps a shared session/list scan alive when one connection is destroyed — a race where the scan resolves after teardown already returned -32603. This PR touches zero files under src/serve/ (git diff --name-only origin/main...HEAD | grep -c serve/ → 0), and that file was last modified by unrelated upstream commits. The push above starts a fresh run.

Verification on the merged tree (origin/main merged in first): packages/core six affected suites 2202 passed, shellAstParser.test.ts now 889; typecheck clean; prettier/eslint clean.

The PR body now carries the Chinese <details> translation Stage 1 asked for. On the ## Linked Issues section: this was submitted as standalone work with no linked issue, so I've left it out rather than assert a link the author didn't make.

中文说明

两个分类器问题都已在 2f68dc7c50 修复,并按评审要求做了实证验证。

1. git 贴写短选项——先验证,再照样加固。

真实 git 拒绝贴写形式。git.c 的 handle_options 用 strcmp 精确比较而非前缀匹配,对 git 2.50.1:

$ git -C./r1 status
unknown option: -C./r1
$ git -ccore.fsmonitor=./evil.sh config core.pager
unknown option: -ccore.fsmonitor=./evil.sh          (exit 129)

所以并不存在现成的绕过:原样转发 argv 的包装器(exec git "$@",也就是 e2e 计划里的示例)交给 git 的是一个 git 会拒绝的 token。但这道筛查的前提只是"包装器大致原样转发",而一个会规范化自身 argv 的包装器,会把贴写形式还原成那个确实能植入配置的分隔形式。因此贴写形式现在也被拒绝。

在哪里拒绝很关键。 我没有把 GIT_REDIRECTING_GLOBAL_OPTION 放宽成前缀匹配 -c/-C:那份清单会作用于每个被背书根命令的每个参数,在那里做前缀匹配意味着任何拼作 -cp、-classpath、-Cdir、-count=5 的 CLI 都要多一次弹窗——而这些 CLI 与 git 毫无关系。检查改放进 vouchedGitShapeIsSafe,筛查位于动词之前的选项,那里是唯一已确知调用呈 git 形态的位置。净效果:gitw -C/hostile status 与 gitw -ccore.fsmonitor=./evil.sh status 被拒绝,而 ib -cp lib get、ib -Cdir get 仍静默执行。git 前端并无实际损失,因为 git 自己就拒绝这种形式。

按你"无论结果如何都应补进测试"的要求,两个方向都已钉扎:5 个贴写形式witness 并入包装器拒绝表;新增测试断言非 git 的 CLI 保留自己的 -c…/-C… flag;第三个测试钉住字面量 git -C. status 永远不是 read-only——从而以测试形式记录:分类器不会比它所建模的二进制更宽松。

2. hash——已修,并连带 unset。

你说得对,而且 unset 有同样的漏洞。hash -p ./evil/git git && git status 重指 git 这个名字解析到哪个二进制;unset PATH && … 则重指全部。两者都不触碰植入配置门禁所探测的目录或环境变量,所以后半段仍按自身文本被判为只读、从用户批准的范围中被剔除——却在被植入的解析结果下执行。两者现已加入 plantsStateForLaterCommands,并在 shell.test.ts 中与既有的 cd/export witness 并列钉住 hash -p … 与 unset PATH …。

该谓词在 monitor.test.ts 的模块 mock 中有一份副本(这是刻意的——返回 false 的 stub 会掩盖该 mock 本就要暴露的缺陷),因此静默漂移是真实风险。于是这份清单也按拒绝底线的模式加了双向棘轮:15 个 planter 必须全部被识别,且前缀近似词(cdr、exports、hashcat、unsetenv、setup)必须不被识别。

两个修复都做了变异测试——回退任一处,恰好只有新增的那 6 条断言失败。

3. 拆分那两个顺带的分类器修复——由维护者决定,我不擅自动手。

随时可以按要求拆。仅提示一个顺序上的成本:确认范围那个修复正是本 PR 的 hash/unset 改动所扩展的对象,所以拆分意味着背书这部分工作要落在被拆出的 PR 之上,或者重复一遍。你们定,两种都行。

关于 CI 红灯: Test (ubuntu-latest, Node 22.x) 失败在 src/serve/acp-http/transport.test.ts > keeps a shared session/list scan alive when one connection is destroyed——一个竞态,scan 在拆连接路径已返回 -32603 之后才 resolve。本 PR 不触碰 src/serve/ 下任何文件(git diff --name-only origin/main...HEAD | grep -c serve/ → 0),且该文件最近的改动来自无关的上游提交。上面这次推送会触发新的运行。

合并 origin/main 后在合并树上的验证:packages/core 六个受影响套件 2202 passed,shellAstParser.test.ts 现为 889;typecheck 干净;prettier/eslint 干净。

PR 正文已补上 Stage 1 要求的中文 <details> 翻译。关于 ## Linked Issues:本 PR 是作为独立工作提交的,没有关联 issue,因此我没有填写该节,而不是去断言一个作者并未建立的关联。

@TianYuan1024

Copy link
Copy Markdown
Contributor Author

All nine Critical findings and all ten Suggestions are addressed in 69f29b8181. Most collapsed into two root causes, which is how they were fixed.

Root cause 1 — R1-1 / R1-2: the planter predicate was text, not shape

You were right that the list can't be finished. The predicate is now decided on the classifier's own parse: only a plain command node whose resolved name is not a planter is cleared, and every other shape fails closed — variable_assignment, declaration_command, unset_command, function_definition, negated_command, blocks, keywords, subshells. That closes the missing-name class (read/mapfile/readarray/getopts are now planters too) and all seven hiding forms in one decision. Every witness row is pinned, including \cd, "cd", ! cd, FOO=1 cd, command cd, { cd /hostile; }, if true; then cd; fi, and git() { … }.

R1-2 was orthogonal, as you said, and survived the shape fix: plants is now computed before the drop decision and a planter is never dropped. cd /hostile && git status shows cd, git again.

One correction to the R1-1 report: unsetenv x is not a prefix collision — tree-sitter-bash parses it as an unset_command, so it plants correctly and legitimately. I pinned it as a planter rather than as an inert control.

Root cause 2 — R1-3 / R1-7: the git-shape test was too narrow, and failing it skipped all git screening

R1-3 and R1-7 entrance 1 are the same defect seen from two sides: a verb the shape test didn't recognise meant no screening at all. Recognition now runs against git's complete 170-command vocabulary (git --list-cmds=builtins,main,others), so every real verb reaches evaluateGitSafety and its unknown floor. All ten of your R1-3 witnesses now refuse. OTHER_WRITE_GIT_SUBCOMMAND is deleted — it was a partial list of the thing that is now complete.

For R1-7 I did not take the suggested regex as written. ATTACHED_GIT_REDIRECTING_SHORT_OPTION is /^-[Cc].+/, so adding it to the per-argument check refuses -cp, -classpath, and -Cdir — the flip-verification claiming those stay read-only isn't reproducible. What I did instead targets the two shapes the payload actually needs:

  • every vouched invocation: refuse a single-dash argument that is an attached path (-C…) or carries a key=value (-c…=…, -pc…=…). Closes entrances 1, 2 and 4.
  • git-shaped or verb-less invocations: additionally refuse any single-dash cluster carrying a c/C, which closes entrance 3's -pC/hostile form. Exempts all-digit arguments, since -10 is git's own git log -<n>.

Scanning now covers every argument up to -- and the verbIndex < 0 path. Cost, stated precisely: -cp and -classpath still work; -Cdir and -count=5 now prompt. Documented.

The rest

  • R1-4 — extended to gpg.*.program, core.sshCommand/askPass/gitProxy/pager, credential.helper, remote.*.uploadpack/receivepack, pager.*, diff.*.command. The alias arm now flags a value starting with ! or -; your point that git re-parses a dash-leading alias in global-option context is the part I'd missed. Local/worktree scope filtering keeps a user's global core.pager = delta out of it.
  • R1-5 — [^{}]* → [\s\S]*. Both executed witnesses pinned.
  • R1-6 — all 34 names added, plus pypy and the shell families in VERSIONED_INTERPRETER; floor is 217 and the two-way ratchet pins every one.
  • R1-8 — --bare added, gitw --bare diff main..feature pinned.
  • R1-9 — acceptance-side strip is now process.platform === 'win32'. The refusal side still strips everywhere; git.exe push stays refused. Docs corrected.
  • R1-13 — probe hoisted and memoised per classification, and ordered after the free changedDirectory check.
  • R1-12 (both) — you were right that both gates were dead code as far as the suites were concerned. Monitor now has three planter cases and shell has an allow-rule case; both mutation-verified (deleting the gate fails exactly those, and nothing else). Note the monitor cases need npm run build to stay non-read-only, or the "nothing left, show everything" fallback masks the gate — my first attempt at these tests survived the mutant for exactly that reason.
  • R1-16 — the mock now spreads importOriginal() instead of copying the regex.
  • R1-17 — cat <<$(rm -rf build), the backtick form, and the vouched form all pinned at unknown.
  • R1-14 / R1-19 / R1-20 — all three doc statements corrected as suggested; tools.approvalMode is indeed a persistable setting and the parenthetical about the probed directory was simply wrong.
  • R1-15 — design doc gains the fourth bullet.

One decline, with reasoning

R1-18 — I did not add export/unset/declare/readonly/typeset/local to NEVER_READ_ONLY_ROOT_COMMANDS. You flagged the cost yourself: it also makes namesAKnownCommand('export') true, so a vouched CLI's own mytool export data starts prompting — and export is a far more plausible CLI verb than add or tag. Since there's no exploitable hole today and the concern is a future regression, I bought the same protection at zero cost: the JSDoc that falsely credited the name list is corrected to say these six are refused by parse shape, and a new test pins all six at unknown both alone and in the export GIT_DIR=… && gitw status compound. A read-only arm for declaration_command now fails the suite instead of shipping quietly. If you'd rather pay the prompt, say so and I'll move them.

Verification

packages/core seven affected suites 2620 passed (shellAstParser.test.ts 926); packages/cli five config suites 1317 passed; typecheck clean; prettier/eslint clean. A throwaway probe asserted all 81 of your witness rows plus controls proving the feature still works (ib list, ib domain get, ib -cp lib get, ib export data, gitw status, gitw log --oneline -10, gitw --version) — deleted after use.

On the review's own gaps: the no such file or directory Test Plan errors are a cwd artifact — those paths are relative to packages/core, which is where AGENTS.md says to run them.

中文说明

九个 Critical 与十个 Suggestion 全部在 69f29b8181 中处理。大部分归结为两个根因,修复也是按根因做的。

根因一 —— R1-1 / R1-2:planter 判定基于文本而非形态

你说得对,这份清单无法枚举完。判定现在改为基于分类器自己的解析结果:只有"解析出的名字不是 planter 的普通 command 节点"才被放行,其余所有形态一律 fail closed —— variable_assignment、declaration_command、unset_command、function_definition、negated_command、代码块、关键字语句、子 shell。这一个决策同时关闭了"漏名字"这一类(read/mapfile/readarray/getopts 现在也是 planter)和全部七种藏匿形式。所有 witness 都已钉扎。

R1-2 如你所说是正交问题,且在形态修复后依然存在:plants 现在在剔除判定之前计算,且 planter 永不被剔除。cd /hostile && git status 重新显示 cd, git。

对 R1-1 报告的一处更正:unsetenv x 不是前缀误撞——tree-sitter-bash 把它解析为 unset_command,它确实是一个 unset 变体,因此被判为 planter 是正确的。我把它钉成 planter 而非惰性对照。

根因二 —— R1-3 / R1-7:git 形态识别过窄,一旦不匹配就跳过全部 git 筛查

R1-3 与 R1-7 的入口 1 是同一缺陷的两个侧面:形态识别不认识的动词意味着完全不筛查。识别现在对照 git 的完整 170 条命令表(git --list-cmds=builtins,main,others),每个真实动词都会进入 evaluateGitSafety 及其 unknown 底线。R1-3 的十个 witness 现在全部拒绝。OTHER_WRITE_GIT_SUBCOMMAND 已删除——它正是那份现已完整的清单的残缺版本。

R1-7 我没有照搬建议的正则。ATTACHED_GIT_REDIRECTING_SHORT_OPTION 是 /^-[Cc].+/,把它加进逐参数检查会拒绝 -cp、-classpath、-Cdir——"这些仍保持 read-only"的 flip 验证结论无法复现。我改为针对载荷真正需要的两种形状:

  • 所有被背书的调用:拒绝"贴写路径"(-C…)或携带 key=value(-c…=…、-pc…=…)的单横线参数。关闭入口 1、2、4。
  • git 形态或无动词的调用:额外拒绝任何含 c/C 的单横线簇,关闭入口 3 的 -pC/hostile。全数字参数豁免,因为 -10 是 git 自己的 git log -<n>。

扫描范围现已覆盖 -- 之前的全部参数以及 verbIndex < 0 路径。代价精确说明:-cp、-classpath 仍可用;-Cdir、-count=5 现在会弹窗。已写入文档。

其余各项

  • R1-4 —— 扩展至 gpg.*.program、core.sshCommand/askPass/gitProxy/pager、credential.helper、remote.*.uploadpack/receivepack、pager.*、diff.*.command。alias 分支现在对以 ! 或 - 开头的值置位;"git 会在全局选项上下文重新解析以横线开头的 alias"是我此前漏掉的关键点。local/worktree 作用域过滤保证用户全局的 core.pager = delta 不会误触。
  • R1-5 —— [^{}]* 改为 [\s\S]*,两个 witness 均已钉扎。
  • R1-6 —— 34 个名字全部加入,VERSIONED_INTERPRETER 增补 pypy 与 shell 家族;底线现为 217 条,双向棘轮逐条钉扎。
  • R1-8 —— 加入 --bare,gitw --bare diff main..feature 已钉扎。
  • R1-9 —— 接受侧的 .exe 剥离现在限定 process.platform === 'win32';拒绝侧仍全平台剥离,git.exe push 依旧拒绝。文档已改。
  • R1-13 —— 探测提升到循环外并按分类记忆化,且排在免费的 changedDirectory 检查之后。
  • R1-12(两条) —— 你判断正确,两个门禁对测试套件而言都是死代码。monitor 新增三个 planter 用例,shell 新增 allow 规则用例,均经变异验证。补充一点:monitor 的用例必须让 npm run build 保持非只读,否则"全部被剔除就显示全部"的兜底会掩盖门禁——我第一版测试正是因此在变异下存活。
  • R1-16 —— mock 改为展开 importOriginal(),不再复制正则。
  • R1-17 —— cat <<$(rm -rf build)、反引号形式、被背书形式均钉扎为 unknown。
  • R1-14 / R1-19 / R1-20 —— 三处文档表述按建议更正;tools.approvalMode 确实是可持久化设置,关于探测目录的括注也确实是错的。
  • R1-15 —— 设计文档补上第四条。

一处不采纳及理由

R1-18 —— 我没有把 export/unset/declare/readonly/typeset/local 加入 NEVER_READ_ONLY_ROOT_COMMANDS。代价你自己也标注了:这会让 namesAKnownCommand('export') 为真,于是被背书 CLI 自己的 mytool export data 开始弹窗——而 export 作为 CLI 动词远比 add、tag 常见。既然当下没有可利用的漏洞、担心的是未来回归,我用零代价换取了同等保护:把错误归因于名字清单的 JSDoc 更正为"这六个由解析形态拒绝",并新增测试把六者在单独形式与 export GIT_DIR=… && gitw status 复合形式下都钉为 unknown。将来若有人给 declaration_command 加只读分支,会直接测试失败而不是悄悄发布。若你们更愿意付这个弹窗代价,说一声我就挪过去。

验证

packages/core 七个受影响套件 2620 passed(shellAstParser.test.ts 926);packages/cli 五个配置套件 1317 passed;typecheck 干净;prettier/eslint 干净。一次性探测断言了你全部 81 条 witness,外加证明功能仍可用的对照(ib list、ib domain get、ib -cp lib get、ib export data、gitw status、gitw log --oneline -10、gitw --version),用后即删。

关于评审自述的缺口:Test Plan 里的 no such file or directory 是工作目录问题——那些路径相对于 packages/core,也正是 AGENTS.md 要求运行它们的位置。

@TianYuan1024

Copy link
Copy Markdown
Contributor Author

All six Critical findings and all 14 Suggestions are addressed in 6cd0d7cdcc. Two of the Criticals were round-1 fixes of mine that did not work, and one whole class of Suggestions was my own testing being wrong. Taking those first.

Two round-1 fixes that were inert

R1-4 (round 2) — you are right, and I should have checked this empirically. Git normalises the section and name parts of a config key, so core.sshCommand comes back out of --get-regexp as core.sshcommand. I verified against a real repo:

$ git config --local core.sshCommand ./evil.sh && git config --local --show-scope --get-regexp '.*'
local   core.sshcommand ./evil.sh
local   core.hookspath ./hooks

The camelCase branch matched nothing, so sshCommand/askPass/gitProxy never set helperProgram — the round-1 fix for that family was dead on arrival, and only core.pager worked, by accident of already being lowercase. Every key literal is now lowercase, normalised once into a name variable used by both the probe pattern and the loop, so the two layers cannot drift apart again. URL-scoped credential.<url>.helper is matched too.

R2-21 — filter.<driver>.process and core.hooksPath added. Your lfs argument settles the trade-off: this diff already accepts over-refusal for filter.lfs.clean/smudge, so .process is the consistent choice, not a new cost.

R2-38 (--bare pin) — my round-1 reply asserted a pin that does not exist. I wrote gitw --bare diff main..feature in a throwaway probe and reported it as if it were in the suite. It is in the suite now, along with gitw --bare status and gitw --open-files-in-pager log; deleting bare from the alternation fails two tests.

Two root causes

R2-22 — the versioned check was a second hand-written list, so of course it drifted. Your sweep found 183 floor names with a vouchable <name>-9.9 spelling. Rather than extend the alternation with the four families you named, it is now derived from NEVER_READ_ONLY_ROOT_COMMANDS: a name added to the floor is versioned-refused the same day, with nothing to keep in sync. One wrinkle your suggested regex would also have hit — greedy stripping from the first digit turns linux32-9.9 into linux and run09 into run, neither on the floor — so every split point is tried instead. The test is now a sweep over the entire floor in both -9.9 and 9 shapes, not one row per remembered family; deleting the derivation fails 36 tests.

R2-36 — same lesson as R1-1, one level down. The shape rewrite closed non-command shapes but left a word list inside command nodes, and all ten entrances you list were real. Closed on the plant axis rather than by another round of names: a dash-leading word after a resolution prefix plants (no reading past command -p), a redirect target carrying a substitution plants (the INERT_REDIRECT_CHILD split you identified — the safety axis has a second pass, the plant axis does not), and let/trap/enable/fc/unalias/shopt/exec join the list they belonged in. time became a resolution prefix, since bash's time keyword does not fork.

One correction from implementing it: I first added the redirect check in both the command loop and the wrapper arm, and the command-loop copy survived mutation — tree-sitter attaches redirects to the enclosing redirected_statement, never to command, so that branch is unreachable. I removed it rather than ship untested code.

The rest

  • R2-2 — the -- cut now serves only the verb lookup and the option screen; evaluateGitSafety gets the untruncated tail. gitw branch -- newbranch and gitw checkout -- file.txt pinned, with literal git branch -- newbranch pinned at write beside them.
  • R2-37 — svn, cvsserver, citool, gui, instaweb added by hand, and the docblock now says plainly that --list-cmds is structurally blind to exec-fallback porcelains so regenerating cannot close it. (instaweb is absent from --list-cmds on 2.50.1 as well, so this was not a version artifact.)
  • R2-9 — vouchCovers strips both directions on Windows.
  • R2-40 — run-parts, setpriv, schroot, proot on the floor (221 now); split, csplit, mktemp, patch, fallocate, chattr in WRITE_ROOT_COMMAND, which also makes bare split bigfile classify write — correct in itself, as you note.
  • R2-38 (remaining six) — every one confirmed. The two ib rows really did run under withGitw and passed only because ib was unvouched; --git-dir/--work-tree really did share the only line either appeared on; the pushd arm really was pinned by a witness that never reached the gate; the cluster witness really did carry an =. All rebuilt, plus helperProgram now has a row per key family including the dash-alias branch.
  • R2-39 (all seven) — corrected, including the two you flagged as able to motivate removing a guard: the design doc's dispatch snippet now shows vouchCovers with the POSIX asymmetry spelled out and a "do not simplify this away" note, and the namesAKnownCommand example is now rm.exe foo rather than git.exe push, since you are right that the git-shape screen refuses the latter regardless.

Mutation results

Every arm this round touches was mutation-tested; each kills at least one test and none survives:

drop --bare                          -> 2 failed
kill CLUSTERED regex                 -> 1 failed
drop C.+ branch                      -> 1 failed
pass truncated tail to evaluator     -> 1 failed
drop dash-alias branch               -> 1 failed
restore camelCase keys               -> 1 failed
drop filter .process                 -> 1 failed
drop versioned derivation            -> 36 failed
drop svn/gui externals               -> 1 failed
drop dash-after-prefix plant         -> 1 failed
drop redirect-substitution plant     -> 1 failed
drop printf -v plant                 -> 1 failed
drop let|trap|enable|fc|exec|shopt   -> 1 failed
drop time prefix                     -> 1 failed

Verification

origin/main merged first. packages/core six affected suites 2265 passed (shellAstParser.test.ts 946); packages/cli four config suites 625 passed; typecheck clean; prettier/eslint clean.

Nothing declined this round.

中文说明

六个 Critical 与全部 14 个 Suggestion 均已在 6cd0d7cdcc 中处理。其中两个 Critical 是我上一轮的修复根本没生效,另有一整类 Suggestion 是我自己的测试写错了。先说这两件。

上一轮两个失效的修复

R1-4(第二轮)—— 你是对的,这一条我本该实测。 git 会规范化配置键的 section 与 name 部分,所以 core.sshCommand 从 --get-regexp 出来是 core.sshcommand。我在真实仓库验证:

$ git config --local core.sshCommand ./evil.sh && git config --local --show-scope --get-regexp '.*'
local   core.sshcommand ./evil.sh
local   core.hookspath ./hooks

camelCase 分支什么都匹配不到,sshCommand/askPass/gitProxy 从未置位 helperProgram——上一轮针对这一族的修复出生即死,只有 core.pager 碰巧本来就是小写才生效。现在所有键字面量统一小写,并一次性规范化成 name 变量供探测正则与循环共用,两层不可能再漂移。URL 作用域的 credential.<url>.helper 也已覆盖。

R2-21 —— 已加入 filter.<driver>.process 与 core.hooksPath。你关于 lfs 的论证定了这个取舍:本 diff 对 filter.lfs.clean/smudge 已经接受了过度拒绝,那么 .process 是一致选择,而非新增代价。

R2-38(--bare 钉扎)—— 我上一轮回复里声称存在的钉扎并不存在。 我把 gitw --bare diff main..feature 写在了一次性探测里,却当作套件内容汇报。现在它真的在套件里了,另加 gitw --bare status 与 gitw --open-files-in-pager log;从 alternation 删除 bare 会导致两个测试失败。

两个根因

R2-22 —— 版本检查是第二份手写清单,漂移是必然的。 你的扫描发现 183 个 floor 名字的 <name>-9.9 拼写可被背书。我没有按你点名的四个家族去扩充 alternation,而是把它从 NEVER_READ_ONLY_ROOT_COMMANDS 派生:往 floor 加名字的当天,其版本化拼写即被拒绝,无需同步任何东西。有一个坑你建议的正则同样会踩——从第一个数字贪婪剥离会把 linux32-9.9 变成 linux、run09 变成 run,两者都不在 floor——所以改为逐个切分点尝试。测试现在是对整个 floor 的 -9.9 与 9 两种形态全量扫描,而不是每个"想起来的"家族一行;删除派生逻辑会导致 36 个测试失败。

R2-36 —— 与 R1-1 同一教训,只是低了一层。 形态化重写关闭了非 command 形态,却在 command 节点内部留下了词表,你列出的十个入口全部属实。这次同样从 plant 轴上按类关闭,而不是再来一轮加名字:resolution 前缀后出现以横线开头的词即判定为植入(不再尝试读过 command -p);重定向目标含替换即判定为植入(正是你指出的 INERT_REDIRECT_CHILD 分野——safety 轴有第二次求值,plant 轴没有);let/trap/enable/fc/unalias/shopt/exec 归位。time 改为 resolution 前缀,因为 bash 的 time 关键字不 fork。

实现过程中的一处更正:我最初在 command 循环与 wrapper 分支两处都加了重定向检查,而 command 循环那份在变异测试中存活——tree-sitter 把重定向挂在外层 redirected_statement 上,从不挂在 command 上,所以那个分支不可达。我把它删了,而不是发布未经测试的代码。

其余各项

  • R2-2 —— -- 切分现在只服务于动词查找与选项筛查,evaluateGitSafety 拿到未截断的尾部。gitw branch -- newbranch 与 gitw checkout -- file.txt 已钉扎,旁边并钉扎字面量 git branch -- newbranch 为 write。
  • R2-37 —— 手工加入 svn、cvsserver、citool、gui、instaweb,并在文档注释里明说 --list-cmds 对 exec-fallback porcelain 结构性失明,重新生成无法弥补。(instaweb 在 2.50.1 上同样不出现在 --list-cmds 中,所以这不是版本差异。)
  • R2-9 —— vouchCovers 在 Windows 上双向剥离 .exe。
  • R2-40 —— run-parts、setpriv、schroot、proot 进入 floor(现 221 条);split、csplit、mktemp、patch、fallocate、chattr 进入 WRITE_ROOT_COMMAND,这也让裸 split bigfile 归类为 write——正如你所说,这本身就是正确的。
  • R2-38(其余六条) —— 逐条属实。那两个 ib 行确实跑在 withGitw 下、仅因 ib 未被背书而通过;--git-dir/--work-tree 确实共用了它们唯一出现的那一行;pushd 分支确实由一个根本到不了门禁的 witness 钉扎;cluster witness 确实带了 =。全部重建,另外 helperProgram 现在每个键族一行,含 dash-alias 分支。
  • R2-39(全部七条) —— 均已更正,包括你标记为"可能促使他人移除守卫"的两条:设计文档的分发片段现在展示 vouchCovers 并写明 POSIX 侧的不对称与"不要简化掉"的提示;namesAKnownCommand 的示例改为 rm.exe foo 而非 git.exe push——你说得对,后者无论如何都会被 git 形态筛查拒绝。

变异测试结果

本轮触及的每个分支都做了变异测试,各自至少杀死一个测试,无一存活(表见上文英文部分)。

验证

已先合入 origin/main。packages/core 六个受影响套件 2265 passed(shellAstParser.test.ts 946);packages/cli 四个配置套件 625 passed;typecheck 干净;prettier/eslint 干净。

本轮无不采纳项。

tree-sitter parses whatever follows a heredoc opener on the same line *inside*
the `heredoc_redirect` node, beside the body. The `redirected_statement` arm
filtered every redirect child out before evaluation, so both kinds of thing
written there vanished from the analysis:

- a statement — `cat <<EOF && rm -rf build` classified `read-only`, as did the
  `;`, `|`, `||`, `&` spellings and every compound shape (`for`, `if`, `while`,
  a block, a negation, a subshell, a `case`); and
- a redirect — `cat <<EOF >out.txt` and `cat <<EOF 2>out.txt` classified
  `read-only` too, because `evaluateRedirectionSafety` only walks the direct
  children of the `redirected_statement` and never reached inside the heredoc
  node.

Both are now evaluated, each on the right axis: a child that is itself a
redirect goes to `evaluateRedirectionSafety`, anything else goes to
`evaluateStatementSafety`. The inert leaves — the delimiters, the body, a bare
file descriptor — are named in a skip-list rather than the statement shapes
being named in an allow-list, so an unanticipated shape is evaluated and
floored at `unknown` instead of silently dropped. `cat <<EOF 2>&1` stays
read-only: that names a descriptor, not a file.

Both arms are mutation-verified: removing the redirect routing fails 4 tests,
removing the statement walk fails 12.
…odies

Two places where tree-sitter-bash yields a single leaf node, so the
substitution walk finds nothing to collect while bash still runs what is
inside.

The pattern word of `${v%%…}`, `${v%…}`, `${v##…}`, `${v#…}`, `${v^^…}`,
`${v^…}`, `${v,,…}`, `${v,…}` is one leaf, and so is each half of
`${v/pat/rep}` and the operand of `${v:-…}`, `${v:=…}`, `${v:?…}`, `${v:+…}`.
`echo ${x%%$(rm -rf build)}` therefore classified `read-only` and would have
run unattended. Since the collection pass found nothing, an opener still
present in the expansion text is exactly that hidden channel, so the leaf is
refused on the text.

A heredoc body is one leaf too — always for `<<-`, and for `<<` whenever
nothing inside it parsed — and bash expands it before feeding it to stdin.
Expansion there follows double-quote rules, so `$(…)`, backticks and `${v@P}`
run while `<(…)` does not; the body is refused for the first three only. A
quoted delimiter (`<<'EOF'`, `<<"EOF"`, `<<\EOF`) makes the body inert and is
exempted.

`${v@P}` is included in both because a prompt expansion runs any `$(…)` held in
the variable's value, and in a pattern word or a body it is a leaf that the
`@`/`P` child-adjacency check never sees. The regex is deliberately not
anchored to a brace-free span: `${a[${b}]@p}` nests a brace, and a `[^{}]*`
bridge stops at it.

These are leaf fallbacks for sites the node walk cannot reach, so over-refusing
costs at most a prompt. Mutation-verified: neutralising the three regexes fails
28, 10 and 2 tests respectively, and dropping the quoted-delimiter exemption
fails 1.
…n scope

A confirmation dialog is built by splitting a compound command and dropping the
parts that classify read-only, so the user approves only what needs approving.
That is sound only while each part means the same thing alone as it does in
sequence — and after a `cd`, an `export` or a `hash -p` it does not.
`cd /hostile && git status && npm run build` showed the user `npm`, dropped
`git status` as independently read-only, and then ran the whole original
compound, `git status` included, in the planted directory. Approval covered a
command the dialog never showed.

Both call sites — `ShellTool` and `MonitorTool` — now stop dropping
sub-commands once an earlier one has planted state, mirroring
`PermissionManager.evaluateCompoundCommand`. The planter is decided *before*
its own drop decision and is never dropped itself: `cd /hostile` classifies
read-only alone, and it is precisely the segment the user most needs to see.
The gate also covers the allow-rule path, since a `Bash(git *)` rule matches on
a sub-command's own text, which after a `cd` no longer says where it runs.

Whether a segment plants is decided on the parse, not on the raw text. A
leading-word regex cannot be completed: the planter hides behind a block
(`{ cd /hostile; }`), a keyword (`if true; then cd /hostile; fi`), an
assignment prefix (`FOO=1 cd`), a resolution-order prefix (`command -p cd`), a
respelling (`\cd`, `"cd"`), a negation (`! cd`), or a function definition that
rebinds a trusted name (`git() { rm -rf $HOME; }`), and a bare `PATH=evil`
assignment carries no command word at all. So only a plain `command` node whose
resolved name is not a planter is cleared, and every other shape fails closed —
which costs a larger dialog rather than a silently narrowed one.

The planter set covers the builtins that rebind what a later command resolves
to or reads: the `cd`/`export` family, the assigning builtins (`read`,
`mapfile`, `readarray`, `getopts`, `printf -v`), and the resolution-rebinding
ones (`hash`, `alias`, `unalias`, `trap`, `enable`, `fc`, `shopt`, `let`,
`exec`). A substitution in a redirect target plants too: `echo x < $(./evil.sh)`
runs the script before anything after it is classified.

Mutation-verified on both sides: removing either `statePlanted` gate, the
allow-rule guard, or the planter decision itself fails 1–3 tests each.
# Conflicts:
#	packages/core/src/utils/shellAstParser.test.ts
# Conflicts:
#	packages/core/src/utils/shellAstParser.test.ts
Adds `permissions.planMode.extraReadOnlyCommands`, a list of root command names
Plan Mode treats as read-only in addition to its built-in set, so a project's
own read-only CLI stops triggering an approval prompt on every invocation.

A listed root joins the classifier's read-only set at the very end of the
dispatch chain, after every root the classifier already understands has been
matched, so it can only ever add: listing `rm`, `git` or `tee` leaves
`rm -rf build`, `git push` and `tee out.txt` classified exactly as before.
Redirections, command substitution, environment-assignment prefixes and pipes
into unknown commands are untouched.

Four layers bound the vouch, in decreasing order of how much weight they carry:

1. Only the user can make it. The setting is read from user, system and
   system-default scopes only; a workspace `.qwen/settings.json` is stripped
   during the merge with a startup warning. A cloned repository cannot vouch
   for itself, which is what makes the remaining layers a guard against user
   error rather than against an adversary who picks the entry.
2. The invocation must be one the classifier can read: every argument a plain
   literal word naming no command Qwen Code knows. `ib exec rm -rf build`
   prompts even though `ib` is vouched.
3. A refusal floor of 221 roots — interpreters, launchers, build and package
   tools, and the builtins that rebind name resolution — which no caller can
   vouch back in, plus the versioned spellings of every one of them, derived
   from the floor itself rather than a second list.
4. Git-shaped invocations are screened by git's own evaluator against its full
   command vocabulary, and a vouched root inherits git's planted-config gate,
   extended to every repository-local key that makes a read verb execute a
   program.

The setting applies only in Plan Mode, read through one accessor that returns
an empty set in every other approval mode, and is dropped in `--bare` and safe
mode like `permissions.autoMode`.

An entry vouches for the entire binary: Qwen Code cannot see inside a custom
CLI, so a mutating sub-command of a vouched root is silenced too. The refusal
floor is a floor under foreseeable mistakes, not a boundary — `uv run evil.py`
and `ib get ./report.json` are structurally identical — and layer 1 is what
makes that tradeoff supportable. Both are documented rather than implied.
@TianYuan1024
TianYuan1024 force-pushed the feat/plan-mode-read-only-shell-roots branch from 3ddbca1 to 1f247a6 Compare August 25, 2026 15:07
@TianYuan1024
TianYuan1024 requested a review from qqqys as a code owner August 25, 2026 15:07
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

The restack appended the heredoc and hidden-substitution suites onto a
file that already carried them, so three titles existed twice and
`vitest/no-identical-title` failed the lint lane.

Both surviving copies are the richer ones: the heredoc-body suite kept
here is a strict superset of the deleted copy (it also pins the vouched
`extraReadOnlyRoots` shapes), and the pattern-word suite was
byte-identical. No assertion is lost.
@TianYuan1024

Copy link
Copy Markdown
Contributor Author

Pushed fe12d968e6.

Lint lane fixed. The restack had appended the heredoc and hidden-substitution suites onto a file that already carried them, so three titles existed twice and vitest/no-identical-title failed:

packages/core/src/utils/shellAstParser.test.ts
  2848:3  error  Test is used multiple times in the same describe(suite) block
  2865:1  error  Describe is used multiple times in the same describe(suite) block
  2986:1  error  Describe is used multiple times in the same describe(suite) block

Both surviving copies are the richer ones, verified by diff before deleting: the heredoc-body suite kept at line 760 is a strict superset of the deleted copy — it additionally pins the vouched extraReadOnlyRoots shapes (vtool <<EOF | rm -rf build, the &&/||/; segment rows, the for ((…))/select compounds) — and the pattern-word suite was byte-identical. No assertion was lost; 969 tests pass, eslint --max-warnings 0 is clean, tsc --noEmit is clean.

Merged origin/main (a6d30ebc6b). That commit registers report_findings in the permission alias table, which clears the two permission-manager.test.ts > resolveToolName exhaustiveness (#9827) failures this branch was inheriting from main.

Not fixed, and not ours: ubuntu-latest / Java 11 in the SDK lane. This branch changes no Java; the diff is TypeScript, docs, and the generated settings schema.

Round R3 is still open — 14 findings reviewed at 3ddbca1eb0, filed before the PR was split. Six of them (the statePlanted gate, printf -v, {varname} fd-allocation, the heredoc-nested plant axis) now describe code that lives in #10030, so they will be answered there. The remaining eight are this PR's and are still queued.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant