Skip to content

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

Closed
TianYuan1024 wants to merge 2 commits into
QwenLM:mainfrom
TianYuan1024:feat/plan-mode-read-only-vouch
Closed

feat(core): let plan mode vouch for extra read-only shell roots#9948
TianYuan1024 wants to merge 2 commits into
QwenLM:mainfrom
TianYuan1024:feat/plan-mode-read-only-vouch

Conversation

@TianYuan1024

Copy link
Copy Markdown
Contributor

Supersedes #9735, which accumulated too many review rounds to read. Same change, squashed; the description below is rewritten to match what actually ships (the old one still described the first draft).

What this PR does

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

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

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

What bounds the vouch

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

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

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

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

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

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

Scope

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

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

Two fixes that are not about this setting

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

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

Why it's needed

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

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

Reviewer Test Plan

How to verify

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

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

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

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

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

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

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

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

Evidence (Before & After)

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

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

 Test Files  6 passed (6)
      Tests  2194 passed (2194)

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

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

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

Tested on

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

Risk & Scope

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

Linked Issues

Closes #9694

…LM#9694)

Plan mode judges a shell command by matching its root against a hardcoded
read-only set. A binary outside that set cannot be judged, so it classifies
`unknown` and prompts — and plan-mode shell confirmations hide "Always allow"
and accept `ProceedOnce` only, so the prompt returns on every invocation,
forever. A team whose plan-mode sessions run through a project-specific
read-only CLI clicks through every single read while `cat` and `git status`
pass silently. Neither `permissions.allow` nor a `PreToolUse` hook helps: plan
mode overrides the former for shell, and the latter runs after the decision.

`permissions.planMode.extraReadOnlyCommands` names root commands the user
vouches for. The entry is consulted at the very end of the dispatch chain,
after every root the classifier already understands, so it can only add to the
read-only set — listing `rm` or `git` leaves `rm -rf build` and `git push`
classified exactly as before. Redirections, substitutions, env-assignment
prefixes and pipes into unknown commands are untouched.

What bounds the vouch is not the name but the shape of the invocation:

- A refusal floor of 183 roots the vouch can never cover — interpreters,
  launchers, build and package tools, and builtins that rebind name
  resolution. Their payload is a code string, a recipe, or a downloaded
  package, so no argument inspection can see it. Companion regex matches
  versioned spellings by family (`python3.12`, `gcc-13`, `luajit-2.1.0-beta3`)
  rather than release by release. This is a floor under foreseeable mistakes,
  not a boundary: the list cannot be closed by enumeration, which is why the
  next item matters more.
- The vouch is honoured only for an invocation the classifier can read
  literally — every argument a plain literal word naming no known command, so
  a launcher nobody enumerated cannot smuggle one through.
- The setting is taken from user, system and system-default scopes only. A
  cloned repository cannot vouch for itself, which is what turns the floor
  from an adversarial boundary into a guard against user error.
- A vouched root is treated as a possible git frontend: when its first
  non-flag argument is a git verb the invocation is screened by git's own
  evaluator, and it inherits git's planted-config gate for repositories that
  set `diff.external`, `core.fsmonitor`, a textconv driver, a clean/smudge
  filter, `gpg.program`, or a `!` shell alias.

Scoped to plan mode through one accessor that returns an empty set in every
other approval mode, so a vouch made while planning never widens
auto-approval elsewhere. Dropped in `--bare` and safe mode.

An entry vouches for the whole binary. Qwen Code cannot see inside a custom
CLI, so a mutating sub-command is silenced too; that tradeoff is documented.

Two defects found while building this are fixed here because the vouch turns
each from a prompt into an unattended run, and both also affect built-in
roots today:

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

Tests: 2,194 in the six affected core suites, 625 in the four cli config
suites. `shellAstParser.test.ts` carries 881, including a two-way ratchet on
the refusal floor — a deleted entry fails containment, an undeclared addition
fails the count.

