Repository navigation
fix(core): keep sub-commands after a state planter in the confirmation scope - #10030
TianYuan1024 wants to merge 3 commits into
Conversation
…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.
|
Added the Also merged Ready for re-run. |
|
The 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
left a comment
There was a problem hiding this comment.
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:
- Extracts a shared
plantsStateForLaterCommandsfunction 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). - Adds a
statePlantedflag in bothShellTool.getConfirmationDetailsandMonitorTool.getConfirmationDetailsthat prevents read-only classification and allow-rule dropping of sub-commands after a planter. - Covers the allow-rule path (
if (pm && !statePlanted)) to preventBash(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
left a comment
There was a problem hiding this comment.
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.
|
Thanks — pushed R1-1 — taken, but not where it was aimedThe literal request (add isReadOnly =
!statePlanted &&
!plants &&
(await isShellCommandReadOnlyASTInDirectory(sub, cwd));
Pinned rather than asserted: The need behind the finding is real, so it is served where the value actually varies — once, on the At Not mirrored into R1-2 — declinedThe two loops differ in one way that matters: The part that genuinely must not drift — the planter predicate — is already shared as CIYour 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 ( |
|
@qwen-code /triage Head is now |
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, anexport, or ahash -pit does not.cd /hostile && git status && npm run buildshowed the usernpm, droppedgit statusas independently read-only, and then executed the whole original compound,git statusincluded, in the planted directory. Approval covered a command the dialog never displayed.Both call sites —
ShellToolandMonitorTool— now stop dropping sub-commands once an earlier one has planted state, mirroring whatPermissionManager.evaluateCompoundCommandalready does. The planter is decided before its own drop decision and is never dropped itself:cd /hostileclassifies 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 aBash(git *)rule matches on a sub-command's own text, which after acdno 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 barePATH=evilassignment carries no command word at all. So only a plaincommandnode 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/exportfamily, 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()runsthis.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 singlecd.Reviewer Test Plan
How to verify
Behavioural check, before this change on
main— build aShellToolInvocationforcd /repo2 && git status && npm run buildand readgetConfirmationDetails():rootCommandisnpm.git statusandcdare both gone.rootCommandiscd, git, npm.The drop itself is intact for segments that mean the same thing alone as in sequence —
ls -la && npm run buildstill dropsls.Mutation-verified on both sides: removing the
statePlantedgate inshell.tsfails 1 test, inmonitor.tsfails 2; removing the allow-rule guard fails 1; forcing the planter decision tofalsefails 3; and clearing every non-commandshape in the predicate fails 1.monitor.test.tspreviously mocked the wholeshellAstParsermodule; the mock now spreadsimportOriginal()so the confirmation scope runs against the real predicate — a stub returningfalsewould hide the very defect these tests guard.Note:
src/permissions/permission-manager.test.tshas 2 failures onmainat this commit (resolveToolName exhaustiveness (#9827), aboutReportFindings). They are unrelated to this PR and reproduce on a cleanorigin/maincheckout.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.tsas above. No layout or styling change.Tested on
Risk & Scope
cd x && <read-only thing>.permission-managerfailures noted above.isToolCallConcurrencySafeis untouched — it is a batching optimisation, not a permission decision.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 && <只读操作>会多点一次。main自带的失败;isToolCallConcurrencySafe不动,它是批处理优化而非权限决策。关联 Issue
没有需要关闭的 issue。这是从 #9950 中拆分出来的一处独立正确性修复——确认范围与状态植入这两个缺口是在那个 PR 评审过程中发现的,单独提出以便独立评审合入。仅作引用、不带关闭关键字:#9950。