feat(core): let plan mode vouch for extra read-only shell roots - #9735
feat(core): let plan mode vouch for extra read-only shell roots#9735TianYuan1024 wants to merge 16 commits into
Conversation
…LM#9694) Plan mode classifies shell commands against a hardcoded set of read-only root commands. Any other binary classifies as unknown, which forces the one-time approval prompt — and because plan-mode shell confirmations hide "Always allow" and accept only ProceedOnce, the approval never sticks. Teams driving plan-mode sessions through a project-specific read-only CLI are therefore prompted on every read, with no configuration escape hatch: permissions.allow is deliberately overridden for shell in plan mode, and PreToolUse hooks run after the permission decision. Add permissions.planMode.extraReadOnlyCommands, a list of root command names that plan mode treats as read-only alongside its built-in set. The classifier gains an optional ShellSafetyOptions parameter threaded through its recursive evaluators, consulted only by the terminal fallback branch that decides whether an otherwise-unrecognised root is read-only. Every root the classifier understands specially is matched before that branch, so a vouched entry can only add to the read-only set and never override a write classification. Redirections, command substitution, environment-assignment prefixes, and pipes into unknown commands are unaffected. Normalisation drops anything that is not a bare command name, along with shell interpreters and generic command wrappers, since each of those exists to run some other command and accepting one would bypass the analysis entirely. The setting is read through a single accessor that returns an empty set outside plan mode, so a vouch made for planning never widens auto-approval in the other approval modes, where permissions.allow remains the supported mechanism. Options are threaded as parameters rather than held in module state because a single process can host several workspace configurations.
|
|
|
Thanks for the PR! Template looks good ✓ Problem: real and observed — this implements #9694, where a user's project-specific CLI ( Direction: aligned. The linked issue was accepted for exploration with exactly this design direction — extend the known-safe root set while keeping every syntactic gate on top — and it sits on the Size: core paths are touched ( Approach: the scope feels right. It implements exactly the setting shape proposed in the issue, and the PR description makes the honest scope call: the issue's alternative form (honoring Risk: elevated — Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:真实且已观测——本 PR 实现 #9694,用户的项目自定义 CLI( 方向:对齐。关联 issue 已按此设计方向接受探索——在保留全部语法级门禁的前提下扩展已知安全根命令集合——且该需求在 规模:触及核心路径( 方案:范围合理。实现了 issue 中提议的配置形态,并做了诚实的范围取舍:issue 中的另一种形式(在 Plan 模式下对 unknown 分类的命令尊重 风险:升级—— 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewI formed an independent proposal before reading the diff (new
One non-blocking observation for the maintainer: No critical issues found. Files changed (19 of 19 shown)
Test evidenceThis is an unattended CI run — I did not build or execute any PR code. The evidence below is the PR's own CI on the reviewed commit, fetched via the API. The macOS/Windows unit jobs and the integration job show At fetch time the main unit suite is still running; the Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Not verified: live TUI behavior (that the prompt actually disappears in a Plan Mode session and reappears after leaving it) — unit and policy tests pin the classification change, but nothing in CI exercises the end-to-end prompt path, and the author's manual testing was macOS-only. Sandboxed verification would settle this: 中文说明代码审查在读 diff 之前我先独立给出了自己的方案(新增
一个非阻断的观察,留给维护者: 未发现严重问题。 测试证据本次为无人值守的 CI 运行——我没有构建或执行任何 PR 代码。以上证据来自 PR 自身在被审 commit 上的 CI,通过 API 获取。macOS/Windows 单元测试与集成测试显示 — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — clean review of a well-executed, narrowly scoped feature; only non-blocking nits, and CI is still running. Stepping back: this is what a good fork contribution looks like. It answers a real request from a different user (#9694), implements exactly the setting shape that issue proposed, and makes the conservative call on the scope question the issue left open (honoring The part that usually goes wrong in this kind of feature is the guardrail boundary, and that's where I spent the review. The design gets it right the way I would have wanted: the vouch is consulted only at the very end of the classification chain, write classifications are decided before it and cannot be overridden, syntactic gates (redirects, substitutions, env prefixes, unknown pipe targets) merge afterwards and dominate, the interpreter/wrapper denylist closes the obvious "vouch for bash and bypass everything" hole, and mode scoping lives in a single accessor so nothing leaks into default/auto/yolo. Every consumer I could name was checked; the ones left untouched fail closed. If I'm maintaining this in six months, the code reads clearly and the comments say why, not what. The honest reservations, none of them blocking:
Every edit in the diff serves the goal; the docs and committed test plan follow this repo's conventions rather than padding the change. Approval is deferred until CI lands green on 中文说明退一步看:这是一次高质量的外部贡献。它回应的是另一位用户提出的真实需求(#9694),实现了该 issue 提议的配置形态,并在 issue 留下的范围问题上做了保守取舍(有意不在 Plan 模式下尊重 这类功能最容易出错的是护栏边界,审查也集中在这里,且设计是正确的:担保只在分类链的最末端被查询;写判定在其之前做出、不可被覆盖;语法级门禁(重定向、命令替换、环境变量前缀、未知管道目标)在其后合并且优先级更高;解释器/包装器否决名单堵住了"担保 bash 即可绕过一切"的明显漏洞;模式限定集中在单一访问器,不会泄漏到 default/auto/yolo。所有能点名的消费方都已核查,未改动的消费方全部 fail-closed。半年后维护这段代码也不会吃力——代码清晰,注释解释的是"为什么"。 诚实的保留意见(均非阻断):否决名单无法穷尽( diff 中每一处改动都服务于目标;文档与提交的测试计划遵循本仓库惯例而非凑数。批准将延迟到 — Qwen Code · qwen3.8-max Reviewed at |
The scheduler's plan-shell routing suite and the ACP session suite build Config test doubles by casting object literals, so the accessor added for permissions.planMode.extraReadOnlyCommands was missing on both. The plan-mode shell policy reads it before classifying, which threw and took out all 29 plan-shell routing tests across the two suites. Production code is unchanged; real Config has always implemented the method. Verified by running both package suites in full rather than only the files the feature commit touched.
Code review on QwenLM#9735 noted that the denylist rejecting shell interpreters and command wrappers missed several binaries in the same category. Add the multi-call binaries (busybox, toybox), the privilege launchers (su, runuser, pkexec), and the process/namespace launchers (watch, setsid, chroot, unshare, nsenter, flock). Each takes an arbitrary command as an argument, so vouching for one would make the AST analysis blind to whatever it runs. A denylist can never be exhaustive, so say so where it is defined and in the user docs: the backstop for anything it misses is the documented scope of a vouch, which covers the whole binary including any mutating sub-commands.
|
@qwen-code /triage Two commits since the reviewed head:
Still tested on macOS only. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Test Plan (not a blocker): src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more.
中文说明
Test Plan(非阻断):src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more。
— qwen3.8-max via Qwen Code /review (v0.22.0)
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Test Plan (not a blocker): src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
Test Plan(非阻断):src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more。
— qwen3.8-max via Qwen Code /review (v0.22.0)
…the vouch Review on QwenLM#9735 filed three Critical findings against the vouch, each with a reproduction. All three are fixed and the probes now return unknown. Launchers. A denylist of names never converges — two review rounds produced 16 misses (time, builtin, parallel, cmd, powershell, trap, ionice, at, wine, wsl and more), each turning `<launcher> rm -rf build` into an auto-run. The answer is two-layered. The list moves into the classifier, so no caller can vouch a launcher back in by supplying its own set, and it gains every demonstrated name. Structurally, a vouched root is now refused as soon as one of its arguments names a command the classifier knows, matched on the basename. That closes the demonstrated shape for launchers nobody has enumerated: an unrecognised binary handed `rm` fails closed without anyone having to know it is a launcher. The cost is an occasional extra prompt when a CLI's own sub-command shares a name with a real command, which is the right side to err on. State planters. Builtins that rebind name resolution — hash, alias, bind, complete, enable, set, shopt and friends — were vouchable, so a planted resolution could hijack a root the classifier explicitly trusts: `hash -p ./evil/git git && git status` classified read-only. Statements are classified independently and nothing models that coupling, so these are refused outright. Unlike launchers this family is enumerable. Hidden substitutions. tree-sitter-bash parses the pattern word of `${v%%…}` and its siblings as a single leaf, so a substitution inside it produced no node to evaluate even though bash runs it while expanding. This was already wrong for built-in roots — `echo ${HOME%%$(rm -rf build)}` classified read-only before this PR — and the vouch would have widened it to arbitrary user-named roots. The substitution walker now treats a surviving `$(` or backtick in an expansion as exactly that channel. Also from the same review: guard normalization against a non-array value, which was otherwise iterated per character (vouching `m`, `y`, `c`, …) or thrown out of the Config constructor during startup; drop the trailing commas from all three settings snippets, which the loader rejects outright and which would have sent a user's settings file into corruption recovery; and correct the e2e plan, whose baseline expectations described blocked writes as prompts and whose launch instructions started the CLI where the workspace settings under test are never loaded. Test coverage follows the findings: every entry of the refused-roots list is driven off the exported set so the list cannot drift untested, the structural guard and each expansion operator are pinned, and the gaps review named are filled — safe-mode and bare-mode drops, and the confirmation-details sub-command filter for both tools.
|
@qwen-code /triage All three Criticals and the five Suggestions are addressed in R2-1 (launchers). Agreed that a name list does not converge — so it is no longer the only defence. The list moved into the classifier, where it gates the terminal branch directly, so no caller can vouch a launcher back in by supplying its own set; it also gained all 16 demonstrated names. On top of that, a vouched root is refused as soon as one of its arguments names a command the classifier knows, matched on the basename. That closes the demonstrated shape for launchers nobody has enumerated: R2-2 (expansion pattern word). Fixed as suggested. R2-6 / ledger R2-3 (state planters). Took the deeper fix rather than the name fix: these are refused at the classifier, not filtered out of the caller-supplied set. R1-2. R1-3. Answered structurally rather than by enumeration: the refused-roots test is R1-4. Safe-mode and R1-5. R2-7 (trailing commas). Confirmed against the repo R2-4 / R2-5 (e2e plan). Baseline expectations reworded — the state-modifying cases are blocked on baseline, not prompted, and only cases 1, 5b, 7, 8, 9 depend on the setting. Launch instructions corrected: the plan now says to launch from the scratch workspace via Verification: full |
The review flagged trailing commas in all three settings snippets this PR
adds: the loader is JSON.parse(stripJsonComments(...)), which strips comments
but not trailing commas, so a user copying any of them corrupts their
settings.json — the file is backed up as .corrupted and reset to {}.
Removing the commas alone does not hold. The repo's prettier config sets
trailingComma: "all", and its jsonc parser honours that, so the formatter puts
them straight back on the next run — which is what happened to my first
attempt at this fix, and why all 51 jsonc blocks in docs/ carry the same
defect. The json parser accepts // comments and never adds trailing commas, so
the fence language is the fix that survives formatting.
Not fixed here: the two pre-existing autoMode snippets on the same page have
the identical defect and are outside this PR's scope.
|
Correction on my previous comment: I said all three trailing-comma snippets were fixed. Only the e2e plan was. Removing the commas is not enough. This repo's prettier config sets Out of scope, flagging rather than fixing: the two pre-existing State of the other findings at
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Test Plan (not a blocker): src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more.
Convergence: round 3 posted 14 inline comment(s), 13 of them reported for the first time; the previous round posted 12 (8 new). Findings keep coming back to the same files: packages/core/src/utils/shellAstParser.ts (findings in round 2; 5 more now); .qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md (findings in round 2; 3 more now); docs/users/features/approval-mode.md (findings in round 2; 2 more now), and 1 more file(s). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)
中文说明
仅完成部分审查,审查缺口已披露。
未审查:reverse audit — did not converge within the reverse-audit round cap of 5。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
Test Plan(非阻断):src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more。
收敛情况:第 3 轮发布了 14 条行内评论,其中 13 条是首次提出;上一轮发布了 12 条(其中 8 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/shellAstParser.ts(第 2 轮已出过发现,本轮又有 5 条);.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md(第 2 轮已出过发现,本轮又有 3 条);docs/users/features/approval-mode.md(第 2 轮已出过发现,本轮又有 2 条),另有 1 个文件。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.22.0)
Round 3 of review filed five Criticals, and the meta-observation with them is
the important one: the fixes were treating instances of one root cause. Every
launcher finding — 16 names in round 2, language interpreters and exec
wrappers in round 3 — comes from the same place. A vouch is a claim about a
binary, the classifier was applying it to arbitrary arguments, and both halves
of the previous defence matched text against surfaces that have no edge.
So the rule is inverted. A vouched root now classifies read-only only for an
invocation the classifier can actually read:
- every argument is a plain literal word, whitelisted by character and
cross-checked with hasShellExpansion. The old rule compared argument text to
known command names, but bash rewrites `r\m`, `r'm'`, `"r"m`, `$cmd`,
`${cmd}`, `*` and `{rm,ls}` into `rm` before the binary sees them, and
enumerating those spellings terminates no sooner than enumerating launchers;
- no argument names a command this file knows — unchanged, but it now runs on
literal words only, so the text it inspects is the text that executes;
- the root is not a known command under another spelling. `git.exe` matched no
dispatch arm and reached the vouch branch, so `.exe` is stripped for the
known-command test in both places. Not stripped before the dispatch chain:
that could only widen classifications to read-only, and this must not.
The name lists stay as the floor under the rule and gain what round 3
demonstrated: language interpreters (python, python3, node, deno, bun, perl,
ruby, php, lua, tclsh, wish, osascript), exec wrappers (ssh, rsh, sudoedit),
the builtins coproc, fc and history, and `.`, the POSIX spelling of source.
An interpreter cannot be caught structurally — `python3 evil.py` names a file,
not a command — so for that family the list is the whole answer.
Two more hidden-substitution leaves, both pre-existing and neither needing a
vouch. `<(…)`/`>(…)` in an expansion pattern word run exactly as `$(…)` does,
and a heredoc body with an unquoted delimiter is expanded before it reaches
stdin, so `cat <<EOF` with a backtick payload classified read-only. Both are
handled in the branch that already exists for this, and a quoted delimiter
still classifies read-only because it is genuinely inert.
Tests: the evasion battery, the .exe shapes, the dd/kill/tee arm of
namesAKnownCommand that no test pinned, process substitution across all four
operators, heredocs quoted and unquoted, and a mixed-type array through the
normalizer. Docs and the e2e plan follow the code: the residual example no
longer uses a launcher the list covers, case 8 no longer asserts that a
command the shape rule refuses runs unprompted, the bundle entry point is
dist/cli.js, and the interpreter promise in the user doc is now one the code
keeps.
|
All 14 round-3 findings are addressed in The root cause. Every launcher finding — 16 names in round 2, language interpreters and exec wrappers in round 3 — is the same defect. A vouch is a claim about a binary; the classifier was applying it to arbitrary arguments, and both halves of the round-2 defence matched text against surfaces that have no edge. Enumerating spellings of A vouched root now classifies read-only only for an invocation the classifier can actually read:
On the interpreters (R2-1, third round). This one has no structural answer and I want to be straight about it: R3-4 / R3-5 (hidden substitutions). Both pre-existing, neither needs a vouch. R3-2. Correct, and the shape rule refuses Remaining suggestions: R3-6 Verification: full @qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Test Plan (not a blocker): src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 1 more.
Deferred under the convergence posture (round 4, not a blocker) — recorded, not requested in this round:
docs/design/2026-08-22-plan-mode-extra-read-only-commands.md:55 — [review] Design doc's quoted behavioural hook omits the vouchedRootIsSafe gatepackages/core/src/utils/shellAstParser.test.ts:1114 — [probe] it.each([...NEVER_READ_ONLY_ROOT_COMMANDS]) iterates the very constant it guards; a deletion is unobservable
Convergence: round 4 posted 3 inline comment(s), 2 of them reported for the first time; the previous round posted 14 (13 new). Findings keep coming back to the same files: packages/core/src/utils/shellAstParser.ts (findings in rounds 2, 3; 2 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
Test Plan(非阻断):src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 1 more。
收敛姿态下延后(第 4 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 4 轮发布了 3 条行内评论,其中 2 条是首次提出;上一轮发布了 14 条(其中 13 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/shellAstParser.ts(第 2、3 轮已出过发现,本轮又有 2 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.22.0)
Four review rounds have now produced four batches of roots whose payload the
classifier cannot see: launchers, language interpreters, versioned interpreter
spellings, and this round build and package tools. That list has no end — any
binary can execute something its command line does not mention — so the
question is not which names are missing but why an unbounded enumeration was
load-bearing at all.
It was load-bearing because the setting merged from workspace scope. A cloned
repository could vouch for `make` in its own .qwen/settings.json, which turns
"the user accepted this binary" into "the repository asserted something about
a binary", and defending that means enumerating every binary an attacker might
choose.
So the setting now follows security.allowPrivateNetworkHooks: stripped from
workspace scope in stripWorkspaceSecurityBypasses, with a startup warning when
a workspace file sets it. An entry can only come from someone who typed it
into their own settings, so a vouch means what the docs say it means. The
lists stay as a floor under foreseeable mistakes rather than a boundary
against an adversary who picks the entry, and the doc now says which of the
two it is. What remains adversarial is the invocation — the model chooses the
arguments and repository content can influence the model — and that is the
bounded problem vouchedRootIsSafe already addresses.
This narrows the feature: a team can no longer ship the vouch in the repo,
only in each developer's settings. That is the same trade the repo already
makes for the other self-granted bypasses, and it is what makes the rest of
the defence finite.
Also from this round:
- Variable-planting builtins (read, mapfile, readarray, getopts) plant PATH
for the next statement without a VAR=VALUE word, so the variable_assignment
guard never sees them. Enumerable, and now listed.
- Versioned interpreter spellings (python3.12, lua5.4) are matched by shape
rather than by name, since listing every release is not a finite job.
- namesAKnownCommand now splits on `=` as well as the path separators, so
--exec=rm names rm. This one is a real bypass of the shape rule under the
invocation threat model, not a vouch-source one.
- ${v@P} prompt expansion runs a substitution held in the variable's value,
and in a pattern word it is a leaf, so neither the @/P adjacency check nor
the opener scan saw it. No vouch needed: echo ${x%%${v@P}} auto-ran.
- `<<\EOF` quotes the heredoc delimiter as surely as `<<'EOF'`, so its body is
inert and no longer prompts.
|
Round 4 is in It was load-bearing because the setting merged from workspace scope. A cloned repository could put So the setting now follows What that changes about the lists: they are now a floor under foreseeable mistakes, not a boundary against an adversary who chooses the entry, and the docs and the set docstring say which of the two they are. What stays adversarial is the invocation — the model picks the arguments and repository content can influence the model — and that is the bounded problem This narrows the feature and I want that on the record: a team can no longer ship the vouch in the repo, only in each developer's settings. If you would rather keep breadth and accept repository-supplied vouches, deleting the Also fixed from this round:
Both deferred items are done too: the design doc's quoted hook was stale (predates the The e2e plan needed reworking for the scope change: the vouch moves to Verification: full @qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Test Plan (not a blocker): src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more.
Convergence: round 5 posted 15 inline comment(s), 15 of them reported for the first time; the previous round posted 3 (2 new). Findings keep coming back to the same files: packages/core/src/utils/shellAstParser.ts (findings in rounds 2, 4; 6 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)
中文说明
Test Plan(非阻断):src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more。
收敛情况:第 5 轮发布了 15 条行内评论,其中 15 条是首次提出;上一轮发布了 3 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/shellAstParser.ts(第 2、4 轮已出过发现,本轮又有 6 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.22.0)
Round 5 found no new laundering class — the scope change closed that — but it
found that last round's refusal rules over-reach, and four of the fifteen
findings are regressions I introduced tightening them. Those come first.
Path arguments. Scanning every `/`-separated segment for a known command name
made `./report.json` name `.`, the POSIX spelling of source, so every vouched
invocation with a relative path prompted — precisely the reads this feature
exists to silence. `docs/history/x.md` went the same way through `history`.
Matching is back to the basename of each `=`-separated part, which still
refuses `/bin/rm`, `--exec=rm` and `--exec=/bin/rm`.
Non-ASCII arguments. The literal whitelist used `\w`, so `报告.md` or
`café.txt` refused the vouch. Every shell metacharacter is ASCII, so a bare
non-ASCII word is literal by construction; the whitelist now matches letters,
digits and marks by Unicode property.
Windows spellings. The refusal side normalized `.exe` while the acceptance
side did an exact lookup, so vouching `mytool` never matched `mytool.exe`. A
known command under an `.exe` spelling is still refused first, so agreeing on
the two sides cannot re-open one. Case is documented rather than changed:
`MyTool` and `mytool` are different binaries on Linux.
Process substitution in heredoc bodies. Round 4 reused the pattern-word
opener set for heredoc bodies, but bash expands a body as if double-quoted —
`$(…)`, backticks and `@P` run there, `<(…)` does not. A body merely
containing the text `<(` was refused. The body scan now has its own opener
set, and the test that asserted the wrong behaviour asserts the right one.
The Critical is the other side of that same scan: `${v@P}` was never tested
there, and a `<<-EOF` body always parses as one raw leaf, so a tab-indented
`${v@P}` line classified read-only while bash prompt-expanded it and ran the
substitution held in the variable. No vouch needed.
Also: the refusal floor gains crontab, systemd-run, cc, go, expect, docker,
podman, luajit, bunx, pnpx and the free-threaded `python3.13t` suffix; the
versioned-interpreter test no longer leans on path handling for its result,
which a mutant confirms it was doing (deleting the constant left it green,
and now fails it 10/10); the design doc no longer claims nothing could
suppress these prompts, since a PermissionRequest hook can (traced: shell
defines no requiresUserInteraction, so an allow decision reaches
validatePlanModeShellApproval); and the user doc example points at
~/.qwen/settings.json rather than the one scope this feature strips.
The e2e plan gains the login step the scratch QWEN_HOME makes necessary, the
restart case 6c's restore needs, `git` in case 6's entry list so its bullet
tests what it claims, and honest preconditions for case 6b.
|
All 15 round-5 findings are in R5-2, path arguments. Scanning every R5-3, non-ASCII. R5-5, Windows spellings. The refusal side normalized R5-7, process substitution in heredoc bodies. You are right and I had this wrong: a heredoc body expands as if double-quoted, so R5-1 (Critical) is the other half of that same scan: R5-6 was the sharpest test finding of the review so far. I reproduced the mutant: deleting Remaining: R5-4 adds Verification: full @qwen-code /triage |
One conflict, in the workspace-scope strip this PR had added to. `main` (QwenLM#9098, QwenLM#9737) generalised that strip into a single data list: WORKSPACE_RESTRICTED_SETTINGS in settingsUtils.ts now drives the strip, the "your workspace value was ignored" warning, and the settings dialog's scope filter, so the three surfaces cannot drift apart. This PR had hand-rolled the same thing for permissions.planMode — a second branch inside stripWorkspaceSecurityBypasses and a third copy of the warning text. Both are deleted in favour of main's version, and the setting is registered as one entry in the list instead. The behaviour is unchanged and the four scope tests added here still pass against the generic mechanism; the dialog filter comes along for free, though planMode sets showInDialog: false.
|
Merged
This PR had hand-rolled the same thing for Verification after the merge: full |
…ze baseline The workflow-size ratchet fails on this branch, and the growth is not this PR's: QwenLM#9587 grew cd-cua-driver.yml from 29715 to 42519 bytes on 2026-08-23 without updating the baseline in the same PR, so the entry has been stale on main since. The ratchet scans every workflow file rather than the ones a PR touches, so it fails for anything that merges main after that commit — this branch just happened to merge first. Recording the shipped size, which is what the failure message asks for. It is well under the 470000-byte gate; nothing else in the manifest moves.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5 (rounds 3, 4, 5 each reported findings).
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Not explored to full depth (tool budget reached): chunk 5: could not execute the new vitest suites (permission-manager/monitor/shell) against the real WASM classifier — the review worktree has no node_modules/dist and ….
Test Plan (not a blocker): src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more.
Deferred under the convergence posture (round 6, not a blocker) — recorded, not requested in this round:
docs/design/2026-08-22-plan-mode-extra-read-only-commands.md:113 — [review] Design doc cites nonexistent stripWorkspaceSecurityBypasses ; actual function is stripWorkspaceRestrictedSettingsdocs/design/2026-08-22-plan-mode-extra-read-only-commands.md:66 — [probe] Design doc's dispatch snippet omits the .exe disjunct, which IS a wideningdocs/users/features/approval-mode.md:196 — [probe] Doc promises .exe ignored on both sides; only the invocation side strips it.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md:91 — [review] Cases 3, 4, 5a call the re-firing prompt 'one-time' — contradicting case 1 and the implementationpackages/core/src/utils/shellAstParser.test.ts:517 — [review] Pattern-word substitution tests omit the ^ / , operators and the ${var/…/…} pattern partpackages/core/src/utils/shellAstParser.test.ts:570 — [probe] No test pins substitutions in the replacement part of ${var/pat/rep}packages/core/src/utils/shellAstParser.test.ts:588 — [probe] $(…) inside always-leaf <<- heredoc bodies is caught only by the regex's $( branch, and no test pins itpackages/core/src/utils/shellAstParser.test.ts:1275 — [review] The versioned-interpreter pin list covers 8 of 9 regex families — no wish spellingpackages/core/src/utils/shellAstParser.ts:298 — [probe] A bare . argument of a vouched root is refused — . / .. can never name an executable
Convergence: round 6 posted 2 inline comment(s), 2 of them reported for the first time; the previous round posted 15 (15 new). Findings keep coming back to the same files: packages/core/src/utils/shellAstParser.ts (findings in round 5; 2 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)
中文说明
仅完成部分审查,审查缺口已披露。
未审查:reverse audit — did not converge within the reverse-audit round cap of 5 (rounds 3, 4, 5 each reported findings)。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未探索到全部深度(达到工具调用预算):chunk 5:could not execute the new vitest suites (permission-manager/monitor/shell) against the real WASM classifier — the review worktree has no node_modules/dist and …。
Test Plan(非阻断):src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more。
收敛姿态下延后(第 6 轮,非阻断)——已记录,本轮不要求修改:共 9 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 6 轮发布了 2 条行内评论,其中 2 条是首次提出;上一轮发布了 15 条(其中 15 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/shellAstParser.ts(第 5 轮已出过发现,本轮又有 2 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
— qwen3.8-max via Qwen Code /review (v0.22.0)
R6-2. `localGitConfigMakesCommandUnsafe` protects `git diff` and `git status`
from a repository that plants `diff.external` or `core.fsmonitor` in its own
`.git/config`, because git then runs a script that appears nowhere in the
command line. The loop was keyed to the literal name `git`, so a vouched
wrapper — the case this setting exists for — skipped the probe entirely and
auto-executed the planted hook in Plan mode, while `git status` in the same
repository correctly prompted.
A vouched root now gets the same gate, and without the sub-command filter: a
wrapper spells its verb wherever it likes (the reporting issue's own CLI puts
it in the second argument), so filtering on `diff`/`status` would only move
the gap. Repositories that plant nothing are unaffected — the probe reports no
risk and the command stays read-only.
R6-1 adds the payload-executing siblings of names already listed — pip, uv,
uvx, poetry, conda, gradle, mvn, ninja, scons, clang, clang++, c++, rustc,
javac, run0, setarch, linux32, linux64, newgrp, bwrap, fakeroot,
systemd-nspawn — and rewrites VERSIONED_INTERPRETER to match a family name
followed by a version rather than one release shape, covering `gcc-13`,
`luajit-2.1.0-beta3`, `expect5.45` and `python3.7m`. Nine sibling pairs are
pinned as tests, since every round of this review has found its gap at a
family edge.
Also stops refusing a bare `.` or `..` argument: as a whole word it names a
directory, not the POSIX spelling of `source`, so `ib list .` was prompting
for nothing. `.` as a *root* is still refused by the dispatch chain.
Tests: the `${var/pat/rep}` halves and the `^`/`,` case operators join the
pattern-word battery, `$(…)` in an always-leaf `<<-` body is pinned, and the
versioned-interpreter list gains one spelling per regex family. Docs correct
the function name, the dispatch snippet's `.exe` disjunct, the claim that
`.exe` is stripped on both sides, and the e2e plan's "one-time" wording for a
prompt that re-fires by design.
|
Both round-6 Criticals are in R6-2 is the real bug of this round, and it was mine. I did not take the suggested minimum ("at least when its first argument is R6-1 I have fixed as asked, and I do not claim it is closed. The 22 names and the regex rewrite are in: But you are right that this does not converge, and I think it is worth naming why, because I do not want a seventh batch to look like progress. The threat model changed under the list. Through round 4 the adversary picked the entry — a cloned repo could vouch I considered the argument-side rule that would be the structural answer — refuse a vouched invocation whose arguments name a script or a loadable object — and rejected it. It does not close the class: 6 of your 20 witnesses ( So: the list is a floor under foreseeable mistakes, and the boundary is that only the user can set it. The design doc says that; if you would rather the setting refuse to load unknown roots entirely, that is a product decision I am happy to take, but it is a different feature and I would rather not smuggle it in as a fix. Deferred items, all done since they were cheap: the design doc's function name ( One of the deferred probes was a real over-refusal, so I took it as a finding: a bare Verification: full |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5 (rounds 3, 4, 5 each reported findings).
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Test Plan (not a blocker): src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more.
Deferred under the convergence posture (round 7, not a blocker) — recorded, not requested in this round:
packages/cli/src/config/settingsUtils.ts:273 — [review] PR description's scope-union and byte-for-byte-identical claims contradict shipped behavior.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md:54 — [review] Baseline-divergence list omits case 6dpackages/core/src/utils/shellAstParser.test.ts:552 — [probe] it.each titles mangled by printf %s; regression output ungreppablepackages/core/src/utils/shellAstParser.test.ts:1233 — [probe] Refusal-floor test iterates its own constant; 70 of 125 entries un-pinned against deletionpackages/core/src/utils/shellAstParser.ts:1498 — [probe] changedDirectory branch for vouched git frontends is pinned by no testpackages/core/src/utils/shellAstParser.test.ts:1486 — [probe] .exe vouch entries never match the bare invocation; the Windows-natural entry spelling is silently inert.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md:186 — [review] Case 6c restart lacks a launch directory; the moved vouch never loads.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md:174 — [probe] Case 6d step 2 promises 'no prompt' but every in-session execution form prompts.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md:104 — [review] All five instructed restarts silently drop Plan mode; post-restart cases exercise AUTOpackages/core/src/utils/shellAstParser.test.ts:659 — [probe] Loose .not.toBe('read-only') hides an existing unknown/write category drift in heredoc pinspackages/core/src/utils/shellAstParser.test.ts:550 — [probe] Default-value operator family (:- := :? …) unpinned; bash executes its payloadspackages/core/src/utils/shellAstParser.test.ts:1356 — [probe] Versioned-interpreter pins miss the g++/cc/clang++ families; dropping them passes silently
Convergence: round 7 posted 7 inline comment(s), 6 of them reported for the first time; the previous round posted 2 (2 new). Findings keep coming back to the same files: packages/core/src/utils/shellAstParser.ts (findings in round 6; 6 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had none either, so the next review re-reads the whole diff and will keep doing so until a round's marker carries an anchor again. (Stated, not acted on — this changes nothing about what the round posts.)
中文说明
仅完成部分审查,审查缺口已披露。
未审查:reverse audit — did not converge within the reverse-audit round cap of 5 (rounds 3, 4, 5 each reported findings)。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
Test Plan(非阻断):src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more。
收敛姿态下延后(第 7 轮,非阻断)——已记录,本轮不要求修改:共 12 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 7 轮发布了 7 条行内评论,其中 6 条是首次提出;上一轮发布了 2 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/shellAstParser.ts(第 6 轮已出过发现,本轮又有 6 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有锚点,因此下一次评审将重读整个 diff——并会一直如此,直到某一轮的标记重新带上锚点。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
— qwen3.8-max via Qwen Code /review (v0.22.0)
…readonly-commands
…ions
R7-1/2/3. The round-6 gate treats a vouched root as a possible git frontend,
but git's own global options walk straight past it: `-C` and `--git-dir`
point the probe at the wrong repository, `-c core.fsmonitor=./evil.sh` needs
no planted config at all because the value arrives through argv, and
`--exec-path=./evil` redirects where git resolves every non-builtin
sub-command — a payload a clone ships with the executable bit intact.
`evaluateGitSafety` screens literal git by refusing any leading-dash first
argument; a wrapper cannot be screened that broadly without retracting the
feature, so the specific redirecting options are named instead. Unlike the
open set of binaries the refusal list chases, this one is finite and
authoritative: it is git's documented global option list.
R7-5's flag half rides along — `--textconv`, `--filters`, `--show-signature`
and `--ext-diff` make a read verb run a helper program, and
`GIT_EXTERNAL_HELPER_OPTION` already names them for literal git. The screen
lives in `vouchedRootIsSafe` rather than in the config gate so it also covers
the entry points that carry no cwd. Ordinary flags are untouched: `ib --json
list` and `ib list --format=json` stay read-only, pinned as tests.
R7-4. A pipeline written after a heredoc opener is parsed *inside* the
`heredoc_redirect` node, next to the body — `vtool <<EOF | rm -rf build` puts
the `rm` in a `pipeline` sibling — and the `redirected_statement` arm filters
every `*_redirect` child out before evaluation, so the whole write segment
vanished. The outermost statement-shaped children of a redirect are now
evaluated; anything deeper stays owned by the substitution walk. The shape
predates this PR for built-in roots, so `cat <<EOF | rm -rf build` is pinned
too.
R7-6. `go` and `nodejs` were in the refusal list but missing from the
companion regex, so `go1.22` — the literal name `go install golang.org/dl/…`
produces — and `nodejs18` were vouchable. Both families added.
R6-1 stays open by its own terms; the seven demonstrated names (`just`,
`rake`, `tox`, `dotnet`, `pipx`, `bazel`, `task`) and their task-runner and
environment-manager siblings are added, taking the floor to 155 entries.
The floor is now pinned entry by entry rather than by an `it.each` over the
constant it guards: every name is spelled out and the size asserted both ways,
so a deletion fails containment and an undeclared addition fails the count. A
mutant dropping one name fails the suite.
Also pins the default/assign/error/alternate expansion operators, substring
and subscript positions, the `changedDirectory` branch for vouched frontends,
and the `g++`/`cc`/`clang++` versioned families; tightens four heredoc
assertions from `not.toBe('read-only')` to exact categories; and un-mangles
three `it.each` titles that printf was eating. The e2e plan gains the `/plan`
step every restart silently needed, launch directories for cases 6c and 6d,
and the new refusal rows.
|
Merged On the merge: R7-1/2/3 — git's global options. This is the same mistake as R6-2 one level down: I gave a vouched wrapper git's planted-config gate, and then left the options that walk past the gate unscreened. I did not take the broad reading of the suggestion ("treat any leading-dash argument on a vouched root as The screen went into R7-5. Flag half fixed with the same screen — R7-4 is a real AST bug and the best find of this round. I had not registered that a pipeline written after a heredoc opener parses inside R7-6. Finite, as you say: two families were in the refusal list but missing from the companion regex. Both added, R6-1. Seven demonstrated names added, plus their task-runner and environment-manager siblings; the floor is at 155. My position is unchanged and I am not restating it — the maintainer decision you are waiting on is the right thing to wait on. One thing I did change is how the floor is tested, because your deferred probe was right that it could not detect its own erosion: the Other deferred items taken: default/assign/error/alternate expansion operators pinned (all already refused — coverage gap, not a hole), substring and subscript positions, the Verification: full |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.
Test Plan (not a blocker): src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more.
Deferred under the convergence posture (round 8, not a blocker) — recorded, not requested in this round:
packages/core/src/utils/shellAstParser.test.ts:590 — [review] False bash-semantics claim about <(...) in pattern words, in three places this PR addspackages/core/src/utils/shellAstParser.ts:1439 — [review] No test pins that an uppercase-spelled invocation of a vouched root still promptspackages/core/src/utils/shellAstParser.test.ts:702 — [review] The <<- always-one-raw-leaf coverage comment is false in both directions.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md:61 — [review] Restart warning misstates approval-mode persistence and the post-restart landing modepackages/core/src/utils/shellAstParser.ts:435 — [review] Quoted literal arguments refuse the vouch — whitelist tests raw text, not the stripped form.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md:178 — [review] The plan's session-cwd immutability premise is false — /cd and the tool directory parameter move the gate's directorypackages/core/src/utils/shellAstParser.test.ts:689 — [review] Arithmetic expansion $((…)) is unpinned in the hidden-channel test blocks
Convergence: round 8 posted 4 inline comment(s), 3 of them reported for the first time; the previous round posted 7 (6 new). Findings keep coming back to the same files: packages/core/src/utils/shellAstParser.ts (findings in rounds 6, 7; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)
[Critical] R8-2: The confirmation-scope filter in getConfirmationDetails (packages/core/src/tools/shell.ts:2129-2132; same shape packages/core/src/tools/monitor.ts:226-229) classifies each sub-command alone against the original cwd and has no model of state planted by a preceding sub-command, so a vouched root whose danger depends on that state is excluded from the displayed confirmation scope. Two demonstrated vectors: (1) cd /hostile && gitw status && curl example.com — after the cd, gitw status is probed at the clean original cwd, passes the planted-config gate and is dropped, so the dialog asks only about curl; (2) export GIT_DIR=/x/.git GIT_WORK_TREE=/x && gitw status — the export stays confirmable while gitw status is dropped the same way (a cd-tracking fix does not close this vector). Approval then executes gitw status inside the hostile repo, whose planted core.fsmonitor executes attacker code — the exact attack the planted-config gate this PR extends exists to prevent; the changed-directory fail-closed branch this PR added fires only when cd and the vouched root stay inside one classified string. Witness (probed end-to-end at this commit): SCOPE VOUCHED: defaultPermission=ask rootCommand="export" permissionRules=[] (gitw dropped) vs SCOPE UNVOUCHED: rootCommand="export, gitw"; FSMONITOR EXECUTED: marker file created by git status via env-planted GIT_DIR. Pre-PR, gitw status was always unknown and always in scope; the identical hiding pre-exists for built-in read-only commands, but the diff line is what makes the wrapper form newly reachable. Fix: mirror PermissionManager.evaluateCompoundCommand — if any sub matches the existing cd/pushd test or is an environment/state planter (export, declare, readonly, typeset, local), keep all later subs confirmable. (Inline placement at shell.ts:2131 was dropped as a location overlap with the existing R1-5 comments 3836659886/3836948926 — those report missing tests at this call site; this is the defect such tests would catch.)
[Critical] R6-1 (still stands — re-posted under its original id): the vouch refusal surface remains an unbounded enumeration of payload-executing programs. The round-7 minimum fix landed — all seven names demonstrated in round 7 (just, rake, tox, dotnet, pipx, bazel, task) are now in the 155-entry NEVER_READ_ONLY_ROOT_COMMANDS, verified at this commit — but the class mechanism still fires: this round demonstrated 28 fresh same-family names absent from both refusal mechanisms — ash, mksh, osh, yash, posh (shell families beside the listed bash/sh/dash/ksh/zsh), jshell, scala, groovy, tsx, ts-node, swift, racket (interpreters/runners), dart, julia, kotlin, elixir, escript, rscript, ocaml, guile, crystal, nim, clojure, ghc, runghc, tcc, zig, dmd (language toolchains). Witness (sweep at HEAD): all 28 report inNEVERset=false and vouched <name> ./evil.<ext> = read-only; controls bash/python3/ruby/make report inNEVERset=true -> unknown; VERSIONED_INTERPRETER covers none of the 28 families. Trigger: a Node project adds tsx (a natural entry) so tsx --version stops prompting; the agent then runs tsx ./build.sh from the untrusted repo under investigation — read-only, unattended, arbitrary writes. Six review rounds have now each produced a fresh batch of missed names; the author's recorded position is that the list is a floor and whether to refuse unknown roots entirely is a maintainer product decision — that decision is still outstanding, and until it is made or the surface is structurally closed this finding stands. Fix: close the class structurally — generate the refusal list from an authoritative enumeration with a per-entry regression battery, or refuse any root whose read-only-ness cannot be established from structure (the way env/sudo are unwrapped); minimum if deferred: add the 28 demonstrated names and extend the family-enumeration pins (REFUSAL_FLOOR forces the pairing). (Inline placement at shellAstParser.ts:162 was dropped as a location overlap with comments 3837407390/3838200967 — the round-3/4 postings of this same class under its then-id R2-1.)
[Critical] R8-5: A planted [alias] x = "!cmd" turns an argv-clean vouched wrapper verb into unattended code execution. The planted-config gate this PR extends to vouched wrappers (shellAstParser.ts:1579-1581) models only diff.external/core.fsmonitor; a hostile checkout's [alias] pwn = "!./evil.sh" is invisible to the probe, git executes !-aliases through the shell, and vouchedRootIsSafe passes pwn (a literal word naming no known command) — so gitw pwn classifies read-only and Plan Mode runs it unattended. Literal git never reaches this channel: evaluateGitSafety returns unknown for any sub-command outside READ_ONLY_GIT_SUBCOMMANDS — the filter the wrapper surface drops. Git refuses aliases that shadow built-ins, so the exposure is confined to non-builtin alias verbs — exactly the filter-less wrapper surface this PR adds; pre-PR gitw pwn was unknown and prompted. Witness (probed end-to-end at this commit with real classifier + real git): vouched gitw pwn in planted repo -> read-only; unvouched -> unknown; literal git pwn -> unknown; executing gitw pwn: status=0, marker-exists=true (alias payload executed); the candidate fix flips the vouched classification to unknown. Bounded sibling of the R7-5 family, enumerated separately. Fix: widen the risk probe to ^alias\. (and ^gpg\.program$) and report risk when any local/worktree-scoped alias value begins with ! — or refuse the vouch whenever the repo's local config contains any ! shell alias. (Inline placement at shellAstParser.ts:1580 was dropped as a location overlap with comment 3841589716 — R7-1, a different finding.)
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。
未审查:反向审计——在 5 轮的反审轮数上限内未收敛。
Test Plan(非阻断):src/utils/shellAstParser.test.ts — no such file or directory; src/config/config.test.ts — no such file or directory; src/core/plan-mode-shell-policy.test.ts — no such file or directory; src/tools/shell.test.ts — no such file or directory; src/tools/monitor.test.ts — no such file or directory; and 2 more。
收敛姿态下延后(第 8 轮,非阻断)——已记录,本轮不要求修改:共 7 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 8 轮发布了 4 条行内评论,其中 3 条是首次提出;上一轮发布了 7 条(其中 6 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/shellAstParser.ts(第 6、7 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
[Critical] R8-2: The confirmation-scope filter in getConfirmationDetails (packages/core/src/tools/shell.ts:2129-2132; same shape packages/core/src/tools/monitor.ts:226-229) classifies each sub-command alone against the original cwd and has no model of state planted by a preceding sub-command, so a vouched root whose danger depends on that state is excluded from the displayed confirmation scope. Two demonstrated vectors: (1) cd /hostile && gitw status && curl example.com — after the cd, gitw status is probed at the clean original cwd, passes the planted-config gate and is dropped, so the dialog asks only about curl; (2) export GIT_DIR=/x/.git GIT_WORK_TREE=/x && gitw status — the export stays confirmable while gitw status is dropped the same way (a cd-tracking fix does not close this vector). Approval then executes gitw status inside the hostile repo, whose planted core.fsmonitor executes attacker code — the exact attack the planted-config gate this PR extends exists to prevent; the changed-directory fail-closed branch this PR added fires only when cd and the vouched root stay inside one classified string. Witness (probed end-to-end at this commit): SCOPE VOUCHED: defaultPermission=ask rootCommand="export" permissionRules=[] (gitw dropped) vs SCOPE UNVOUCHED: rootCommand="export, gitw"; FSMONITOR EXECUTED: marker file created by git status via env-planted GIT_DIR. Pre-PR, gitw status was always unknown and always in scope; the identical hiding pre-exists for built-in read-only commands, but the diff line is what makes the wrapper form newly reachable. Fix: mirror PermissionManager.evaluateCompoundCommand — if any sub matches the existing cd/pushd test or is an environment/state planter (export, declare, readonly, typeset, local), keep all later subs confirmable. (Inline placement at shell.ts:2131 was dropped as a location overlap with the existing R1-5 comments 3836659886/3836948926 — those report missing tests at this call site; this is the defect such tests would catch.)
[Critical] R6-1 (still stands — re-posted under its original id): the vouch refusal surface remains an unbounded enumeration of payload-executing programs. The round-7 minimum fix landed — all seven names demonstrated in round 7 (just, rake, tox, dotnet, pipx, bazel, task) are now in the 155-entry NEVER_READ_ONLY_ROOT_COMMANDS, verified at this commit — but the class mechanism still fires: this round demonstrated 28 fresh same-family names absent from both refusal mechanisms — ash, mksh, osh, yash, posh (shell families beside the listed bash/sh/dash/ksh/zsh), jshell, scala, groovy, tsx, ts-node, swift, racket (interpreters/runners), dart, julia, kotlin, elixir, escript, rscript, ocaml, guile, crystal, nim, clojure, ghc, runghc, tcc, zig, dmd (language toolchains). Witness (sweep at HEAD): all 28 report inNEVERset=false and vouched <name> ./evil.<ext> = read-only; controls bash/python3/ruby/make report inNEVERset=true -> unknown; VERSIONED_INTERPRETER covers none of the 28 families. Trigger: a Node project adds tsx (a natural entry) so tsx --version stops prompting; the agent then runs tsx ./build.sh from the untrusted repo under investigation — read-only, unattended, arbitrary writes. Six review rounds have now each produced a fresh batch of missed names; the author's recorded position is that the list is a floor and whether to refuse unknown roots entirely is a maintainer product decision — that decision is still outstanding, and until it is made or the surface is structurally closed this finding stands. Fix: close the class structurally — generate the refusal list from an authoritative enumeration with a per-entry regression battery, or refuse any root whose read-only-ness cannot be established from structure (the way env/sudo are unwrapped); minimum if deferred: add the 28 demonstrated names and extend the family-enumeration pins (REFUSAL_FLOOR forces the pairing). (Inline placement at shellAstParser.ts:162 was dropped as a location overlap with comments 3837407390/3838200967 — the round-3/4 postings of this same class under its then-id R2-1.)
[Critical] R8-5: A planted [alias] x = "!cmd" turns an argv-clean vouched wrapper verb into unattended code execution. The planted-config gate this PR extends to vouched wrappers (shellAstParser.ts:1579-1581) models only diff.external/core.fsmonitor; a hostile checkout's [alias] pwn = "!./evil.sh" is invisible to the probe, git executes !-aliases through the shell, and vouchedRootIsSafe passes pwn (a literal word naming no known command) — so gitw pwn classifies read-only and Plan Mode runs it unattended. Literal git never reaches this channel: evaluateGitSafety returns unknown for any sub-command outside READ_ONLY_GIT_SUBCOMMANDS — the filter the wrapper surface drops. Git refuses aliases that shadow built-ins, so the exposure is confined to non-builtin alias verbs — exactly the filter-less wrapper surface this PR adds; pre-PR gitw pwn was unknown and prompted. Witness (probed end-to-end at this commit with real classifier + real git): vouched gitw pwn in planted repo -> read-only; unvouched -> unknown; literal git pwn -> unknown; executing gitw pwn: status=0, marker-exists=true (alias payload executed); the candidate fix flips the vouched classification to unknown. Bounded sibling of the R7-5 family, enumerated separately. Fix: widen the risk probe to ^alias\. (and ^gpg\.program$) and report risk when any local/worktree-scoped alias value begins with ! — or refuse the vouch whenever the repo's local config contains any ! shell alias. (Inline placement at shellAstParser.ts:1580 was dropped as a location overlap with comment 3841589716 — R7-1, a different finding.)
— qwen3.8-max via Qwen Code /review (v0.22.0)
R8-3/R8-4. Round 6 decided a vouched root is a possible git frontend and gave it git's planted-config gate; round 7 bolted two option regexes onto that. The rest of `evaluateGitSafety` was never wired up, so `gitw push origin main`, `gitw reset --hard`, `gitw branch -D`, `gitw diff --output=f` and `gitw log --format=%GG` all classified read-only while their literal twins classified `write` or `unknown`. Screening every vouched root through `evaluateGitSafety` would refuse every CLI whose verbs are not git's, so the screen fires only when the first non-flag argument is a git verb — then the wrapper gets exactly what literal git gets. `gitw status`, `gitw diff` and `gitw log --oneline` stay read-only; the cost is a prompt when a vouched CLI's own verb collides with one of git's. `gitw diff -o f` stays read-only because literal `git diff -o f` does: that gap is in `evaluateGitSafety` and predates this PR. Matching it is the point. R8-5 and R7-5's config-key half. The gate modelled `diff.external` and `core.fsmonitor` only, but a wrapper has no sub-command filter, so every other repository-local key that makes a read verb run a program reaches it — a textconv driver through `.gitattributes`, a clean/smudge filter, the gpg program, and `[alias] x = "!cmd"`, which git runs through the shell for a verb it does not recognise. `getLocalGitConfigRisk` now reports those as a third flag, consumed on the vouched path only: `git lfs install --local` writes `filter.lfs.clean`, so keying literal `git diff` to it would downgrade a large share of real checkouts. Closing that for literal git is a separate change, and the lfs case is pinned so this one cannot drift into it. R8-1. The redirect-child allow-list added in round 7 named the shapes its witness list happened to contain. tree-sitter nests whatever follows `&&`, `||` or `;` on the opener line inside the redirect, which is an open set — `negated_command`, `if_statement`, `c_style_for_statement`, `select_statement` and more all vanished, two of them without needing a vouch at all. Inverted to a skip-list of inert redirect leaves; everything else goes to `evaluateStatementSafety`, whose default arm floors an unknown type at `unknown`, so an unanticipated shape prompts instead of disappearing. R8-2. `getConfirmationDetails` classified each sub-command against the original cwd, so `cd /hostile && gitw status && curl x` dropped `gitw status` from the dialog and then ran it in the planted repository on approval. `export GIT_DIR=…` does the same with no `cd` at all. Both call sites now stop dropping sub-commands once one has planted state, mirroring `PermissionManager.evaluateCompoundCommand`. R6-1 stays open by its own terms; the 28 demonstrated names are added, taking the floor to 183 entries with the REFUSAL_FLOOR ratchet updated to match.
…readonly-commands
|
Round 8 fixed in R8-3/R8-4 are the finding I should have anticipated two rounds ago, and the diagnosis matters more than the patch. In round 6 I decided a vouched root is a possible git frontend and gave it git's planted-config gate. In round 7 I bolted two option regexes onto that. What I never did was ask the obvious follow-up: if this thing is a git frontend, why is it not going through So the fix is not another regex. When the first non-flag argument is a git verb, the whole invocation now goes through git's own evaluator and has to come back One row of your witness list I did not flip: R8-5 and R7-5's config-key half. You were right and my round-7 reasoning for declining was too broad. I refused the whole key-set extension because R8-1 is the same lesson as R8-3 in miniature: my round-7 fix enumerated the shapes your witness list happened to contain, and you found two more that need no vouch at all. Inverted to a skip-list of inert redirect leaves — everything else goes to R8-2 — good catch on the second vector. A cd-tracking fix really would not have closed R6-1. 28 names added, floor at 183, Verification: full |
|
Superseded by #9948 — same change, squashed into one commit, with a description rewritten to match what actually ships rather than the first draft. Closing this one because eight review rounds have made the thread unreadable, not because anything here is unresolved. Carrying forward:
Round 8's remaining deferred items are recorded and not lost; I will take them on the new PR. |
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 — nothing else about the analysis changes. The entry is consulted only at the very end of the classification chain, after every root the classifier already understands has been matched, so a listed entry can only ever add to the read-only set and can never turn a known write into a read. Listing
rm,git, orteeleavesrm -rf build,git push, andtee out.txtclassified exactly as before. Redirections, command substitution, environment-assignment prefixes, and pipes into unknown commands are likewise untouched: withiblisted,ib listruns silently whileib list > out.txtis still blocked as state-modifying andib list $(whoami)still prompts.Two categories of entry are rejected during normalisation. Anything that is not a bare command name — a path, a command with arguments, a string with shell metacharacters — is dropped, because the classifier matches on the lowercased root token and such an entry could never match anyway. Shell interpreters and generic command wrappers (
bash,sh,env,sudo,xargs,nohup,timeout,exec,eval, and similar) are also dropped: each exists to run some other command, so accepting one would let a single settings line bypass the analysis entirely.The setting is scoped to Plan Mode. It is 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 —
permissions.allowremains the mechanism there. Entries merge as a union across the user, project, and system scopes, and are dropped in--bareand safe mode, matching howpermissions.autoModebehaves.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 called out in the docs.
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 single 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 overridespermissions.allowfor shell, so an allow rule does not help, andPreToolUsehooks run after the permission decision and can only deny or ask, so they cannot help either.This gives users the same kind of explicit, narrow vouch they already make with
permissions.allowin every other mode, without relaxing any of Plan Mode's syntactic guarantees.Reviewer Test Plan
How to verify
Create a scratch workspace with a fake read-only CLI on
PATH(printf '#!/bin/sh\necho ok\n' > ib && chmod +x ib), addpermissions.planMode.extraReadOnlyCommands: ["ib"]to.qwen/settings.json, and enter Plan Mode with/plan. The full scripted plan is committed at.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md.Ask the model to run
ib domain list: it should run with no confirmation prompt. Remove the settings key and repeat: the "could not determine whether this shell command is read-only" prompt appears, and appears again on every identical invocation.Confirm the guardrails still hold while the key is in place.
ib domain list > out.txtmust be rejected as state-modifying, not prompted.ib domain list $(whoami)andIB_TOKEN=x ib domain listmust still show the one-time unknown prompt.ib domain list | badcmdmust still prompt, whileib domain list | wc -lruns silently.Confirm the safety net cannot be switched off from settings. Add
"bash","rm", and"git"to the list and restart:bash -c 'echo hi'must still prompt, andrm -rf tmpandgit push origin mainmust still be blocked as state-modifying.Confirm the scope. Leave Plan Mode with
/approval-mode defaultand runib domain list— the normal shell confirmation must appear. Switch back with/planand it stops prompting again, 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 should start normally, andib domain listshould run 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.
Unit tests, run from
packages/core:New coverage asserts the vouched root classifies read-only, that redirects, substitutions, env prefixes and unknown pipe targets are unaffected, that listing
rm/git/tee/mv/ddcannot override their built-in write classification, that normalisation drops malformed and wrapper entries, and that the accessor returns an empty set outside Plan Mode including across a runtime mode switch.Tested on
Environment (optional)
Unit tests via vitest, plus the settings JSON Schema regenerated with
npm run generate:settings-schema.Risk & Scope
permissions.allowfor unknown-classified shell commands in Plan Mode — that changes Plan Mode's trust model and needs its own design discussion. Sub-command scoping (a per-CLI analogue of the git sub-command table) is also out of scope, since custom CLIs place their verb at varying argument positions. The deprecated regex checker used as a fallback when the tree-sitter parser is unavailable is deliberately left alone: it ignores the setting and keeps prompting, which fails closed. The synchronous concurrency-batching check, the speculation gate, and memory-scoped agent config are likewise untouched.Linked Issues
Closes #9694
中文说明
这个 PR 做了什么
新增一个配置项,让你可以告诉 Plan 模式哪些额外的根命令是只读的,这样项目自定义 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)仍会弹窗。归一化阶段会拒绝两类条目。任何不是纯命令名的内容——路径、带参数的命令、含 shell 元字符的字符串——都会被丢弃,因为分类器匹配的是小写后的根命令 token,这类条目本来也匹配不上。shell 解释器和通用命令包装器(
bash、sh、env、sudo、xargs、nohup、timeout、exec、eval等)同样会被丢弃:它们存在的意义就是执行其它命令,接受其中任何一个都会让一行配置彻底绕过整个分析。该配置的作用域限定在 Plan 模式。它通过单一访问器读取,在其它所有审批模式下返回空集,因此为规划而给某个 CLI 的担保绝不会放宽 default、auto-edit、auto、yolo 模式下的自动批准——那些模式仍然使用
permissions.allow。条目在用户、项目、系统三个作用域之间以并集合并,并在--bare与 safe 模式下被丢弃,与permissions.autoMode的行为一致。一个条目是对整个二进制的担保。Qwen Code 无法看透自定义 CLI 的内部,因此如果它有写操作子命令,列出该命令也会一并免除那些子命令的确认。这个取舍在文档中已明确说明。
为什么需要
Plan 模式通过把根命令与一个硬编码集合做匹配来判断 shell 命令是否只读。集合之外的二进制无法判定,于是被归类为 unknown 并触发「无法确定该 shell 命令是否只读」的确认框。Plan 模式的 shell 确认有意隐藏「始终允许」且只接受一次性批准,因此该弹窗会在每一次调用时重新出现,永远如此。
对于 Plan 模式会话依赖项目自定义只读 CLI 的团队来说,每一次读都需要手动点击,而内建的等价命令(
cat、grep、git status)却静默通过。目前没有任何出路:Plan 模式有意覆盖 shell 的permissions.allow,所以 allow 规则没用;PreToolUse钩子在权限决策之后才运行且只能拒绝或询问,所以钩子也没用。本改动让用户能够做出与其它模式中
permissions.allow同样明确、同样窄的担保,同时不放松 Plan 模式的任何语法级保证。审阅者测试计划
如何验证
创建一个临时工作区,把一个假的只读 CLI 放进
PATH(printf '#!/bin/sh\necho ok\n' > ib && chmod +x ib),在.qwen/settings.json中加入permissions.planMode.extraReadOnlyCommands: ["ib"],然后用/plan进入 Plan 模式。完整的脚本化计划已提交在.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md。让模型执行
ib domain list:应当无确认弹窗直接运行。移除该配置项后重复:会出现「无法确定该 shell 命令是否只读」的弹窗,且每一次相同调用都会再次出现。在配置项存在的情况下确认护栏依然有效。
ib domain list > out.txt必须被判为写操作直接拒绝,而不是弹窗。ib domain list $(whoami)和IB_TOKEN=x ib domain list必须仍然显示一次性 unknown 弹窗。ib domain list | badcmd必须仍然弹窗,而ib domain list | wc -l静默执行。确认配置无法关掉安全网。把
"bash"、"rm"、"git"加进列表并重启:bash -c 'echo hi'必须仍然弹窗,rm -rf tmp与git push origin main必须仍被判为写操作并拦截。确认作用域。用
/approval-mode default退出 Plan 模式后执行ib domain list——必须出现正常的 shell 确认框。再用/plan切回,无需重启即可恢复静默执行。最后确认非法条目会被忽略而非导致崩溃:把列表设为
["", " ", "ib list", "/usr/local/bin/ib", "ib;rm", "IB"]并重启。CLI 应当正常启动,且仅凭"IB"这一条,ib domain list就应无弹窗运行。证据(改动前后)
N/A —— 无 TUI 变更。用户可见的差异是确认弹窗的消失,已由上述步骤和单元测试覆盖。
单元测试,在
packages/core目录下运行:新增覆盖断言了:被担保的根命令判定为只读;重定向、替换、环境变量前缀、未知管道目标不受影响;列出
rm/git/tee/mv/dd无法覆盖它们内建的写判定;归一化会丢弃格式错误的条目与包装器条目;访问器在 Plan 模式之外返回空集,包括运行时模式切换的情形。测试平台
环境(可选)
通过 vitest 运行单元测试,并用
npm run generate:settings-schema重新生成了 settings JSON Schema。风险与范围
permissions.allow——那会改变 Plan 模式的信任模型,需要单独的设计讨论。子命令级限定(针对每个 CLI 的、类似 git 子命令表的机制)同样超出范围,因为自定义 CLI 的动词位置各不相同。在 tree-sitter 解析器不可用时作为回退的已废弃正则检查器被有意保留原样:它会忽略该配置并继续弹窗,方向是 fail-closed。同步的并发批处理检查、推测执行闸门、memory-scoped agent config 同样未改动。关联 Issue
Closes #9694