Skip to content

fix(core): recognize sed quiet aliases as read-only - #12221

Merged
wenshao merged 5 commits into
QwenLM:mainfrom
dvd233:fix/12215-sed-long-options
Sep 26, 2026
Merged

wenshao merged 5 commits into
QwenLM:mainfrom
dvd233:fix/12215-sed-long-options

Conversation

@dvd233

@dvd233 dvd233 commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Recognizes GNU sed's --quiet and --silent aliases as read-only, matching the existing -n classification. Sed scripts that write output files remain classified as writes. Regression coverage exercises both the AST and legacy shell-safety paths.

Why it's needed

The safety allowlist already included --quiet and --silent, but an earlier long-option guard returned unknown before the allowlist could run. Equivalent read-only commands therefore received unnecessary confirmation prompts and lost concurrency-safe classification.

Reviewer Test Plan

How to verify

  • Run sed --quiet 's/hello/world/' file.txt and sed --silent -n '1,5p' file.txt: both classify as read-only.
  • Run sed --quiet 'w output.txt' file.txt and the equivalent --silent command: both remain writes and still require confirmation.
  • Run the focused core test files listed below.

Evidence (Before & After)

Before: the generic long-option guard returned unknown for --quiet and --silent before the existing safe-option allowlist was reached.

After: those aliases reach the same safety classification as -n; write scripts remain unsafe in both classification paths. The focused tests pass 782/782.

Tested on

OS Status
🍏 macOS ⚠️ not tested locally
🪟 Windows ✅ tested
🐧 Linux ✅ current head CI passed

Environment (optional)

Windows, Node.js 24.15.0, npm 11.12.1. The focused core tests (782/782), core typecheck, targeted ESLint, and CLI workspace build passed. On the current PR head, GitHub Linux tests, no-AK integration, lint/static, Linux and Windows desktop shell, no-flicker, and TUI parity checks pass. The separate review-pr automation failed because its runner could not load the PR review cache and reported unhealthy directories; retrying that job requires repository-admin permissions.

Risk & Scope

  • Main risk or tradeoff: classifying a read-only alias as safe must not allow sed write scripts. Both AST and legacy-path tests cover the w script case.
  • Not validated / out of scope: non-GNU sed implementations.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #12215

中文说明

这个 PR 做了什么

将 GNU sed 的 --quiet 和 --silent 别名识别为只读,与现有 -n 分类一致。会写出文件的 sed 脚本仍分类为写操作。回归覆盖 AST 和旧版 shell safety 两条路径。

为什么需要

现有安全 allowlist 已包含 --quiet 和 --silent,但更早执行的长选项检查会先返回 unknown,使 allowlist 无法生效。因此等价的只读命令会触发多余确认,并失去并发安全分类。

审查者测试计划

如何验证

  • 执行 sed --quiet 's/hello/world/' file.txt 和 sed --silent -n '1,5p' file.txt:两者都应分类为只读。
  • 执行 sed --quiet 'w output.txt' file.txt 及对应的 --silent 命令:两者仍是写操作并需要确认。
  • 运行下方列出的 core 定向测试。

前后证据

修改前:通用长选项检查会在现有安全选项 allowlist 之前将 --quiet 和 --silent 判为 unknown。

修改后:这两个别名与 -n 使用相同的安全分类;AST 和旧版路径中的写脚本测试仍保持不安全。定向测试 782/782 通过。

测试平台

OS 状态
🍏 macOS ⚠️ 本地未测试
🪟 Windows ✅ 已测试
🐧 Linux ✅ 当前 head CI 已通过

环境(可选)

Windows、Node.js 24.15.0、npm 11.12.1。core 定向测试(782/782)、core 类型检查、相关 ESLint 检查和 CLI workspace 构建通过。当前 PR head 的 GitHub Linux tests、no-AK integration、lint/static、Linux/Windows desktop shell、no-flicker 与 TUI parity checks 均通过。独立的 review-pr 自动审查因 runner 找不到 PR review cache 且 runner 目录不健康而失败;重跑该 job 需要仓库管理员权限。

风险与范围

  • 主要风险或取舍:将只读别名分类为安全时,不能放行 sed 写脚本;AST 和 legacy 路径均有 w 写脚本覆盖。
  • 未验证或不在范围内:非 GNU sed 实现。
  • 破坏性变更或迁移说明:无。

关联 Issue

Fixes #12215

@dvd233

dvd233 commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@qwen-code /review

@dvd233

dvd233 commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@qwen-code /triage

@dvd233

dvd233 commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@qwen-code /review

@wenshao

wenshao commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao

wenshao commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

Verification report — PR 12221 fix(core): recognize sed quiet aliases as read-only

Verdict: merge-ready — 72/72 scripted assertions passed, 0 unexpected failures.
Verified head aaba62f0258b2daa83580f2bc6fa7f419666b71c (fork dvd233/qwen-code, branch fix/12215-sed-long-options); base / true merge-base 34246c66068dc496e937adc05b01661e66bf1e1f. The production change is a 2-line deletion in packages/core/src/utils/shell-safety-rules.ts (blob 1ff179c8 at head, 3402bcb1 at base); the control arm of every A/B below is a rebuild whose source file hash-matches the base blob byte-for-byte, so the arms differ by nothing but this hunk.

中文摘要

