Skip to content

fix(core): keep sub-commands after a state planter in the confirmation scope - #10030

Closed
TianYuan1024 wants to merge 3 commits into
QwenLM:mainfrom
TianYuan1024:fix/confirmation-scope-state-planters
Closed

TianYuan1024 wants to merge 3 commits into
QwenLM:mainfrom
TianYuan1024:fix/confirmation-scope-state-planters

Conversation

@TianYuan1024

@TianYuan1024 TianYuan1024 commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Stops the shell confirmation dialog from showing less than what it runs.

The 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 executed the whole original compound, git status included, in the planted directory. Approval covered a command the dialog never displayed.

Both call sites — ShellTool and MonitorTool — now stop dropping sub-commands once an earlier one has planted state, mirroring what PermissionManager.evaluateCompoundCommand already does. The planter is decided before its own drop decision and is never dropped itself: cd /hostile classifies read-only on its own, 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 the raw text. A leading-word regex cannot be completed here: 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. That costs a larger dialog, never 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.

Why it's needed

This is an approve-what-wasn't-shown bug. execute() runs this.params.command — the full original string — while the dialog is assembled from a filtered subset. Any gap between the two is a command the user authorised without seeing, and the gap is reachable with a single cd.

Reviewer Test Plan

How to verify

cd packages/core && npx vitest run src/utils/shellAstParser.test.ts src/tools/shell.test.ts src/tools/monitor.test.ts

 Test Files  3 passed (3)
      Tests  936 passed (936)

Behavioural check, before this change on main — build a ShellToolInvocation for cd /repo2 && git status && npm run build and read getConfirmationDetails():

  • Before: rootCommand is npm. git status and cd are both gone.
  • After: rootCommand is cd, git, npm.

The drop itself is intact for segments that mean the same thing alone as in sequence — ls -la && npm run build still drops ls.

Mutation-verified on both sides: removing the statePlanted gate in shell.ts fails 1 test, in monitor.ts fails 2; removing the allow-rule guard fails 1; forcing the planter decision to false fails 3; and clearing every non-command shape in the predicate fails 1.

monitor.test.ts previously mocked the whole shellAstParser module; the mock now spreads importOriginal() so the confirmation scope runs against the real predicate — a stub returning false would hide the very defect these tests guard.

