Repository navigation
feat(core): let plan mode vouch for extra read-only shell roots - #9950
TianYuan1024 wants to merge 8 commits into
Conversation
|
Thanks — both classifier findings are fixed in 1. Attached git short options — verified, then hardened anyway. Real git rejects the attached form. So there was no live bypass: a wrapper that forwards argv unchanged ( Where they are refused matters. I did not widen Pinned both directions, per your ask that either outcome be pinned: the five attached-form witnesses joined the wrapper refusal table, a new test asserts the non-git CLIs keep their 2. You were right, and That predicate is duplicated in Both fixes were mutant-tested — reverting either fails exactly the six new assertions and nothing else. 3. Splitting the two bundled classifier fixes — your call, not mine to make unilaterally. Still happy to split on request. I'd only note the ordering cost: the confirmation-scope fix is what this PR's On the red CI leg: Verification on the merged tree ( The PR body now carries the Chinese 中文说明两个分类器问题都已在 1. git 贴写短选项——先验证,再照样加固。 真实 git 拒绝贴写形式。 所以并不存在现成的绕过:原样转发 argv 的包装器( 在哪里拒绝很关键。 我没有把 按你"无论结果如何都应补进测试"的要求,两个方向都已钉扎:5 个贴写形式witness 并入包装器拒绝表;新增测试断言非 git 的 CLI 保留自己的 2. 你说得对,而且 该谓词在 两个修复都做了变异测试——回退任一处,恰好只有新增的那 6 条断言失败。 3. 拆分那两个顺带的分类器修复——由维护者决定,我不擅自动手。 随时可以按要求拆。仅提示一个顺序上的成本:确认范围那个修复正是本 PR 的 关于 CI 红灯: 合并 PR 正文已补上 Stage 1 要求的中文 |
|
All nine Critical findings and all ten Suggestions are addressed in Root cause 1 — R1-1 / R1-2: the planter predicate was text, not shapeYou were right that the list can't be finished. The predicate is now decided on the classifier's own parse: only a plain R1-2 was orthogonal, as you said, and survived the shape fix: One correction to the R1-1 report: Root cause 2 — R1-3 / R1-7: the git-shape test was too narrow, and failing it skipped all git screeningR1-3 and R1-7 entrance 1 are the same defect seen from two sides: a verb the shape test didn't recognise meant no screening at all. Recognition now runs against git's complete 170-command vocabulary ( For R1-7 I did not take the suggested regex as written.
Scanning now covers every argument up to The rest
One decline, with reasoningR1-18 — I did not add Verification
On the review's own gaps: the 中文说明九个 Critical 与十个 Suggestion 全部在 根因一 —— R1-1 / R1-2:planter 判定基于文本而非形态你说得对,这份清单无法枚举完。判定现在改为基于分类器自己的解析结果:只有"解析出的名字不是 planter 的普通 R1-2 如你所说是正交问题,且在形态修复后依然存在: 对 R1-1 报告的一处更正: 根因二 —— R1-3 / R1-7:git 形态识别过窄,一旦不匹配就跳过全部 git 筛查R1-3 与 R1-7 的入口 1 是同一缺陷的两个侧面:形态识别不认识的动词意味着完全不筛查。识别现在对照 git 的完整 170 条命令表( R1-7 我没有照搬建议的正则。
扫描范围现已覆盖 其余各项
一处不采纳及理由R1-18 —— 我没有把 验证
关于评审自述的缺口:Test Plan 里的 |
|
All six Critical findings and all 14 Suggestions are addressed in Two round-1 fixes that were inertR1-4 (round 2) — you are right, and I should have checked this empirically. Git normalises the section and name parts of a config key, so The camelCase branch matched nothing, so R2-21 — R2-38 ( Two root causesR2-22 — the versioned check was a second hand-written list, so of course it drifted. Your sweep found 183 floor names with a vouchable R2-36 — same lesson as R1-1, one level down. The shape rewrite closed non- One correction from implementing it: I first added the redirect check in both the The rest
Mutation resultsEvery arm this round touches was mutation-tested; each kills at least one test and none survives: Verification
Nothing declined this round. 中文说明六个 Critical 与全部 14 个 Suggestion 均已在 上一轮两个失效的修复R1-4(第二轮)—— 你是对的,这一条我本该实测。 git 会规范化配置键的 section 与 name 部分,所以 camelCase 分支什么都匹配不到, R2-21 —— 已加入 R2-38( 两个根因R2-22 —— 版本检查是第二份手写清单,漂移是必然的。 你的扫描发现 183 个 floor 名字的 R2-36 —— 与 R1-1 同一教训,只是低了一层。 形态化重写关闭了非 实现过程中的一处更正:我最初在 其余各项
变异测试结果本轮触及的每个分支都做了变异测试,各自至少杀死一个测试,无一存活(表见上文英文部分)。 验证已先合入 本轮无不采纳项。 |
tree-sitter parses whatever follows a heredoc opener on the same line *inside* the `heredoc_redirect` node, beside the body. The `redirected_statement` arm filtered every redirect child out before evaluation, so both kinds of thing written there vanished from the analysis: - a statement — `cat <<EOF && rm -rf build` classified `read-only`, as did the `;`, `|`, `||`, `&` spellings and every compound shape (`for`, `if`, `while`, a block, a negation, a subshell, a `case`); and - a redirect — `cat <<EOF >out.txt` and `cat <<EOF 2>out.txt` classified `read-only` too, because `evaluateRedirectionSafety` only walks the direct children of the `redirected_statement` and never reached inside the heredoc node. Both are now evaluated, each on the right axis: a child that is itself a redirect goes to `evaluateRedirectionSafety`, anything else goes to `evaluateStatementSafety`. The inert leaves — the delimiters, the body, a bare file descriptor — are named in a skip-list rather than the statement shapes being named in an allow-list, so an unanticipated shape is evaluated and floored at `unknown` instead of silently dropped. `cat <<EOF 2>&1` stays read-only: that names a descriptor, not a file. Both arms are mutation-verified: removing the redirect routing fails 4 tests, removing the statement walk fails 12.
…odies
Two places where tree-sitter-bash yields a single leaf node, so the
substitution walk finds nothing to collect while bash still runs what is
inside.
The pattern word of `${v%%…}`, `${v%…}`, `${v##…}`, `${v#…}`, `${v^^…}`,
`${v^…}`, `${v,,…}`, `${v,…}` is one leaf, and so is each half of
`${v/pat/rep}` and the operand of `${v:-…}`, `${v:=…}`, `${v:?…}`, `${v:+…}`.
`echo ${x%%$(rm -rf build)}` therefore classified `read-only` and would have
run unattended. Since the collection pass found nothing, an opener still
present in the expansion text is exactly that hidden channel, so the leaf is
refused on the text.
A heredoc body is one leaf too — always for `<<-`, and for `<<` whenever
nothing inside it parsed — and bash expands it before feeding it to stdin.
Expansion there follows double-quote rules, so `$(…)`, backticks and `${v@P}`
run while `<(…)` does not; the body is refused for the first three only. A
quoted delimiter (`<<'EOF'`, `<<"EOF"`, `<<\EOF`) makes the body inert and is
exempted.
`${v@P}` is included in both because a prompt expansion runs any `$(…)` held in
the variable's value, and in a pattern word or a body it is a leaf that the
`@`/`P` child-adjacency check never sees. The regex is deliberately not
anchored to a brace-free span: `${a[${b}]@p}` nests a brace, and a `[^{}]*`
bridge stops at it.
These are leaf fallbacks for sites the node walk cannot reach, so over-refusing
costs at most a prompt. Mutation-verified: neutralising the three regexes fails
28, 10 and 2 tests respectively, and dropping the quoted-delimiter exemption
fails 1.
…n scope
A confirmation dialog is built by splitting a compound command and dropping the
parts that classify read-only, so the user approves only what needs approving.
That is sound only while each part means the same thing alone as it does in
sequence — and after a `cd`, an `export` or a `hash -p` it does not.
`cd /hostile && git status && npm run build` showed the user `npm`, dropped
`git status` as independently read-only, and then ran the whole original
compound, `git status` included, in the planted directory. Approval covered a
command the dialog never showed.
Both call sites — `ShellTool` and `MonitorTool` — now stop dropping
sub-commands once an earlier one has planted state, mirroring
`PermissionManager.evaluateCompoundCommand`. The planter is decided *before*
its own drop decision and is never dropped itself: `cd /hostile` classifies
read-only alone, and it is precisely the segment the user most needs to see.
The gate also covers the allow-rule path, since a `Bash(git *)` rule matches on
a sub-command's own text, which after a `cd` no longer says where it runs.
Whether a segment plants is decided on the parse, not on the raw text. A
leading-word regex cannot be completed: the planter hides behind a block
(`{ cd /hostile; }`), a keyword (`if true; then cd /hostile; fi`), an
assignment prefix (`FOO=1 cd`), a resolution-order prefix (`command -p cd`), a
respelling (`\cd`, `"cd"`), a negation (`! cd`), or a function definition that
rebinds a trusted name (`git() { rm -rf $HOME; }`), and a bare `PATH=evil`
assignment carries no command word at all. So only a plain `command` node whose
resolved name is not a planter is cleared, and every other shape fails closed —
which costs a larger dialog rather than a silently narrowed one.
The planter set covers the builtins that rebind what a later command resolves
to or reads: the `cd`/`export` family, the assigning builtins (`read`,
`mapfile`, `readarray`, `getopts`, `printf -v`), and the resolution-rebinding
ones (`hash`, `alias`, `unalias`, `trap`, `enable`, `fc`, `shopt`, `let`,
`exec`). A substitution in a redirect target plants too: `echo x < $(./evil.sh)`
runs the script before anything after it is classified.
Mutation-verified on both sides: removing either `statePlanted` gate, the
allow-rule guard, or the planter decision itself fails 1–3 tests each.
# Conflicts: # packages/core/src/utils/shellAstParser.test.ts
# Conflicts: # packages/core/src/utils/shellAstParser.test.ts
Adds `permissions.planMode.extraReadOnlyCommands`, a list of root command names Plan Mode treats as read-only in addition to its built-in set, so a project's own read-only CLI stops triggering an approval prompt on every invocation. A listed root joins the classifier's read-only set at the very end of the dispatch chain, after every root the classifier already understands has been matched, so it can only ever add: listing `rm`, `git` or `tee` leaves `rm -rf build`, `git push` and `tee out.txt` classified exactly as before. Redirections, command substitution, environment-assignment prefixes and pipes into unknown commands are untouched. Four layers bound the vouch, in decreasing order of how much weight they carry: 1. Only the user can make it. The setting is read from user, system and system-default scopes only; a workspace `.qwen/settings.json` is stripped during the merge with a startup warning. A cloned repository cannot vouch for itself, which is what makes the remaining layers a guard against user error rather than against an adversary who picks the entry. 2. The invocation must be one the classifier can read: every argument a plain literal word naming no command Qwen Code knows. `ib exec rm -rf build` prompts even though `ib` is vouched. 3. A refusal floor of 221 roots — interpreters, launchers, build and package tools, and the builtins that rebind name resolution — which no caller can vouch back in, plus the versioned spellings of every one of them, derived from the floor itself rather than a second list. 4. Git-shaped invocations are screened by git's own evaluator against its full command vocabulary, and a vouched root inherits git's planted-config gate, extended to every repository-local key that makes a read verb execute a program. The setting applies only in Plan Mode, read through one accessor that returns an empty set in every other approval mode, and is dropped in `--bare` and safe mode like `permissions.autoMode`. An entry vouches for the entire binary: Qwen Code cannot see inside a custom CLI, so a mutating sub-command of a vouched root is silenced too. The refusal floor is a floor under foreseeable mistakes, not a boundary — `uv run evil.py` and `ib get ./report.json` are structurally identical — and layer 1 is what makes that tradeoff supportable. Both are documented rather than implied.
3ddbca1 to
1f247a6
Compare
|
Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration. 中文请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。 |
The restack appended the heredoc and hidden-substitution suites onto a file that already carried them, so three titles existed twice and `vitest/no-identical-title` failed the lint lane. Both surviving copies are the richer ones: the heredoc-body suite kept here is a strict superset of the deleted copy (it also pins the vouched `extraReadOnlyRoots` shapes), and the pattern-word suite was byte-identical. No assertion is lost.
|
Pushed Lint lane fixed. The restack had appended the heredoc and hidden-substitution suites onto a file that already carried them, so three titles existed twice and Both surviving copies are the richer ones, verified by diff before deleting: the heredoc-body suite kept at line 760 is a strict superset of the deleted copy — it additionally pins the vouched Merged Not fixed, and not ours: Round R3 is still open — 14 findings reviewed at |
What this PR does
Adds a setting that lets you tell Plan Mode which extra root commands are read-only, so a project-specific CLI stops triggering an approval prompt on every single read.
{ "permissions": { "planMode": { "extraReadOnlyCommands": ["ib"] } } }A listed root joins the classifier's built-in read-only set. The entry is consulted at the very end of the dispatch chain, after every root the classifier already understands has been matched, so it can only ever add to the read-only set — listing
rm,git, orteeleavesrm -rf build,git push, andtee out.txtclassified exactly as before. Redirections, command substitution, environment-assignment prefixes, and pipes into unknown commands are untouched: withiblisted,ib listruns silently whileib list > out.txtis still blocked as state-modifying andib list $(whoami)still prompts.What bounds the vouch
The interesting question is not "which names are allowed" but "what stops a vouch from laundering a write". Four layers, in decreasing order of how much weight they carry:
1. Only the user can vouch. The setting is read from user, system, and system-default scopes only; a workspace
.qwen/settings.jsonis stripped during the merge and a startup warning names the key. This is the load-bearing one. A cloned repository cannot vouch for itself, which means the lists below guard against user error rather than against an adversary who picks the entry.2. The invocation has to be one the classifier can read. A vouch says "this binary only reads"; it can never say "and so does whatever I pass it". So the vouch is honoured only when every argument is a plain literal word that names no command Qwen Code knows.
ib exec rm -rf buildprompts even thoughibis vouched andib execis not otherwise special — the refusal is on shape, so a launcher nobody enumerated cannot use the vouch to smuggle a known command past the analysis.3. A refusal floor of 183 roots. Shell and language interpreters, launchers, build and package tools, and builtins that rebind name resolution can never be vouched. Their payload is a code string, a Makefile recipe, or a package downloaded mid-command — never argv — so no argument inspection can see it. A companion regex matches versioned spellings by family (
python3.12,gcc-13,luajit-2.1.0-beta3,go1.22) rather than release by release.This list is a floor under foreseeable mistakes, not a boundary, and I want to be explicit about that rather than imply otherwise: it cannot be closed by enumeration.
uv run evil.pyand a custom CLI'sib get ./report.jsonare structurally identical, so no classifier can tell a user who vouched a payload-executor from one who vouched their own read-only tool. Layer 1 is what makes that acceptable — the wrong assertion is the user's own, in their own settings file.4. Git gets special handling, because a vouched wrapper of
gitis a case this setting explicitly supports. When a vouched root's first non-flag argument is a git verb, the whole invocation is screened by git's own evaluator — write verbs,branch -D,--output, the%G…signature formats. A vouched root also inherits git's planted-config gate, extended for the wrapper path to every repository-local key that makes a read verb execute a program:diff.external,core.fsmonitor, atextconvdriver, a clean/smudge filter,gpg.program, and!-prefixed shell aliases. Repositories that plant none of these — the ordinary case — are unaffected.Scope
The setting applies only in Plan Mode, read through one accessor that returns an empty set in every other approval mode, so vouching for a CLI while planning never widens auto-approval in default, auto-edit, auto, or yolo mode. Entries are dropped in
--bareand safe mode, matchingpermissions.autoMode.An entry vouches for the entire binary. Qwen Code cannot see inside a custom CLI, so if it has mutating sub-commands, listing it silences the prompt for those too. That tradeoff is documented.
Two fixes that are not about this setting
Both affect built-in roots today; they are here because the vouch turns each from a prompt into an unattended run.
redirected_statementarm filtered every redirect child out before evaluation.cat <<EOF && for ((i=0;i<1;i++)); do rm -rf build; doneclassifiedread-onlywith no vouch involved. Now a skip-list of inert redirect leaves, with unrecognised shapes floored atunknownso an unanticipated one prompts instead of vanishing.cd /hostile && git status && curl xdroppedgit statusfrom the scope the user approved, then ran it in the planted repository. Both call sites now stop dropping sub-commands once an earlier one has planted state (cd,export, …), mirroringPermissionManager.evaluateCompoundCommand.Why it's needed
Plan Mode decides whether a shell command is read-only by matching its root against a hardcoded set. A binary outside that set cannot be judged, so it classifies as unknown and triggers the "could not determine whether this shell command is read-only" prompt. Plan-mode shell confirmations deliberately hide "Always allow" and accept a one-time approval only, so that prompt reappears for every invocation, forever.
For a team whose Plan Mode sessions run through a project-specific read-only CLI, every read needs a manual click while the built-in equivalents (
cat,grep,git status) pass silently. There is no way out today: Plan Mode intentionally overridespermissions.allowfor shell, andPreToolUsehooks run after the permission decision and can only deny or ask. APermissionRequesthook can suppress the prompt, but only by writing a hook that re-implements the classification.Reviewer Test Plan
How to verify
The full scripted plan is committed at
.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md. It uses a scratchQWEN_HOMEso the vouch never touches your real settings, and notes the/planstep every restart needs — approval mode is session state, so a post-restart case run without it silently exercises the default mode instead.Create a scratch workspace with a fake read-only CLI on
PATH(printf '#!/bin/sh\necho ok\n' > ib && chmod +x ib), putpermissions.planMode.extraReadOnlyCommands: ["ib"]in$QWEN_HOME/settings.json, and enter Plan Mode with/plan.Ask the model to run
ib domain list: it should run with no confirmation prompt. Remove the key and repeat — the prompt appears, and appears again on every identical invocation.Confirm the guardrails hold.
ib domain list > out.txtmust be rejected as state-modifying, not prompted.ib domain list $(whoami)andIB_TOKEN=x ib domain listmust still 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","git","make", and"uv"and restart:bash -c 'echo hi',make, anduv run x.pymust still prompt;rm -rf tmpandgit push origin mainmust still be blocked.Confirm a workspace cannot vouch for itself: move the settings file into the repository's own
.qwen/, restart, and the prompt returns with a startup warning namingpermissions.planMode.Confirm the scope:
/approval-mode default, thenib domain list— the normal shell confirmation must appear./planagain and it stops prompting, with no restart.Finally, confirm invalid entries are ignored rather than fatal: set the list to
["", " ", "ib list", "/usr/local/bin/ib", "ib;rm", "IB"]and restart. The CLI starts normally andib domain listruns 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.
shellAstParser.test.tscarries 889 of those. The refusal floor is pinned entry by entry with a two-way ratchet — a deleted entry fails containment, an undeclared addition fails the count — verified with a mutant that drops one name and fails the suite.Tested on
Risk & Scope
cat <<EOF && …shapes that previously classifiedread-onlynow classifywriteorunknown, and confirmation dialogs after acd/exportlist more sub-commands than before.ib add,ib tag); one that spells its config flag-cor-C; and one whose argument names a command the classifier knows (ib exec watch). All three are documented. The attached spellings (-C/hostile,-ccore.fsmonitor=…) are refused only once the invocation is already git-shaped, so a CLI with its own-cpor-Cdirflag is unaffected.permissions.allowfor unknown-classified shell commands in Plan Mode (changes Plan Mode's trust model). Sub-command scoping. The deprecated regex fallback used when tree-sitter is unavailable is left alone deliberately — it ignores the setting and keeps prompting, which fails closed. ExtendinggetLocalGitConfigRisk's new key set to literalgitis also left out:git lfs install --localwritesfilter.lfs.clean, so that would downgradegit diffin a large share of real checkouts and wants its own PR. A test pins the git-lfs case so this cannot drift.中文说明
这个 PR 做了什么
新增一个配置项,让你告诉 Plan Mode 哪些额外的根命令是只读的,这样项目专用 CLI 就不会在每次读取时都弹出确认框。
{ "permissions": { "planMode": { "extraReadOnlyCommands": ["ib"] } } }列出的根命令会并入分类器内置的只读集合。该配置在分发链的最末端才被查询——排在分类器已经理解的所有根命令之后——所以它只能做加法:即使把
rm、git、tee写进去,rm -rf build、git push、tee out.txt的分类也完全不变。重定向、命令替换、环境变量赋值前缀、管道进入未知命令,这些规则一律不受影响:配置了ib之后,ib list静默执行,而ib list > out.txt仍被判定为修改状态而拦截,ib list $(whoami)仍会弹窗。什么在约束这份背书
真正的问题不是"允许哪些名字",而是"什么阻止一次背书被用来洗白一次写操作"。四层防线,按承重程度递减:
1. 只有用户本人能背书。 该配置仅从 user、system、system-default 作用域读取;workspace 的
.qwen/settings.json在合并阶段被剥离,并在启动时给出点名该 key 的警告。这一层最承重:被克隆的仓库无法为自己背书,也就意味着下面几层防的是用户误用,而不是能自行挑选条目的攻击者。2. 调用形态必须是分类器读得懂的。 一次背书说的是"这个二进制只读",它永远说不了"以及我传给它的任何东西也只读"。因此只有当每个参数都是纯字面量词、且不指向任何 Qwen Code 认识的命令时,背书才生效。即便
ib已被背书、ib exec也没有任何特殊性,ib exec rm -rf build依然弹窗——拒绝依据的是形态,所以一个没人枚举过的启动器无法借背书把已知命令偷渡过分析。3. 183 个根命令的拒绝底线。 shell 与语言解释器、启动器、构建与包管理工具、以及重绑定名称解析的内建命令,永远无法被背书。它们的载荷是代码字符串、Makefile 配方,或命令执行中途下载的包——从来不是 argv——所以任何参数检查都看不见它。配套正则按家族匹配带版本号的拼写(
python3.12、gcc-13、luajit-2.1.0-beta3、go1.22),而不是逐个版本追加。这份清单是可预见误用之下的底线,不是边界,我想把这点明说而不是暗示相反:它无法靠枚举收敛。
uv run evil.py与自定义 CLI 的ib get ./report.json在结构上完全相同,所以没有任何分类器能区分"背书了一个载荷执行器的用户"和"背书了自己只读工具的用户"。让这件事可以接受的是第 1 层——错误的断言出自用户本人,写在他们自己的配置文件里。4. git 有专门处理,因为"为 git 包装器背书"正是这个配置明确要支持的场景。当被背书根命令的第一个非 flag 参数是 git 子命令时,整个调用会交给 git 自己的求值器筛查——写类子命令、
branch -D、--output、%G…签名格式。被背书的根命令同时继承 git 的植入配置门禁,并为包装器路径扩展到所有"能让读类子命令执行程序"的仓库级配置键:diff.external、core.fsmonitor、textconv驱动、clean/smudge filter、gpg.program,以及!前缀的 shell 别名。不含这些配置的仓库——也就是绝大多数情况——完全不受影响。作用范围
该配置仅在 Plan Mode 生效,通过唯一一个访问器读取,该访问器在其他任何审批模式下都返回空集合。因此在规划时为某个 CLI 背书,绝不会扩大 default、auto-edit、auto、yolo 模式下的自动批准范围。
--bare与 safe mode 下条目被丢弃,与permissions.autoMode保持一致。一个条目背书的是整个二进制。Qwen Code 看不进自定义 CLI 内部,所以如果它带有修改类子命令,列出它同样会让那些子命令免于弹窗。这一取舍已写入文档。
两个与本配置无关的修复
两者今天就影响内置根命令;放在这里是因为背书会把它们各自从"弹窗"变成"无人值守执行"。
redirected_statement分支在求值前把所有重定向子节点都过滤掉了。于是cat <<EOF && for ((i=0;i<1;i++)); do rm -rf build; done在完全不涉及背书的情况下被判为read-only。现改为惰性重定向叶子节点的跳过清单,未识别的形态一律下压到unknown——这样意料之外的形态会弹窗,而不是凭空消失。cd /hostile && git status && curl x会把git status从用户批准的范围中剔除,然后在被植入的仓库里执行它。现在两处调用点在前序子命令植入状态(cd、export等)之后都不再剔除后续子命令,与PermissionManager.evaluateCompoundCommand保持一致。为什么需要它
Plan Mode 判断一条 shell 命令是否只读,靠的是拿它的根命令去匹配一个硬编码集合。集合之外的二进制无从判断,于是被归为 unknown 并触发"无法确定该 shell 命令是否只读"的弹窗。而 Plan Mode 的 shell 确认框刻意隐藏了"始终允许"、只接受一次性批准,所以这个弹窗会在每一次调用时重新出现,永远如此。
对于 Plan Mode 会话要走项目专用只读 CLI 的团队,每一次读取都要手动点一下,而内置的等价物(
cat、grep、git status)却静默通过。今天没有绕过的办法:Plan Mode 有意对 shell 覆盖permissions.allow,而PreToolUsehook 在权限决策之后才运行、且只能拒绝或询问。PermissionRequesthook 确实能压掉弹窗,但代价是写一个把分类逻辑重新实现一遍的 hook。风险与范围
read-only的cat <<EOF && …形态现在会被判为write或unknown;cd/export之后的确认对话框会列出比以前更多的子命令。ib add、ib tag);把自己的配置 flag 拼作-c或-C的 CLI;参数指向分类器已知命令的 CLI(ib exec watch)。三者均已写入文档。贴写形式(-C/hostile、-ccore.fsmonitor=…)只在调用已呈 git 形态时才拒绝,所以带-cp、-Cdir这类自有 flag 的 CLI 不受影响。permissions.allow(会改变 Plan Mode 的信任模型)。子命令级作用域。tree-sitter 不可用时的已废弃正则回退路径刻意不动——它忽略该配置并继续弹窗,属于 fail-closed。把getLocalGitConfigRisk新增的键集扩展到字面量git也刻意排除:git lfs install --local会写入filter.lfs.clean,那样会在相当比例的真实检出中降级git diff,应当单开一个 PR。已有测试钉住 git-lfs 这一情形,防止漂移。