Closes QwenLM#9694
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

  • Template ✓ — all required headings present. Minor: the template's Chinese <details> translation section is missing from the body.

  • Problem: real and observed. Closes Plan mode: configurable read-only shell command allowlist (custom CLIs prompt on every invocation) #9694 — an open, labeled (priority/P2, type/feature-request, category/security, scope/shell) feature request with a concrete case: a project-specific CLI (ib) where every read in Plan Mode needs a one-time approval click because the read-only root set is hardcoded and Plan Mode deliberately hides "Always allow". Not theoretical.

  • Direction: the usability gap is genuine and the design choices sound right in the abstract — user/system scopes only (a workspace cannot vouch for itself), shape guards so a vouch can't launder a write, Plan-Mode-only scope, fail-closed on invalid entries. But this lands squarely in the shell permission classifier, which is security-critical surface, so this needs maintainer awareness regardless of how the code review goes. CHANGELOG (claude-code): no direct counterpart setting found, but plan-mode shell classification is an active area there (e.g. auto-mode classifier judging commands the static analyzer can't prove read-only), so the area is relevant.

  • Size: core paths touched. ~834 production logic lines (+802/−32; shellAstParser.ts alone is 630 of them) vs ~1407 test lines, 13 schema lines, ~638 docs/e2e-plan lines. A feat at 500+ production lines isn't blocked, but it is flagged: escalating to the maintainer for awareness — this review will not auto-approve.

  • Approach: the setting itself is small; most of the diff is the safety apparatus around it (a 183-entry refusal floor plus versioned-name regex, git-verb screening for vouched wrappers, an extended planted-git-config gate) plus two classification fixes for built-in roots that ship regardless of the setting — the heredoc-nested-statements drop and the cd/export confirmation-dialog cwd scoping. The PR is candid that it is "not byte-for-byte inert" without the setting. Those two fixes are independently valuable and independently risky; splitting them into their own PR would make each revertable and shrink this diff. Worth considering before merge. One more thing to be upfront about: this is the same change as feat(core): let plan mode vouch for extra read-only shell roots #9735, which went through eight bot review rounds that never converged (findings kept surfacing in shellAstParser.ts) and closed with outstanding change requests. Squashing and rewriting the description doesn't reset that history — the review burden here is correspondingly high.

  • Risk: elevated. packages/core/src/tools/shell.ts matches the repo's high-risk path list (correlated with post-merge reverts). Consequence: full Stage 2 enrichments, CI evidence required before any approval decision, and a named sandboxed verification lane.

Moving on to code review — with the standing caveat that the verdict caps at defer-to-maintainer for the size/security-surface reasons above. 🔍

中文说明

感谢贡献!

  • 模板 ✓ —— 必需标题齐全。小问题:正文缺少模板中的中文翻译 <details> 部分。

  • 问题:真实且已被观测到。关联 Plan mode: configurable read-only shell command allowlist (custom CLIs prompt on every invocation) #9694 —— 一个开放、带标签(priority/P2type/feature-requestcategory/securityscope/shell)的功能请求,有具体场景:项目专用 CLI(ib)在 Plan Mode 下每次读取都需要一次性批准,因为只读根命令集是硬编码的,且 Plan Mode 刻意隐藏"始终允许"。不是理论性问题。

  • 方向:可用性缺口真实存在,设计取向在抽象层面也合理 —— 仅用户/系统作用域(工作区无法为自己担保)、形状守卫(担保无法夹带写操作)、仅 Plan Mode 生效、非法条目失败即关闭。但它正落在 shell 权限分类器上,这是安全关键面,因此无论代码审查结果如何都需要维护者关注。CHANGELOG(claude-code):未找到直接对应的设置,但 plan-mode shell 分类在那边是活跃领域(例如由 auto-mode 分类器判定静态分析无法证明只读的命令),方向相关。

  • 规模:触及核心路径。约 834 行生产逻辑(+802/−32;其中 shellAstParser.ts 独占 630 行),约 1407 行测试、13 行 schema、约 638 行文档/e2e 计划。500+ 生产行的 feat 不会被阻断,但会被标记:升级至维护者关注 —— 本次审查不会自动批准。

  • 方案:设置本身很小;diff 的大头是围绕它的安全机制(183 项拒绝底线加版本名正则、对被担保包装命令的 git 动词筛查、扩展的 git 预置配置门禁),外加两个无论是否配置该设置都会生效的内置根命令分类修复 —— heredoc 嵌套语句丢失问题、以及 cd/export 后确认对话框的 cwd 作用域问题。PR 自己也承认并非"字节级无副作用"。这两个修复各有独立价值也各有独立风险;拆成单独的 PR 可以让各自独立可回滚,也能缩小本 diff。建议合并前考虑。另外需要坦率指出:本 PR 与 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 #9735 经历了八轮机器人审查始终未收敛(问题反复出现在 shellAstParser.ts),关闭时仍有未完成的修改请求。压缩提交、重写描述并不会重置这段历史 —— 本次审查负担因此相当高。

  • 风险:升级。packages/core/src/tools/shell.ts 命中本仓库高风险路径清单(与合并后回滚相关)。后果:Stage 2 全量增强项、任何批准决定前必须有 CI 证据、并指定沙箱验证通道。