结论:merge-ready(可合并) —— 72/72 条脚本化断言通过,0 个意外失败。验证 head aaba62f0,base/merge-base 34246c66。

  • 核心结论(A/B):删除那两行长选项 guard 后,sed --quiet … / sed --silent … 的只读脚本在两条真实产品路径上都变为免确认并并发安全:确认弹窗 4/43 → 16/43(+12,-0),并发安全分类 2/43 → 14/43(+12,-0)。全部 12 个发生变化的用例都带 --quiet/--silent,无任何旁路漂移(见 04-ab-dialog-and-concurrency-flip.png)。
  • 安全性证明:6156 组 sed 参数普查中 base→head 的 304 处差异全部携带别名 token,0 处非别名差异;head 下别名与 -n 在 752 次替换中逐位一致(base 仅 454/752,说明该结论非空转)。12 条新免确认命令在真实 GNU sed 4.9 下实际执行后文件系统逐字节未变;12 条写命令在两个 arm 上仍弹确认,且真实写盘(含 sed --quiet 'e touch pwned' 这类命令执行型脚本)。
  • 真实环境 E2E:同一命令、同一 default 审批模式下,head 的 TUI 直接执行(✓ Shell …,无弹窗),control 弹出 "Allow execution of: 'sed'?" 四选项确认框(见 01-…png 与 02-…png)。
  • 测试有效性:把修复回退(源码 = base blob)后,PR 新增的 8 条断言中有 6 条被杀死(非空转);2 条(legacy 路径里对 --quiet/--silent 'w output.txt' 的 toBe(false))存活,因为 base 返回 unknown 同样映射为 false —— 属冗余防御而非缺陷。
  • 门禁:core typecheck 与 eslint 全绿且已做活性验证(植入错误必红);定向 core 测试 782/782(与 PR 描述一致);被顺带修改的 cli 集成测试 32/32;与当前 main 试合并无冲突且 main 未触碰本 PR 任何文件。
  • Findings(均非阻塞):① PR 夹带了一个与标题无关的改动(narrow-diff.integration.test.ts 的 rmSync 重试),描述中未声明;② 两份 SAFE_SED_OPTION 副本已漂移(-l/--line-length 在 AST 路径免确认但 legacy 路径不并发安全),两 arm 一致,属既有问题;③ isCommandNeedsPermission() 无生产调用方(死代码候选);④ sed 的 y/// 及若干长选项形式仍被保守判为 unknown(既有过度保守,方向安全)。
  • 未覆盖:macOS/Windows 未跑(该 rmSync 重试针对的 EPERM/EBUSY 路径在 Linux 上不会触发);root integration-tests 套件未跑(CI 覆盖);非 GNU sed 未测(PR 已声明范围外);auto 审批模式的 LLM 分类器路径未测;按 commit 逐条归因未做(验证的是 base..head 聚合 diff)。

1. Central claim and the A/B

Central claim. sed --quiet and sed --silent are GNU sed's documented aliases of -n (confirmed against the real binary, §2), so read-only scripts using them must classify as read-only exactly like -n, which means (a) no confirmation dialog via ShellTool.getDefaultPermission() (packages/core/src/tools/shell.ts:2191, the AST path) and (b) concurrency-safe dispatch via isToolCallConcurrencySafe() (packages/core/src/core/coreToolScheduler.ts:1432, the legacy path). At base a generic long-option guard short-circuited both to unknown before the allowlist could be consulted.