Note: src/permissions/permission-manager.test.ts has 2 failures on main at this commit (resolveToolName exhaustiveness (#9827), about ReportFindings). They are unrelated to this PR and reproduce on a clean origin/main checkout.

Evidence (Before & After)

The user-visible change is the content of the confirmation dialog's question line and its extracted permission rules, both asserted in shell.test.ts / monitor.test.ts as above. No layout or styling change.

Tested on

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

Risk & Scope

  • Main risk: confirmation dialogs get longer. Any compound whose first segment is a planter now lists every later segment, including ones the user was used to seeing dropped. That is the intended direction — the dialog should not claim a smaller scope than it grants — but it is more clicking for cd x && <read-only thing>.
  • Also: the predicate fails closed on anything it cannot parse or recognise, so an exotic-but-harmless segment will keep later ones in scope rather than drop them. Deliberate: over-showing is recoverable, under-showing is not.
  • Not validated / out of scope: the pre-existing permission-manager failures noted above. isToolCallConcurrencySafe is untouched — it is a batching optimisation, not a permission decision.
  • Breaking changes: none.

Linked Issues

No issue to close. This is a self-contained correctness fix carved out of #9950 — the confirmation-scope and state-planter gaps surfaced while that PR was under review, and they are filed separately so they can land on their own merits. Referenced without a closing keyword: #9950.

中文说明

这个 PR 做了什么

阻止 shell 确认对话框「显示的比实际执行的少」。

对话框的构建方式是:拆分复合命令,丢弃其中被判为只读的部分,让用户只批准需要批准的内容。这只在「每一部分单独出现时与在序列中含义相同」时才成立——而在 cd、export 或 hash -p 之后就不成立了。cd /hostile && git status && npm run build 只向用户展示 npm,把 git status 作为独立只读丢弃,然后在被植入的目录里执行整条原始复合命令(包含 git status)。批准覆盖了一条对话框从未展示过的命令。

两处调用点(ShellTool 与 MonitorTool)现在在前序子命令植入状态后不再丢弃后续子命令,与 PermissionManager.evaluateCompoundCommand 的既有行为一致。植入者在其自身的丢弃判定之前被识别,且永不被丢弃:cd /hostile 单独看是只读的,而它恰恰是用户最需要看到的那一段。该门禁同样覆盖 allow 规则路径,因为 Bash(git *) 这类规则匹配的是子命令自身的文本,而在 cd 之后那段文本已不再说明它在哪里运行。

是否植入由解析结果判定,而非原始文本。 首词正则在这里无法穷尽:植入者会藏在代码块({ cd /hostile; })、关键字(if true; then cd /hostile; fi)、赋值前缀(FOO=1 cd)、解析顺序前缀(command -p cd)、改写拼法(\cd、"cd")、取反(! cd)或重绑定可信名字的函数定义(git() { rm -rf $HOME; })之后,而裸的 PATH=evil 赋值根本没有命令词。因此只有「解析出的名字不是植入者的普通 command 节点」才被放行,其余所有形态一律 fail closed。代价是更长的对话框,而绝不是被静默收窄的对话框。

为什么需要它

这是一个「批准了未展示内容」的缺陷。execute() 运行的是 this.params.command——完整的原始字符串——而对话框由过滤后的子集拼装。两者之间的任何差距都是用户在未看见的情况下授权的命令,而一个 cd 就能造出这个差距。

验证方式

见上文英文部分的命令与前后对比。两侧均做了变异测试:移除 shell.ts 的 statePlanted 门禁导致 1 个失败,monitor.ts 导致 2 个;移除 allow 规则守卫导致 1 个;把植入判定强制为 false 导致 3 个;把谓词中所有非 command 形态放行导致 1 个。

monitor.test.ts 此前整体 mock 了 shellAstParser 模块;现在改为展开 importOriginal(),使确认作用域跑在真实谓词上——返回 false 的 stub 会掩盖这些测试本要守护的缺陷。

注:本提交所基于的 main 上,permission-manager.test.ts 本身有 2 个与本 PR 无关的失败。

风险与范围

  • 主要风险: 确认对话框会变长。任何首段是植入者的复合命令现在都会列出后续每一段,包括用户习惯于看到被丢弃的那些。这是预期方向——对话框不应宣称比它实际授予的更小的范围——但 cd x && <只读操作> 会多点一次。
  • 另外: 谓词对任何无法解析或识别的内容 fail closed,因此某个罕见但无害的段落会把后续段落保留在范围内而非丢弃。这是刻意的:多显示可以挽回,少显示不能。
  • 明确不在范围内: 上述 main 自带的失败;isToolCallConcurrencySafe 不动,它是批处理优化而非权限决策。
  • 破坏性变更: 无。

关联 Issue

没有需要关闭的 issue。这是从 #9950 中拆分出来的一处独立正确性修复——确认范围与状态植入这两个缺口是在那个 PR 评审过程中发现的,单独提出以便独立评审合入。仅作引用、不带关闭关键字:#9950。

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

Copy link
Copy Markdown
Contributor Author

Added the ## Linked Issues section (and its Chinese counterpart). There is no issue to close — this PR is a self-contained fix carved out of #9950, so the section references that PR without a closing keyword, per the template's "Otherwise reference without a closing keyword."

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

Ready for re-run.

@TianYuan1024

Copy link
Copy Markdown
Contributor Author

The ## Linked Issues gate is addressed (the section references #9950 without a closing keyword, which the template permits for a PR carved out of another). The head is now 8a764ce208, which also merges origin/main (a6d30ebc6b) and so clears the two permission-manager.test.ts > resolveToolName exhaustiveness (#9827) failures this branch was inheriting from main.

The run on the current head was cancelled about 40 seconds in, along with most other runs in the repository between 16:05Z and 16:49Z — a capacity event, not a failure on this commit.

@qwen-code /triage

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

Review of PR #10030

Verdict: Comment — 0 Criticals, 2 Suggestions. The core fix is sound and well-tested.

Summary

This PR fixes an approve-what-wasnt-shown bug where the shell confirmation dialog drops sub-commands after a state-planter (cd, export, hash -p, etc.), showing less than the compound command actually runs. The fix correctly:

  1. Extracts a shared plantsStateForLaterCommands function that parses the shell AST (not raw text) to detect state-planting sub-commands, with a fail-closed design (any unparseable/unrecognized segment plants → larger dialog, never narrower).
  2. Adds a statePlanted flag in both ShellTool.getConfirmationDetails and MonitorTool.getConfirmationDetails that prevents read-only classification and allow-rule dropping of sub-commands after a planter.
  3. Covers the allow-rule path (if (pm && !statePlanted)) to prevent Bash(git *) rules from matching on sub-command text that no longer says where it runs.

12 review agents ran across correctness, security, test coverage, performance, consistency, reuse, and adversarial perspectives — all 10 substantive agents found no Critical issues.

Findings

Suggestion — R1-1: Log statePlanted/plants values alongside monitor warnings for debugability
packages/core/src/tools/monitor.ts:238

The catch block logs the error when isShellCommandReadOnlyASTInDirectory throws, but does not log the statePlanted or plants values. At 3 AM, an oncall engineer looking at a dialog that shows unexpectedly many sub-commands has no way to tell whether the statePlanted flag contributed to the bloat vs. every sub-command genuinely failing classification. Adding statePlanted and plants to the log line (or logging the false→true transition with the sub-command text) would make this debugging path actionable.

Suggestion — R1-2: Consider extracting the statePlanted loop into a shared helper
packages/core/src/tools/shell.ts:2123

The statePlanted tracking loop (~9 lines) is identical in structure between shell.ts and monitor.ts. The only differences are catch (e) logging in monitor vs. bare catch { } in shell, and the pm && !statePlanted gate in shell. A shared helper would prevent future divergence. (The existing extraction of plantsStateForLaterCommands is already at the right level — this targets the outer loop pattern, a minor maintenance concern.)

CI Note

17 CI checks are currently failing (including build, test, integration tests, web-shell E2E, secret scan, and precheck). These appear to be pre-existing infrastructure failures unrelated to this PR. The two permission-manager.test.ts failures the PR description notes as pre-existing on main were confirmed cleared by the origin/main merge.

Limitations

  • No build/test verification: Git fetch is blocked on this reviewer environment (getaddrinfo() failure on Windows), preventing worktree creation and test execution.
  • No reverse audit: The reverse-audit step (hunting what the standard agents missed) was not run.
  • Reviewed against commit 8a764ce2083eadaf975411046e275fb6c872d58c.

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

Two-round code review (round 1 only — issues found)

PR: fix(core): keep sub-commands after a state planter in the confirmation scope
Head reviewed: 8a764ce2083eadaf975411046e275fb6c872d58c
Round 1 model: deepseek-v4-flash
Verdict: Comment — 0 Criticals, 2 Suggestions

Findings

ID Severity Summary
R1-1 Suggestion Log statePlanted/plants values alongside monitor warnings for debugability
R1-2 Suggestion Consider extracting the statePlanted loop into a shared helper

Notes

  • All 12 review agents completed; 10 found no issues.
  • The fails-closed security hardening and test coverage are considered sound.
  • No head drift detected; the reviewed SHA matches the current PR head.
  • qwen's attempted inline/comment post was not visible under the expected bot account, so this body-only comment is submitted to preserve the review summary.

Review asked for `plants`/`statePlanted` in the classifier's failure log.
Those two are unreadable there: the guard short-circuits, so the classifier
is only ever reached while both are false, and the message would carry two
constants. A test pins that — reordering the operands makes it fail.

The information the request was after is real, so it is emitted where the
value varies: once, on the false->true transition, naming the sub-command
that put the rest of the compound in scope. `debugLogger` no-ops without an
active session, so this costs a boolean test outside a debug run.
@TianYuan1024

Copy link
Copy Markdown
Contributor Author

Thanks — pushed 630dd36112.

R1-1 — taken, but not where it was aimed

The literal request (add plants/statePlanted to the catch at monitor.ts:238) would log two constants. The guard reads

isReadOnly =
  !statePlanted &&
  !plants &&
  (await isShellCommandReadOnlyASTInDirectory(sub, cwd));

&& short-circuits left to right, so the classifier — and therefore its catch — is only ever reached while both flags are already false. There is no execution on which that line could report plants=true or statePlantedBefore=true.

Pinned rather than asserted: never reaches the classifier once a sub-command has planted state runs cd /tmp && tail -f app.log && tail -f other.log and expects zero classifier calls, with a control (tail … && tail …, no planter) expecting two, so it cannot pass just because nothing is ever classified for that shape. Mutation: reordering the operands to (await …) && !statePlanted && !plants fails exactly that test.

The need behind the finding is real, so it is served where the value actually varies — once, on the false→true transition, naming the segment that put the rest of the compound in scope:

monitor sub-command plants state; later sub-commands stay in the confirmation scope: cd /tmp

At debug level, not warn: this is ordinary behaviour, not a fault. debugLogger no-ops without an active session, so outside a debug run it costs one boolean test. Two tests pin it (fires once, names the planter; silent when nothing plants). Mutation: deleting the arm fails names the planter that widened the confirmation scope.

Not mirrored into shell.ts: that path has no log at all there today, so adding one is a log-volume decision on the main shell path rather than a debuggability fix, and belongs in its own change.

R1-2 — declined

The two loops differ in one way that matters: monitor.ts deliberately does not consult pm.isCommandAllowed(), because Monitor keeps its own permission boundary and must not let Bash(…) allow rules shrink its confirmation scope. That decision is documented at the call site, which is exactly where someone would otherwise be tempted to "unify" it. Folding the loop into a shared helper moves the divergence out of view and makes that unification look like a cleanup rather than a regression.

The part that genuinely must not drift — the planter predicate — is already shared as plantsStateForLaterCommands, imported by both and by PermissionManager.evaluateCompoundCommand. The remaining duplication is a for loop and a ||=; extracting it would trade nine visible lines for a parameterised helper carrying an error callback and a permission-gate flag.

CI

Your read matches ours: the reds are infrastructure. Between 16:05Z and 16:49Z most runs in the repository were cancelled ~40s in, across unrelated branches — the run on the previous head of this PR was collateral. The Windows lane fails on 8.3 short paths (C:\Users\RUNNER~1 vs runneradmin) and shell-quote stripping backslashes; this PR touches no src/serve/ and no splitCommands, and fix/windows-ci-temp-8dot3 is already in flight for it.

@TianYuan1024

Copy link
Copy Markdown
Contributor Author

@qwen-code /triage

Head is now 630dd36112, which addresses R1-1 and R1-2 from the review at 8a764ce208 (details in the comment above). The previous triage stopped at the stage-1a template gate, which is resolved.

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.

2 participants