进入代码审查 —— 但保留一个前提:由于上述规模/安全面原因,结论上限为"移交维护者"。🔍

Qwen Code · qwen3.8-max

Reviewed at f512e2c38883b9de0cfd04b7b03c3036814967a1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

My independent proposal for this problem was the minimal version of what ships here: a permissions.planMode.extraReadOnlyCommands setting validated to bare names, merged from user/system scopes only, read through one mode-gated accessor, consulted as a strictly terminal fallback in the classifier behind shape guards, with a non-vouchable deny list. The PR matches that and then goes further where the use case demands it (git-wrapper screening, planted-config gate extension), so I reviewed it against the stronger design rather than arguing for the smaller one.

I walked the security surface adversarially and found no blocking issues. What I verified, with the pinning tests:

  • Dispatch order is load-bearing and correct. The vouch branch is strictly terminal: write roots, git/find/sed/awk/tee/dd/kill-family and every other dedicated evaluator dispatch first, and vouchedRootIsSafe re-checks namesAKnownCommand(root) on top — so a vouch can only ever add unknown roots to the read-only set, never override a built-in classification. The .exe angle is covered too: git.exe push / rm.exe -rf are refused because known-command matching strips one trailing .exe (pinned in "sees through a Windows .exe spelling of a known command"), including in =-separated arguments like --exec=/bin/rm.
  • Shape guards hold. Non-literal arguments (quoting, expansion, globs), arguments naming known commands (ib exec rm -rf build, ib watch), git redirecting global options (-c, --git-dir, …), env prefixes (IB_TOKEN=x ib … still prompts — stripShellWrapper only drops the prefix when a real shell wrapper follows, so the classifier sees the assignment), redirects, pipes to unknowns, substitutions including heredoc bodies and ${v%%$(…)} pattern words. All floor at unknown/write.
  • Scope containment holds. One accessor returns the set only in PLAN mode; --bare/--safe-mode strip it; workspace scope is stripped via the existing WORKSPACE_RESTRICTED_SETTINGS machinery with a startup warning; the deprecated regex fallback ignores the setting (fails closed); speculationToolGate and memory-scoped agent config don't pass the vouch, which is stricter, not looser.
  • The refusal floor is pinned two-way (deletion fails containment, undeclared addition fails the count), with sibling-pair edge cases and one versioned spelling per regex family. This is unusually disciplined test engineering; the author also mutation-tested the ratchet.

Findings (none blocking)

  1. The description understates the bundled behavior changes. It lists "two fixes that are not about this setting" (heredoc nested-statement drop, cd/export confirmation scope), but the diff also ships three more classifier changes for built-in roots with no setting involved: a heredoc-body substitution scan ($(…)/backticks/${v@P} in unquoted-delimiter bodies), a hidden-substitution check for pattern words (${v%%$(…)}), and a leaf-expansion @P regex. All fail closed — they turn previously read-only classifications into prompts, never the reverse — so they're safe in direction, but they change behavior for users who never configure this setting and they should be itemized in the description so a maintainer can see the complete behavior delta before merge.
  2. Splitting question (from the gate, kept open). Those fixes are independently valuable and independently revertable; landing them separately would shrink a diff that is already ~834 production lines in the security-critical classifier, on a high-risk path, with the feat(core): let plan mode vouch for extra read-only shell roots #9735 history behind it. Not a blocker — but I'd weigh it seriously given that the previous round never converged.
  3. Hygiene: the PR body is missing the template's Chinese <details> translation section.
sequenceDiagram
    participant S as Settings scopes
    participant C as Config
    participant T as Shell and Monitor tools
    participant P as Plan mode policy
    participant A as AST classifier
    participant G as Git config probe
    S->>C: merge planMode, workspace value stripped
    C->>C: normalize entries to bare lowercase names
    P->>C: getPlanModeReadOnlyRoots
    C-->>P: set, empty outside Plan Mode
    T->>A: classify with extra roots via same accessor
    P->>A: classify with extra roots
    A->>A: built-in roots dispatch first, vouch is terminal
    A->>G: probe repo-local git config for vouched roots
    G-->>A: program-executing keys present or not
    A-->>P: read-only, write or unknown