Control fidelity. Both arms are the same worktree rebuilt from a source file whose git hash-object equals the respective commit blob (3402bcb1… base, 1ff179c8… head); git status showed that file as the only difference. Internal node_modules/@qwen-code/* links were realpath-asserted to resolve inside the worktree, so no arm could load the other's code.

cell build oracle dialog-free (allow) concurrency-safe
control source = base blob 3402bcb1 getDefaultPermission() / isToolCallConcurrencySafe() over 43 real command strings 4/43 2/43
head source = head blob 1ff179c8 same 16/43 14/43
delta +12 / −0 +12 / −0

All 12 rows that moved carry a literal --quiet or --silent token; 0 non-alias rows moved (H4-B4). The head arm reproduces bit-identically across two independent builds (43/43 rows, H6-G9). Witness: 04-ab-dialog-and-concurrency-flip.png, and at the TUI level 01-e2e-head-auto-approved-no-dialog.png vs 02-e2e-control-confirmation-dialog.png.

Blast-radius census (pure classifier). 6,156 distinct sed argument lists (every long/short option GNU sed 4.9 documents, hostile near-miss spellings, three option positions, two-option combinations, -- terminator forms) run through both commit blobs: 304 verdict deltas, 304 carry an alias, 0 do not; shapes unknown→read-only ×206 and unknown→write ×98 (the latter is a precision gain, never a laxing). Alias↔-n substitution parity: 752 substitutions, 0 divergences at head; the same parity held on only 454/752 at base, proving the parity claim is load-bearing rather than vacuous. Witness: 03-census-6156-arglists-all-deltas-carry-alias.png.

Real-environment E2E (the user-visible surface). Identical scratch project, identical prompt, identical --approval-mode default (the host's global settings are yolo, which would mask everything, so the mode was pinned explicitly):

arm what the real TUI did witness
head ✓ Shell sed --quiet '1,3p' file.txt — executed immediately, output shown, no dialog 01-e2e-head-auto-approved-no-dialog.png
control ? Shell sed --quiet '1,3p' file.txt → "Allow execution of: 'sed'?" with the 4-option menu, Waiting for user confirmation… 02-e2e-control-confirmation-dialog.png

2. Ground truth from the real GNU sed binary

The PR's premise is that --quiet/--silent are aliases of -n; that was verified from the tool itself, not from documentation: 16 scripts × 3 spellings produced byte-identical stdout and exit codes (H2-G1), and 48 real executions left the scratch directory byte-identical (H2-G2). The filesystem oracle is not blind: all 5 intended writes (-i, w, s///w, W, -i.bak) were detected (H2-G3). Critically, --quiet does not defang a writing script — all 5 writers still wrote when prefixed with --quiet (H2-G4) — which is exactly why the classifier must keep them unsafe, and H1-S1/H3-X1/X2 show it does: no command any arm called read-only ever touched the disk, on either arm.

3. Corrections

None required. The linked issue #12215's root-cause account (the guard at the SAFE_SED_OPTION consumer short-circuits --quiet/--silent to unknown; the allowlist entries were unreachable) matched my measurements exactly, including its claim that --line-length=80 was already read-only at base. The bot review rounds on this PR disclose that no finding was ever independently verified (all carried [unverified]), so there was no earlier substantive round to carry forward; this is the first measurement-backed round.

4. Findings

Ordered by severity. None are blocking; all are either scope/completeness observations or pre-existing conditions proven identical on both arms.

F1 — Suggestion (scope): the PR bundles an undeclared, unrelated change.
packages/cli/src/commands/review/lib/narrow-diff.integration.test.ts gains maxRetries: 5, retryDelay: 100 on the afterAll rmSync (commit test(cli): retry transient review repo cleanup). It is not mentioned anywhere in the PR description, touches a different package and subsystem, and addresses Windows teardown flakiness. It is benign — 32/32 green here (H6-G8) and inert on Linux — but the retry path it targets (EPERM/EBUSY) is Windows-only and no runner in this round or in CI exercised it, the same gap the bot's round-3 comment disclosed. Recommend splitting it into its own PR or declaring it in the description; as it stands a reviewer of "recognize sed quiet aliases" is silently also reviewing review-command teardown policy.
Repro: git diff 34246c66..aaba62f0 -- packages/cli/src/commands/review/lib/narrow-diff.integration.test.ts

F2 — Observation (pre-existing, both arms identical): the two SAFE_SED_OPTION copies have drifted.
shell-safety-rules.ts uses /^(?:-[nElrsuz]|--(?:quiet|silent|line-length(?:=.*)?))$/; shellReadOnlyChecker.ts:92 uses /^(?:-[nErsuz]|--(?:quiet|silent))$/ (no l, no --line-length). Measured consequence: sed --line-length=80 'l' f and sed -l 80 'l' f skip the confirmation dialog (AST says read-only) while the scheduler refuses to run them concurrently (legacy says not) — two inconsistent decisions about one command. Identical on control and head (H4-B7), so not introduced here, but it is the same "two copies of one predicate" hazard this PR's fix had to thread through both. Worth a follow-up that unifies the copies.
Repro: node h3-product-paths.mjs <arm> rows sed --line-length=80 'l' file.txt / sed -l 80 'l' file.txt (assertion X9).

F3 — Observation (pre-existing): isCommandNeedsPermission() has no production caller.
Repo-wide grep finds only its own unit test (shell-utils.test.ts:1310,1315). The real dialog decision is ShellTool.getDefaultPermission() → evaluatePermissionFlow() L3 (packages/core/src/core/permissionFlow.ts:72). The function's name invites exactly the misreading I initially made while wiring this harness. Dead-code candidate; harmless today.
Repro: grep -rn isCommandNeedsPermission --include='*.ts' packages | grep -v test

F4 — Observation (pre-existing, over-conservative, safe direction): sibling shapes still prompt.
sed's y/// (transliterate) is absent from SAFE_SED_COMMAND, so sed -n 'y/ab/AB/' f prompts despite being read-only; likewise the long forms --separate, --unbuffered, --null-data, --regexp-extended prompt while their short forms (-s -u -z -E) are allowlisted. Same class of over-conservatism as the bug this PR fixes, one command down; unchanged by this PR and out of its stated scope. Recorded so the next alias PR has the list.
Repro: node h3-product-paths.mjs head rows tagged preExisting / the --separate etc. rows.

F5 — Observation: GNU sed accepts long-option abbreviations the allowlist rejects.
Real sed 4.9 accepts --qui and --sil (getopt_long unambiguous abbreviation) and rejects --s as ambiguous; the anchored allowlist rejects all of them, so they prompt. Safe direction (over-conservative). Noted because any future "accept abbreviations" change must re-run the write-shape matrix of §2 — an abbreviation that expands to a writer would otherwise be admitted by the alias parity argument.
Repro: sed --qui 'p' file.txt; echo $? → 0; classifier verdict unknown.

F6 — Observation (test completeness, non-blocking): 2 of the PR's 8 new assertions do not discriminate this change.
Reverting the fix (source = base blob) kills 6 of the 8 new assertions — the 4 it.each cases in shellAstParser.test.ts and the 2 read-only expects in shellReadOnlyChecker.test.ts — each with a behavioural expected X to be Y message (H5-M3/M4). The 2 surviving ones are the toBe(false) expects for sed --quiet/--silent 'w output.txt' inside the legacy rejects sed with write command block: at base the verdict is unknown, which also maps to false, so they pass with or without the fix (H5-M6/M7, corroborated by the control arm's own measurement). Classification: redundant defence, not a coverage gap and not dead code — they still catch a future regression that made the alias writable. No action required; noted so the kill-count is not over-read as "all 8 prove the diff".
Repro: node h5-mutation-matrix.mjs (matrix in 05-mutation-matrix-5-killed-1-survivor.png).

5. Not covered

  • macOS / Windows. This host is Linux/aarch64. The F1 retry path (EPERM/EBUSY on rmSync) is Windows-specific and was exercised by no runner here or in CI.
  • Root integration-tests suite (no-sandbox CLI E2E) not run; the PR's own CI covers it and the targeted suites plus the real-TUI E2E above cover the changed surface.
  • Non-GNU sed (busybox, BSD/macOS sed) not tested — the PR explicitly scopes to GNU sed, and H2 pins the premise against GNU sed 4.9 only.
  • auto approval mode (the LLM classifier that can auto-approve) not exercised; only default was, which is the mode where this classification is the deciding input.
  • Per-commit attribution. The PR has 5 commits including two merges from main; I verified the aggregate base..head diff, not each commit individually.
  • Full-repo test/lint sweep not run; gates were targeted to the affected workspaces and proven live (planted type error → TS2322 exit 2; planted unused var → no-unused-vars exit 1).
  • F4/F5 gaps are measured as observations only; no fix for them was implemented or tested.

6. Methodology

One isolated git worktree at the PR head inside the repo's tmp/, installed with pnpm install --frozen-lockfile (≈6 min, prepare built every package); the control arm was produced by restoring the deleted 2-line guard so the source file's git hash-object equals the base commit's blob, then rebuilding only packages/core (≈64 s), and reverted the same way afterwards (worktree left clean at head). H1 drives both commit blobs directly through Node's native TS type-stripping for the pure-classifier census; H2 executes real sed 4.9 in per-case scratch directories with a sha256+size+name filesystem snapshot oracle and a positive control; H3 imports the built dist and composes the real product functions in the order shell.ts:2191-2219 and coreToolScheduler.ts:1432 use them, executing every command string for real in the same scratch directory; H4 adjudicates the arm pair; H5 diffs per-test vitest JSON between head and the reverted-fix mutant; H6 re-asserts every gate, the reproducibility check and the two TUI captures from their recorded artifacts; H7 sums the assertions. The TUI A/B ran the real built CLI in tmux at 190×46 with --approval-mode default against a scratch project outside the repo, frames captured with tmux capture-pane -e and rasterised by scripts/verify-capture.mjs. Raw logs, harness sources and JSON results are in this run's artifact directory (tmp/pr12221-verify-20260926-220346/); every number quoted above is reproduced by node h7-aggregate.mjs and the per-harness logs.


Evidence

Hosted on the orphan branch assets-pr12221 of wenshao/qwen-code under pr12221/r1/ (commit 13f9f2ce). Every blob sha was verified against the local git hash-object before its URL was referenced here.

01-e2e-head-auto-approved-no-dialog.png

HEAD (aaba62f0), real TUI, --approval-mode default: sed --quiet '1,3p' file.txt executes immediately (✓ Shell …) with no confirmation dialog. The status bar reads "Ask permissions", proving the mode is default, not yolo.

01-e2e-head-auto-approved-no-dialog

02-e2e-control-confirmation-dialog.png

CONTROL (source file = base blob 3402bcb1), same command, same scratch project, same mode: the 4-option confirmation dialog blocks execution. This is the user-visible bug the PR removes.

02-e2e-control-confirmation-dialog

03-census-6156-arglists-all-deltas-carry-alias.png

H1 census over 6,156 distinct sed argument lists: all 304 base→head verdict deltas carry a literal --quiet/--silent token (0 non-alias); alias↔-n substitution parity holds 752/752 at head vs 454/752 at base.

03-census-6156-arglists-all-deltas-carry-alias

04-ab-dialog-and-concurrency-flip.png

H4 A/B adjudication: dialog-free 4 → 16 and concurrency-safe 2 → 14 (+12/−0 each); the 12 writing commands still ask on both arms and every one of them really wrote to disk.

04-ab-dialog-and-concurrency-flip

05-mutation-matrix-5-killed-1-survivor.png

H5 vacuity check: reverting the fix (source = base blob) kills 5/5 of the load-bearing new tests with behavioural expected X to be Y messages; one legacy block survives as redundant defence, explained by measurement.

05-mutation-matrix-5-killed-1-survivor

Local maintainer verification round 1 · Linux/aarch64 · GNU sed 4.9 · Node v24.14.0 · harnesses and raw logs in tmp/pr12221-verify-20260926-220346/

@wenshao

wenshao commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao

wenshao commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator

Verification report — PR 12221, round 2 (macOS, real TUI, non-GNU sed)

Verdict: merge-ready — unchanged from round 1. This round deliberately does not repeat round 1 (Linux/aarch64, GNU sed 4.9). It closes the gaps that round 1 listed as not covered: macOS, non-GNU sed, auto approval mode, end-to-end concurrency, and freshness against today's main. Every production-path claim of the PR held on macOS. I found one pre-existing macOS gap (F1 below). This PR did not introduce it, it does not block this PR, and a one-line candidate fix is measured below.

Arms. The PR head is aaba62f0, unchanged since round 1. CONTROL = main @ a480bc78. PR ARM = local merge of the head into that main (ffa0794a), which is the tree that would actually land. The two arms differ by exactly the PR diff: I compared the changed lines of main..merge with merge-base..head and they are identical. After a full pnpm install + build + npm run bundle in each worktree:

  • The compiled packages/core/dist differs in exactly 2 files: the commit stamp and the 3 deleted guard lines in shell-safety-rules.js.
  • The guard regex ^--(?!line-length(?:=|$)) is present in 1 bundle chunk on CONTROL and in 0 on the PR ARM.
  • Each CLI resolves its own worktree's core (realpath checked).

main moved to c662f78b during the run. The 2 new commits touch only web-shell and managed-agent, and the merge is still clean.

What this round adds

Round-1 "not covered" item This round Result
macOS macOS 26 arm64, Node 24.18.1, real bundled CLI in tmux, scripted model on the repo's integration-tests/fake-openai-server.ts E1 to E6 below
Non-GNU sed Stock /usr/bin/sed (BSD) and Homebrew GNU sed 4.10, both executed for real E2, E5, F1
auto approval mode Counted the LLM-classifier requests the CLI sends E4
Concurrency, end to end FIFO rendezvous in the real TUI, with an -n positive control E3
macOS unit runs (CI skipped the macOS job) The PR's core test files + narrow-diff.integration.test.ts on both arms 782/782; 32/32 ×3 on both arms

Results

E1 — the user-visible fix, on macOS (default mode, GNU sed first on PATH). Same prompt, same scripted model, --approval-mode default (footer shows Ask permissions). CONTROL stops at Allow execution of: 'sed'?. The PR ARM runs sed --quiet '1,3p' notes.txt directly and shows alpha / beta / gamma. → 01-…png

E2 — stock macOS sed (BSD). BSD sed has no long options. On the PR ARM the command is auto-approved and then fails harmlessly: sed: illegal option -- -, exit 1, and notes.txt is unchanged (sha1 equals the seeded content, no new files). CONTROL shows the dialog; approving it would lead to the same failure. For macOS users without GNU sed, the change therefore only removes a prompt in front of a command that cannot succeed anyway. That is not a safety issue. → 02-…png

E3 — "lost concurrency-safe classification" demonstrated end to end. One model turn emits two parallel calls, sed --quiet p fifo-a and sed --quiet p fifo-b. A writer releases both FIFOs only if both readers are attached at the same moment, and after 4 s falls back to releasing a lone reader as SERIAL. The tool output therefore states what the scheduler did:

  • CONTROL: SERIAL / SERIAL. fifo-b's reader attached at 6691 ms, only after fifo-a was released at 6628 ms.
  • PR ARM: CONCURRENT / CONCURRENT. The readers attached 22 ms apart.
  • Positive control (sed -n): CONCURRENT on both arms, so the rig can see parallelism on main.

→ 03-…png

E4 — auto mode: the classifier fast path. A read-only verdict lets a call skip the LLM safety classifier (coreToolScheduler.ts L5, unless classifyAllShell). The scenario ran three sequential steps.

step CONTROL: sent to the classifier? PR ARM: sent to the classifier?
sed -n '1,2p' notes.txt no no
sed --quiet '3,4p' notes.txt yes no
sed --silent 'w copy.txt' notes.txt yes yes (still reviewed)
classifier requests 2 1

The PR saves one classifier round trip per alias call, and a writing alias still reaches the classifier. The scripted classifier always answered shouldBlock:false, so this measures whether the classifier is consulted, not how a real model would judge the call. → 04-…png

E5 — census on macOS against real binaries. 5,184 distinct sed command lines were classified by each arm's built core, using the same two predicates the product uses:

  • dialog-free: hasShellSubstitution → stripShellWrapper → isShellCommandReadOnlyASTInDirectory, as in shell.ts getDefaultPermission();
  • concurrency-safe: isShellCommandReadOnly, as in isToolCallConcurrencySafe().

All 2,057 lines that either arm auto-approves were then executed via bash -c under both BSD sed and GNU sed 4.10. Each run used a fresh directory containing a pre-existing out file, with a sha256 snapshot taken before and after.

CONTROL PR ARM delta
dialog-free 1,325 2,057 +732 / −0
concurrency-safe 610 1,130 +520 / −0
delta lines without a literal --quiet/--silent token 0
auto-approved lines that wrote, GNU sed 4.10 (both predicates) 0 0 0
auto-approved lines that wrote, BSD sed (dialog-free) 11 13 +2, see F1
auto-approved lines that wrote, BSD sed (concurrency-safe) 0 0 0

730 of the 732 newly dialog-free lines changed nothing under either sed. The other 2 are the F1 spellings. GNU sed 4.10 on macOS also reproduces the PR's premise: --quiet and --silent produce byte-identical output to -n. → 05-…png

Tests and build on macOS. shellAstParser.test.ts + shellReadOnlyChecker.test.ts: 782/782, which matches the PR description. narrow-diff.integration.test.ts: 32/32 in 3 runs on each arm. The retry path it adds (EPERM/EBUSY from rmSync) is not triggered on macOS, so this proves the test is inert here, not that the retry works. The merged tree's full workspace build (tsc --build for all 25 packages) had 0 TypeScript errors.

Findings

F1 — Pre-existing, macOS, not introduced by this PR; non-blocking. The classifier parses sed's -l the GNU way, but stock macOS sed is BSD, so a w script can pass as the -l value.
classifySedCommandSafety treats -l as taking a value (SED_VALUE_OPTIONS; shell-safety-rules.ts if (/^(?:-l|--line-length)$/.test(arg) && !args[++i])). In BSD sed, -l is a bare flag (line-buffered output), and the next word is the script.

For sed -l wimportant.txt 1p:

  • The classifier sees -l <value> plus the script 1p → read-only → allow, no dialog.
  • BSD sed sees the script wimportant.txt → it truncates important.txt while compiling the script, before it even finds that the input 1p is missing (exit 1).

This reproduced in the real TUI in default mode on both arms: important.txt went from 35 bytes to 0 with no prompt. GNU sed does not write in these shapes (0 in E5), so Linux users are unaffected. The legacy concurrency checker never accepted -l, so the concurrency-safe count is 0 on both arms.

The PR's relationship to it: head adds exactly 2 spellings to the exposed set (sed -l wout --quiet p and --silent). Their -n twin, sed -l wout -n p, is already auto-approved and writing on main, so the PR adds spellings, not a new class.

Candidate fix (measured, not part of this PR): require a numeric value after -l/--line-length.

-    if (/^(?:-l|--line-length)$/.test(arg) && !args[++i]) return 'unknown';
+    if (/^(?:-l|--line-length)$/.test(arg) && !/^\d+$/.test(args[++i] ?? '')) return 'unknown';
  • Same census, head vs head + fix: BSD writes through auto-approval 13 → 0, GNU stays 0 → 0.
  • Cost: 247 lines return to prompting, and every one of them is -l <non-numeric>. The 792 numeric--l lines and 3,978 lines without -l have 0 verdict changes. Concurrency verdicts: 1,130 → 1,130.
  • shellAstParser + shellReadOnlyChecker + shell-utils + tools/shell tests: 1317/1317 with the fix applied.

This was measured at the classifier and unit level only, not end to end. I recommend a separate follow-up issue/PR rather than widening this one. → 06-…png
Repro on stock macOS: printf 'x\n' > f; sed -l wf 1p; wc -c f → 0 f.

F2 — Observation (round-1 F1, still open): the undeclared narrow-diff.integration.test.ts retry hunk. It is green on macOS (above), but its Windows-only target path is still exercised only by the author's own Windows run. The triage bot's approval makes the same point. It is a hygiene issue, not a defect.

Merge mechanics. The template-gate CHANGES_REQUESTED from 09-18 has been superseded by the bot's APPROVED (09-26 17:06 UTC). reviewDecision is now REVIEW_REQUIRED, so a maintainer approval is what remains. On the head, CI shows every executed check passing. Test (macos-latest), Test (windows-latest) and Integration Tests (CLI, No Sandbox) were skipped by CI; this round covers the first of those locally.

Not covered

  • Windows: not run here. Round 1 did not run it either; only the author's report covers it.
  • Other sed implementations (busybox, toybox): not tested. Only BSD /usr/bin/sed and GNU 4.10 were executed.
  • A real classifier model in auto mode: the scripted classifier always allowed. E4 measures whether the classifier is consulted, not its judgement.
  • The F1 candidate fix end to end: measured at the classifier and unit level only; no TUI run with a rebuilt bundle.
  • Root integration-tests suite: not run (CI no-AK passed on the head).
  • Per-commit attribution: I verified the aggregate diff merged onto main, not each of the 5 commits.

Methodology

Two fresh git worktrees in a scratch directory, pnpm install --frozen-lockfile (whose prepare builds every package), then npm run bundle. The TUI runs used env -i, a dedicated tmux socket, an isolated QWEN_HOME with its own settings.json (selectedType: openai), and OPENAI_BASE_URL pointed at a scripted model built on the repo's integration-tests/fake-openai-server.ts. That model:

  • tells main-session turns apart from side queries by the declared tool surface;
  • detects classifier requests by the forced respond_in_schema tool plus the classifier system prompt;
  • logs the command each classifier call reviewed.

sed flavour was chosen per run with a PATH-front symlink (gsed or /usr/bin/sed). Frames were captured with tmux capture-pane -e and rasterised with scripts/verify-capture.mjs. The census imports each arm's packages/core/dist directly. The candidate fix was measured on a copy of the PR ARM's compiled core, and its unit run patched the PR ARM's source temporarily; that file was restored afterwards and blob-checked (1ff179c8).


Evidence

Hosted on wenshao/qwen-code branch assets-pr12221 under pr12221/r2/. The URLs are anchored to commit 8e2eef35, a fast-forward on top of round 1's 13f9f2ce. Every URL was fetched back, returned 200, and its bytes match the local file.

01 — E1: default mode, GNU sed: dialog on CONTROL vs. auto-run on PR ARM

01

02 — E2: stock macOS BSD sed: PR ARM auto-runs; BSD rejects the long option; nothing written

02

03 — E3: FIFO rendezvous: SERIAL on CONTROL, CONCURRENT on PR ARM, -n control concurrent on both

03

04 — E4: auto mode: classifier requests 2 → 1; the writing alias is still reviewed

04

05 — E5: 5,184-line census, executed under BSD and GNU sed

05

06 — F1: pre-existing BSD -l truncation (identical on both arms) and the measured candidate fix

06

中文版本

验证报告 —— PR 12221 第 2 轮(macOS、真实 TUI、非 GNU sed)

结论:merge-ready(可合并),与 第 1 轮 一致。 本轮有意不重复第 1 轮(Linux/aarch64、GNU sed 4.9),只补第 1 轮列为「未覆盖」的缺口:macOS、非 GNU sed、auto 审批模式、端到端并发,以及与今天 main 的新鲜度。PR 在产品路径上的全部主张在 macOS 上都成立。另外发现一个既有的 macOS 缺口(下文 F1):不是本 PR 引入的,不阻塞本 PR;下文附有已实测的一行候选修复。

对照臂。 PR head 为 aaba62f0,自第 1 轮以来未变。CONTROL = main @ a480bc78;PR ARM = 把 head 本地合并进这个 main 得到的 ffa0794a,也就是真正会落地的那棵树。两臂之间恰好只差 PR 的 diff:main..merge 与 merge-base..head 的改动行逐行一致。两个 worktree 各自完整执行 pnpm install + 构建 + npm run bundle 后:

  • 编译出的 packages/core/dist 只有 2 个文件不同:commit 戳,以及 shell-safety-rules.js 里被删掉的 3 行守卫。
  • 守卫正则 ^--(?!line-length(?:=|$)) 在 CONTROL 的 bundle 里出现在 1 个 chunk 中,在 PR ARM 里为 0。
  • 两个 CLI 各自解析到本 worktree 的 core(已核对 realpath)。

验证过程中 main 前进到 c662f78b:新增的 2 个提交只涉及 web-shell 和 managed-agent,合并仍然干净。

本轮补了什么

第 1 轮「未覆盖」项 本轮做法 结果
macOS macOS 26 arm64、Node 24.18.1,真实打包 CLI 跑在 tmux 里,脚本化模型基于仓库自带的 integration-tests/fake-openai-server.ts 见 E1–E6
非 GNU sed 系统自带 /usr/bin/sed(BSD)与 Homebrew GNU sed 4.10,均真实执行 E2、E5、F1
auto 审批模式 统计 CLI 发出的 LLM 分类器请求数 E4
端到端并发 真实 TUI 中做 FIFO 会合,并带 -n 阳性对照 E3
macOS 单测(CI 跳过了 macOS 作业) PR 的 core 测试文件 + narrow-diff.integration.test.ts,两臂都跑 782/782;两臂各 32/32 ×3

结果

E1 —— macOS 上用户可见的修复(默认模式,PATH 上优先是 GNU sed)。同一 prompt、同一脚本化模型、--approval-mode default(底栏显示 Ask permissions)。CONTROL 停在 Allow execution of: 'sed'?;PR ARM 直接执行 sed --quiet '1,3p' notes.txt,并输出 alpha / beta / gamma。→ 01-…png

E2 —— macOS 自带 sed(BSD)。 BSD sed 没有长选项。在 PR ARM 上这条命令被自动放行,然后无害地失败:sed: illegal option -- -,exit 1,notes.txt 未变(sha1 等于初始内容,也没有新文件)。CONTROL 会弹确认框,批准后也是同样的失败。所以对没装 GNU sed 的 macOS 用户,这个改动只是去掉了一个反正会失败的命令前面的确认框,不构成安全问题。→ 02-…png

E3 —— 端到端证明「丢失并发安全分类」。 同一轮模型输出两个并行调用:sed --quiet p fifo-a 和 sed --quiet p fifo-b。写端只有在两个读端同时挂上时才同时放行;若 4 s 内只有一个读端,就按 SERIAL 单独放行它。这样工具输出本身就说明了调度器做了什么:

  • CONTROL:SERIAL / SERIAL。fifo-b 的读端在 6691 ms 才挂上,晚于 fifo-a 在 6628 ms 被放行。
  • PR ARM:CONCURRENT / CONCURRENT。两个读端挂上的时间相差 22 ms。
  • 阳性对照(sed -n):两臂都是 CONCURRENT,说明这套装置在 main 上能看到并行。

→ 03-…png

E4 —— auto 模式下分类器的快速通道。 只读判定可以让调用跳过 LLM 安全分类器(coreToolScheduler.ts 的 L5,除非开启 classifyAllShell)。场景按顺序执行三步:

步骤 CONTROL:是否送分类器 PR ARM:是否送分类器
sed -n '1,2p' notes.txt 否 否
sed --quiet '3,4p' notes.txt 是 否
sed --silent 'w copy.txt' notes.txt 是 是(仍受审查)
分类器请求数 2 1

每次别名调用省掉一次分类器往返,而带写操作的别名仍会送分类器。脚本化分类器始终回答 shouldBlock:false,所以这里测的是分类器是否被调用,而不是真实模型会如何判断。→ 04-…png

E5 —— macOS 上对真实二进制的普查。 5,184 条不同的 sed 命令行,由两臂各自构建产物里的 core 分类,所用的两个判定与产品一致:

  • 免确认:hasShellSubstitution → stripShellWrapper → isShellCommandReadOnlyASTInDirectory,同 shell.ts 的 getDefaultPermission();
  • 并发安全:isShellCommandReadOnly,同 isToolCallConcurrencySafe()。

凡是任一臂自动放行的 2,057 条,都经 bash -c 在 BSD sed 和 GNU sed 4.10 下各真实执行一次。每次执行都在全新目录里进行,目录中预置了 out 文件,执行前后各做一次 sha256 快照。

CONTROL PR ARM 差值
免确认 1,325 2,057 +732 / −0
并发安全 610 1,130 +520 / −0
不含 --quiet/--silent 字面量的差异行 0
自动放行且实际写盘,GNU sed 4.10(两个判定) 0 0 0
自动放行且实际写盘,BSD sed(免确认) 11 13 +2,见 F1
自动放行且实际写盘,BSD sed(并发安全) 0 0 0

新增免确认的 732 条中,730 条在两种 sed 下都没有改动任何文件;另外 2 条就是 F1 的写法。macOS 上的 GNU sed 4.10 也复现了 PR 的前提:--quiet、--silent 的输出与 -n 逐字节相同。→ 05-…png

macOS 上的测试与构建。 shellAstParser.test.ts + shellReadOnlyChecker.test.ts:782/782,与 PR 描述一致。narrow-diff.integration.test.ts:两臂各 32/32,各跑 3 次。它新增的重试路径(rmSync 的 EPERM/EBUSY)在 macOS 上不会触发,因此这只证明该测试在这里无副作用,不证明重试本身有效。合并后的树完整构建(25 个包的 tsc --build),TypeScript 错误为 0。

发现

F1 —— 既有问题,macOS,不是本 PR 引入的,不阻塞本 PR:分类器按 GNU 的方式解析 sed 的 -l,但 macOS 自带的 sed 是 BSD 版,所以 w 脚本可以冒充 -l 的值混过去。
classifySedCommandSafety 认为 -l 带一个值(SED_VALUE_OPTIONS;shell-safety-rules.ts 中的 if (/^(?:-l|--line-length)$/.test(arg) && !args[++i]))。而 BSD sed 的 -l 是不带值的开关(行缓冲输出),紧随其后的词是脚本。

以 sed -l wimportant.txt 1p 为例:

  • 分类器看到的是 -l <值> 加脚本 1p → 只读 → allow,不弹框。
  • BSD sed 看到的脚本是 wimportant.txt → 在编译脚本时就截断了 important.txt,之后才发现输入文件 1p 不存在(exit 1)。

在真实 TUI 默认模式下,两臂都复现:important.txt 从 35 字节变成 0,全程没有提示。GNU sed 在这些写法下不会写盘(E5 中为 0),所以 Linux 用户不受影响。旧版并发检查器从未接受过 -l,因此两臂的并发安全计数都是 0。

与本 PR 的关系:head 恰好给暴露集合新增了 2 种写法(sed -l wout --quiet p 和 --silent)。它们的 -n 孪生写法 sed -l wout -n p 在 main 上已经会被自动放行并写盘,所以本 PR 只是增加了写法,没有引入新的问题类别。

候选修复(已实测,不属于本 PR):要求 -l/--line-length 后面跟数字。

-    if (/^(?:-l|--line-length)$/.test(arg) && !args[++i]) return 'unknown';
+    if (/^(?:-l|--line-length)$/.test(arg) && !/^\d+$/.test(args[++i] ?? '')) return 'unknown';
  • 同一普查,head 对 head + 修复:BSD 经自动放行写盘的条数 13 → 0,GNU 保持 0 → 0。
  • 代价:247 条回到需要确认,且全部是 -l <非数字>。792 条带数字 -l 的行、3,978 条不含 -l 的行,判定变化都是 0。并发判定 1,130 → 1,130。
  • 打上修复后跑 shellAstParser + shellReadOnlyChecker + shell-utils + tools/shell 测试:1317/1317。

该修复只在分类器层和单测层实测过,没有做端到端验证。建议另开 issue/PR 跟进,而不是扩大本 PR 的范围。→ 06-…png
在自带 sed 的 macOS 上复现:printf 'x\n' > f; sed -l wf 1p; wc -c f → 0 f。

F2 —— 观察(第 1 轮 F1,仍未关闭):narrow-diff.integration.test.ts 的重试改动没有在描述中声明。 它在 macOS 上是绿的(见上),但它针对的 Windows 专属路径,仍只有作者本人的 Windows 运行覆盖过。triage bot 的批准意见也提到同一点。这是规范问题,不是缺陷。

合并流程。 09-18 因模板门禁给出的 CHANGES_REQUESTED 已被 bot 的 APPROVED(09-26 17:06 UTC)取代。reviewDecision 现在是 REVIEW_REQUIRED,还需要一位维护者批准。head 上 CI 实际执行的检查全部通过;Test (macos-latest)、Test (windows-latest) 和 Integration Tests (CLI, No Sandbox) 被 CI 跳过,其中第一项已由本轮在本地补上。

未覆盖

  • Windows:本轮没有跑,第 1 轮也没有;只有作者的报告覆盖。
  • 其它 sed 实现(busybox、toybox):未测。只真实执行了 BSD /usr/bin/sed 和 GNU 4.10。
  • auto 模式下的真实分类器模型:脚本化分类器始终放行。E4 测的是分类器是否被调用,不是它的判断。
  • F1 候选修复的端到端验证:只在分类器层和单测层实测,没有用重建的 bundle 跑 TUI。
  • 根目录 integration-tests 套件:未跑(head 上 CI 的 no-AK 已通过)。
  • 逐 commit 归因:验证的是合并到 main 之后的整体 diff,不是 5 个提交逐个验证。

方法

在临时目录建两个全新的 git worktree,执行 pnpm install --frozen-lockfile(其 prepare 会构建所有包),再执行 npm run bundle。TUI 运行使用 env -i、独立的 tmux socket、隔离的 QWEN_HOME(自带 settings.json,selectedType: openai),并把 OPENAI_BASE_URL 指向一个基于仓库 integration-tests/fake-openai-server.ts 的脚本化模型。这个模型:

  • 按声明的工具面区分主会话轮次与 side query;
  • 通过强制使用的 respond_in_schema 工具加分类器系统提示词识别分类器请求;
  • 记录每次分类器调用审查的是哪条命令。

每次运行的 sed 版本由 PATH 最前面的符号链接决定(gsed 或 /usr/bin/sed)。画面用 tmux capture-pane -e 抓取,再用 scripts/verify-capture.mjs 渲染成图。普查直接 import 两臂各自的 packages/core/dist。候选修复是在 PR ARM 编译产物的副本上测的;它的单测运行临时修改了 PR ARM 的源码,结束后已恢复该文件并核对 blob(1ff179c8)。

证据图

托管在 wenshao/qwen-code 的 assets-pr12221 分支 pr12221/r2/ 目录下。URL 锚定到提交 8e2eef35(在第 1 轮 13f9f2ce 之上快进推送)。每个 URL 都已回读验证:返回 200,且字节与本地文件一致。图见上方英文部分:01 默认模式弹框 vs 自动执行;02 BSD sed 无害失败;03 FIFO 会合并发证明;04 auto 模式分类器请求 2 → 1;05 5,184 条普查;06 既有的 BSD -l 截断与候选修复。

Local maintainer verification round 2 · macOS 26 arm64 · BSD sed + GNU sed 4.10 · Node v24.18.1 · CONTROL main@a480bc78 vs PR ARM main+aaba62f0 = ffa0794a

@wenshao
wenshao added this pull request to the merge queue Sep 26, 2026
Merged via the queue into QwenLM:main with commit f5ad9cb Sep 26, 2026
74 checks passed
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.

Fix sed --quiet/--silent classified as 'unknown' (SAFE_SED_OPTION whitelist unreachable)

3 participants