Loading
Files changed (25 of 25 shown)
File What changed
.qwen/e2e-tests/2026-08-22-plan-mode-extra-read-only-commands.md Committed E2E test plan for the setting, scratch QWEN_HOME based
docs/design/2026-08-22-plan-mode-extra-read-only-commands.md Committed design doc for the vouch and its guardrails
docs/users/configuration/settings.md Settings table row for the new key
docs/users/features/approval-mode.md User-facing section explaining vouch semantics, costs, and scope
packages/cli/src/acp-integration/session/Session.test.ts Mock config gains the new accessor
packages/cli/src/config/config.test.ts Pins bare/safe-mode stripping and the happy path
packages/cli/src/config/config.ts Passes planMode into core config, stripped in bare/safe mode
packages/cli/src/config/settings.test.ts Scope handling: user honored, workspace stripped and warned
packages/cli/src/config/settingsSchema.ts Schema entry with union merge and full description
packages/cli/src/config/settingsUtils.ts Adds planMode to workspace-restricted keys
packages/core/src/config/config.test.ts Normalization and mode-gating tests
packages/core/src/config/config.ts normalizePlanModeReadOnlyRoots plus the PLAN-only accessor
packages/core/src/core/coreToolScheduler.test.ts Mock config gains the new accessor
packages/core/src/core/plan-mode-shell-policy.test.ts Vouch honored in policy without loosening other rules
packages/core/src/core/plan-mode-shell-policy.ts Forwards the accessor into both classify calls
packages/core/src/permissions/permission-manager.test.ts Vouched root resolves default branch to allow
packages/core/src/permissions/permission-manager.ts resolveDefaultPermission passes the options through
packages/core/src/tools/monitor.test.ts Vouch forwarding and confirmation-scope tests
packages/core/src/tools/monitor.ts Vouch in default permission; state-planted guard in confirmation scope
packages/core/src/tools/shell.test.ts Vouch, redirect/substitution guards, state-planted scope tests
packages/core/src/tools/shell.ts Vouch in default permission; sub-commands after a planter stay in confirmation scope
packages/core/src/utils/git-config-safety.ts helperProgram risk flag: textconv, filters, gpg.program, bang aliases
packages/core/src/utils/shellAstParser.test.ts 943 lines: floor ratchet, shape guards, heredoc fixes, wrapper screening
packages/core/src/utils/shellAstParser.ts The vouch fallback, refusal floor, shape guards, git-wrapper screening, heredoc/planted fixes
packages/vscode-ide-companion/schemas/settings.schema.json Companion schema for the new key

Test evidence (PR's own CI, fetched via API — PR code never executed here)

At fetch time the main unit suite had not finished; per workflow this is a single fetch with no polling, and the Qwen Triage Finalize job will rewrite the table below once CI settles on this commit.

The two red checks are classified as pre-existing infra noise, not PR-caused: SDK Java / ubuntu-latest / Java 11 and Java 17 both fail at the checkout step with fatal: couldn't find remote ref refs/pull/9948/merge — a timing race right after PR creation, before any PR code runs; the PR is MERGEABLE, and the same workflow's Java 21 jobs (all three OSes) plus Real daemon E2E / Java 11 are green. The macOS/Windows unit legs and the CLI integration suite show skipped in the still-running CI run (workflow gating).

Check Conclusion
Test (ubuntu-latest, Node 22.x) ⏳ in progress
Test (macos-latest, Node 22.x) skipped (run in progress)
Test (windows-latest, Node 22.x) skipped (run in progress)
Integration Tests (CLI, No Sandbox) skipped (run in progress)
SDK Java — Java 21 (ubuntu/macos/windows) ✅ success
SDK Java — ubuntu-latest / Java 11 ❌ failure (checkout race, pre-existing)
SDK Java — ubuntu-latest / Java 17 ❌ failure (checkout race, pre-existing)
Real daemon E2E / Java 11 ✅ success
Desktop Shell (ubuntu-22.04 / windows-2022) ✅ success
Secret scan (TruffleHog) ✅ success
Dependency CVE audit ✅ success
Classify PR / precheck / label ✅ success

Not verified in this run: live product behavior (a real Plan Mode session where ib domain list stops prompting, the workspace-scope startup warning, mode switching without restart). This run is unattended CI and never executes PR code.

Sandboxed verification would settle this: @qwen-code /verify — that the vouch actually silences the Plan Mode prompt end-to-end, and that the guardrails hold against a real TUI session, is not observable from the diff, and the behavioral claim currently rests on the author's macOS-only testing. This is a sponsored run (the author lacks write access, so /tmux is unavailable): a maintainer's @qwen-code /verify approves the head it was written against, and the run carries a pre-execution risk screen plus a full workspace wipe before any PR code executes. Read the resulting report with the same skepticism as the fork's own CI logs — the code under verification is adversarial input, and a crafted PR can shape what the report says even though the sandbox bounds what it can do.

中文说明

代码审查

我对这个问题的独立方案是本 PR 落地方案的最小版本:一个校验为裸命令名的 permissions.planMode.extraReadOnlyCommands 设置、仅从用户/系统作用域合并、通过单一的模式门禁访问器读取、作为分类器中严格的末端兜底(在形状守卫之后)被查询,外加一份不可担保的拒绝清单。PR 与此一致,并在用例要求的地方走得更远(git 包装命令筛查、预置配置门禁扩展),因此我按更强的设计来审,而不是主张更小的方案。

我对安全面做了对抗性走查,未发现阻断问题。已验证(均有对应测试钉住):

  • 分发顺序是承重的,且正确。 担保分支严格位于末端:写命令根、git/find/sed/awk/tee/dd/kill 族等所有专用求值器先行分发,且 vouchedRootIsSafe 还额外复查 namesAKnownCommand(root) —— 因此担保只能把未知根加入只读集合,永远无法覆盖内置分类。.exe 角度也已覆盖:git.exe push / rm.exe -rf 会被拒绝,因为已知命令匹配会剥去一个尾部 .exe= 分隔参数(如 --exec=/bin/rm)同样覆盖。
  • 形状守卫成立。 非字面参数(引号、展开、通配符)、为已知命令命名的参数(ib exec rm -rf buildib watch)、git 重定向全局选项(-c--git-dir 等)、环境变量前缀(IB_TOKEN=x ib … 仍会提示 —— stripShellWrapper 只在后跟真正的 shell 包装器时才剥除前缀,分类器仍能看到赋值)、重定向、管道到未知命令、各类替换(含 heredoc 体与 ${v%%$(…)} 模式词),全部落到 unknown/write
  • 作用域收敛成立。 单一访问器仅在 PLAN 模式返回集合;--bare/--safe-mode 剥除;工作区作用域经现有 WORKSPACE_RESTRICTED_SETTINGS 机制剥除并在启动时告警;弃用的正则兜底忽略该设置(失败即关闭);speculationToolGate 与 memory-scoped agent config 不传入担保,方向是更严而非更松。
  • 拒绝底线以双向棘轮钉住(删除条目会破坏包含性检查,未声明的新增会破坏计数检查),并带同族配对边界用例与每个正则族一个版本化拼写。这是非常高纪律性的测试工程;作者还做了变异测试验证棘轮。

发现(均非阻断)

  1. 描述低估了捆绑的行为变更。 描述列出"两个与本设置无关的修复"(heredoc 嵌套语句丢失、cd/export 确认作用域),但 diff 还夹带了另外三处无需配置即生效的分类器变更:heredoc 内替换扫描(未加引号定界符体中的 $(…)/反引号/${v@P})、模式词隐藏替换检查(${v%%$(…)})、以及叶子展开的 @P 正则。三者都是失败即关闭方向 —— 把原先只读的分类变为提示,绝不反向 —— 方向安全,但它们改变了从未配置此设置的用户的行为,应在描述中逐条列出,让维护者在合并前看到完整的行为差异。
  2. 拆分问题(来自门禁阶段,保留)。 这些修复各有独立价值、可独立回滚;单独落地可以缩小这个已经约 834 行生产代码、落在安全关键分类器高风险路径上、且背负 feat(core): let plan mode vouch for extra read-only shell roots #9735 历史的 diff。不构成阻断 —— 但考虑到上一轮审查始终未收敛,值得认真权衡。
  3. 规范: PR 正文缺少模板中的中文翻译 <details> 部分。

测试证据(来自 PR 自身 CI,经 API 获取 —— 本审查未执行任何 PR 代码)

获取时主单元测试尚未完成;按流程只取一次快照、不轮询,Qwen Triage Finalize 任务会在该提交的 CI 结束后就地更新上方表格。

两个红色检查判定为既有基础设施噪音、非本 PR 造成:SDK Java / ubuntu-latest / Java 11Java 17 均失败于 checkout 步骤的 fatal: couldn't find remote ref refs/pull/9948/merge —— PR 刚创建时的时序竞争,发生在任何 PR 代码运行之前;PR 状态为 MERGEABLE,同一工作流的 Java 21 任务(三个操作系统)与 Real daemon E2E / Java 11 均为绿色。macOS/Windows 单元测试与 CLI 集成测试在仍在运行的 CI run 中显示 skipped(工作流门控所致)。

本次运行未验证:真实产品行为(ib domain list 在真实 Plan Mode 会话中停止提示、工作区作用域启动告警、免重启切换模式)。本次为无人值守 CI 运行,从不执行 PR 代码。

沙箱验证可以定论:@qwen-code /verify —— 担保是否真的端到端消除了 Plan Mode 提示、守卫在真实 TUI 会话中是否成立,无法从 diff 观察,目前行为性主张仅依赖作者的 macOS 单平台测试。这是一次赞助运行(作者无写权限,/tmux 不可用):维护者的 @qwen-code /verify 评论批准其书写时对应的 head,该运行在任何 PR 代码执行前带有预执行风险筛查与完整工作区清除。请以审视 fork 自身 CI 日志的同等怀疑态度阅读产出的报告 —— 被验证的代码是对抗性输入,精心构造的 PR 可以影响报告_说什么_,尽管沙箱限制了它_能做什么_。

Qwen Code · qwen3.8-max

Reviewed at f512e2c38883b9de0cfd04b7b03c3036814967a1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean code review with no blocking findings, but the Stage 0 size escalation on security-critical core surface caps this run at defer-to-maintainer; the cap is policy, not doubt about the code.

Stepping back: this is a rare fork PR where the engineering discipline exceeds the bar most in-house work meets. The vouch is layered honestly — user-only scope as the load-bearing layer, shape guards as the readable layer, the refusal floor as a floor rather than a boundary, and the author says so in writing instead of overselling. The two-way ratchet on the refusal floor, the sibling-pair edge tests, the mutation-tested claim — that's the work of someone who expected to be attacked on this surface and pre-answered the attacks. I probed the same surface independently (.exe spellings, =-separated argument smuggling, the stripShellWrapper env-prefix interaction, dispatch-order bypasses) and every probe was already pinned by a test.

My honest reservations are about scope and process, not correctness:

  • The diff ships five behavior changes for users who never configure the setting, and the description itemizes only two. All five fail closed, so none is a security concern — but a maintainer approving this is approving all five, and the description should say so.
  • The bundled fixes would each make a fine standalone PR. Given the history — feat(core): let plan mode vouch for extra read-only shell roots #9735 went eight review rounds without converging and closed with outstanding change requests — shrinking this surface before merge is worth serious consideration.
  • The main unit suite had not completed at review time, so green CI is still an open fact, not an observed one.

If this were a 200-line PR with the same test discipline, I would have approved it. It isn't, so I'm handing it over: 834 production lines in the shell permission classifier, on a high-risk path, from a fork, is a maintainer decision by this repo's own rules regardless of how clean the review reads.

中文说明

置信度:3/5 —— 代码审查干净、无阻断性发现,但 Stage 0 对安全关键核心面的规模升级将本次运行的上限锁定为"移交维护者";该上限是政策性的,并非对代码质量的怀疑。

退一步看:这是一个少见的、工程纪律超过多数内部提交水准的 fork PR。担保机制的分层是诚实的 —— 用户专属作用域作为承重层,形状守卫作为可读层,拒绝底线作为"下限"而非"边界",作者白纸黑字地如此表述,没有夸大。拒绝底线的双向棘轮、同族配对边界测试、经过变异验证的断言 —— 这是预期到该安全面会被攻击、并预先回应了攻击的工作。我独立探测了同一表面(.exe 拼写、= 分隔参数夹带、stripShellWrapper 环境变量前缀交互、分发顺序绕过),每一次探测都已有测试钉住。

我诚实的保留意见在范围与流程,而非正确性:

  • diff 为从未配置该设置的用户带来了五处行为变更,描述只列出了两处。五处全部是失败即关闭方向,均无安全顾虑 —— 但批准这个 PR 的维护者实际上是在批准全部五处,描述应当如实列出。
  • 捆绑的修复各自都可以成为独立的优秀 PR。考虑到历史 —— feat(core): let plan mode vouch for extra read-only shell roots #9735 经历八轮审查未收敛、关闭时仍有未完成的修改请求 —— 合并前缩小本次变更面值得认真考虑。
  • 审查时主单元测试套件尚未完成,绿色 CI 仍是待验证的事实,而非已观察到的结果。

如果这是一个具备同样测试纪律的 200 行 PR,我会直接批准。它不是,所以我将其移交:834 行生产代码落在 shell 权限分类器这一高风险路径上、来自 fork,按本仓库自身的规则,无论审查读起来多么干净,这都是维护者的决定。

Qwen Code · qwen3.8-max

Reviewed at f512e2c38883b9de0cfd04b7b03c3036814967a1 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @wenshao (cc @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC, /packages/core/ owners per CODEOWNERS) — needs a human call on this one.

Why this run is not approving, despite a clean review:

  1. Stage 0 escalation. ~834 production logic lines (802+/32−, of which shellAstParser.ts alone is 630) in core paths on a feat PR — at 500+, this repo's rules escalate to maintainer awareness and forbid auto-approval.
  2. Security-critical surface. The change rewrites trust boundaries in the Plan Mode shell classifier (vouch semantics, refusal floor, planted-git-config gate). Direction and mechanics read as sound under adversarial review, but this is exactly the kind of change where a human maintainer should own the sign-off.
  3. High-risk path. packages/core/src/tools/shell.ts is on this repo's revert-correlated path list.
  4. History. This supersedes feat(core): let plan mode vouch for extra read-only shell roots #9735, which went eight review rounds without converging and closed with outstanding change requests — same change, squashed.
  5. CI not yet green at review time. The main unit suite was still in progress; the two red SDK Java checks are a pre-existing checkout race (see the review comment), not PR-caused.

Open questions for the maintainer, from the review:

  • Should the bundled classifier fixes (heredoc nested statements, cd/export confirmation scope, heredoc-body substitutions, pattern-word substitutions, @P leaf expansions) land as their own PR(s)? The description itemizes two of five; all five fail closed, but all five change behavior without the setting.
  • Is @qwen-code /verify worth sponsoring to settle the end-to-end behavioral claim before merge (author lacks write access, so it would be a sponsored run)?

The full reasoning is in the Stage 1 gate and Stage 2 review comments in this thread.

中文说明

⏸️ 移交 @wenshao(抄送 @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC,按 CODEOWNERS 为 /packages/core/ 负责人)—— 此事需要人工决定。

本次运行不予批准的原因(尽管审查干净):

  1. Stage 0 升级。 核心路径上约 834 行生产逻辑(+802/−32,其中 shellAstParser.ts 独占 630 行)的 feat PR —— 达到 500+ 后,本仓库规则要求升级至维护者关注并禁止自动批准。
  2. 安全关键面。 该变更重写了 Plan Mode shell 分类器的信任边界(担保语义、拒绝底线、预置 git 配置门禁)。对抗性审查下方向与机制均属健全,但这类变更正应由人类维护者负责签字。
  3. 高风险路径。 packages/core/src/tools/shell.ts 位于本仓库与回滚相关的路径清单上。
  4. 历史。 本 PR 取代 feat(core): let plan mode vouch for extra read-only shell roots #9735,后者经历八轮审查未收敛、关闭时仍有未完成的修改请求 —— 同一改动,压缩重提。
  5. 审查时 CI 尚未全绿。 主单元测试套件仍在运行中;两个红色的 SDK Java 检查是既有的 checkout 时序竞争(见审查评论),非本 PR 造成。

留给维护者的开放问题(来自审查):

  • 捆绑的分类器修复(heredoc 嵌套语句、cd/export 确认作用域、heredoc 体替换、模式词替换、@P 叶子展开)是否应拆为独立 PR?描述只列出五处中的两处;五处均为失败即关闭方向,但五处都会在未配置该设置时改变行为。
  • 是否值得赞助一次 @qwen-code /verify 以在合并前定论端到端行为主张(作者无写权限,因此将是赞助运行)?

完整推理见本线程的 Stage 1 门禁与 Stage 2 审查评论。

Qwen Code · qwen3.8-max

Reviewed at f512e2c38883b9de0cfd04b7b03c3036814967a1 · re-run with @qwen-code /triage

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plan mode: configurable read-only shell command allowlist (custom CLIs prompt on every invocation)

2 participants