Skip to content

fix(web-shell): sanitise model-supplied text at the approval card's sibling render sites - #13578

Merged
wenshao merged 2 commits into
mainfrom
fix/issue-13566-approval-sibling-sanitise
Oct 8, 2026
Merged

wenshao merged 2 commits into
mainfrom
fix/issue-13566-approval-sibling-sanitise

Conversation

@yiliang114

@yiliang114 yiliang114 commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

The web-shell approval card escaped the command it is about to run, but two siblings in the same dialog still rendered model-supplied text verbatim: the description line above the command block (rawInput.description, or a title built from the tool's own getDescription() — for shell tools that is the verbatim command), and the exec-warnings block next to the command (whose text interpolates the model's raw directory argument via buildOutsideWorkspaceWarning). Both now pass through the same sanitizeControlChars helper the command block already uses, in their body text and in their title tooltip. The render gates stay on the raw values, so the aria-describedby IDREF cannot dangle when a description is absent.

The command block's comment also claimed more than the code delivers — that every producer leaves command verbatim so the block is "the last choke point", and that a crafted command "cannot display one string while authorizing different bytes". Neither holds: CONTROL_CHARS_REGEX matches only C0/ANSI, C1 and the bidi embedding/isolate controls, and the block has unsanitised siblings. The comment now states only what the helper actually covers, and says plainly that zero-width and separator code points still pass through.

Why it's needed

Reported in #13566, which is the deferred residual of #13549. That PR sanitised the command block and escaped the managed approval args preview; its two review threads (R1-1 / R1-2) were closed by merge rather than by a fix, and #13566 is the only thing tracking them. This is not a regression — before #13549 the command block rendered command with no sanitizer at all — but the sibling sites mean a crafted description or directory can still visibly reorder text one element away from the now-escaped command, in its tooltip, and in the aria-describedby target a screen reader reads.

Reviewer Test Plan

How to verify

Two jsdom component tests were added to ToolApproval.test.tsx and were confirmed to fail against unfixed main before any source change (this is the reproduction):

× ToolApproval accessibility > neutralises bidi and C0 controls in the model-supplied description text
  → expected 'Delete temporary data\u202e\nls \u001…' not to contain '\u202e'
× ToolApproval accessibility > neutralises bidi and C0 controls in the exec warnings block
  → expected 'Runs outside the workspace in /tmp/\u…' not to contain '\u202e'

Commands and results (base main = 1753948e3e, fix commit = 5d7d8d3244):

Command Exit Result
vitest run client/components/messages/ToolApproval.test.tsx --config vitest.config.ts --coverage.enabled=false (run in packages/web-shell, tests only, source unfixed) 1 2 failed / 1 passed / 80 skipped (83) — the two new tests
same command, full file, source unfixed 1 2 failed / 81 passed (83)
same command, full file, after the fix 0 83 passed (83)
tsc -p tsconfig.json --noEmit (run in packages/web-shell) 2 57 pre-existing errors, 0 in client/components/messages/ — see Risk & Scope
eslint on the two changed files 0 clean
prettier --check / --write on the two changed files 0 clean

The pre-existing bidi test for the command block (ToolApproval.test.tsx:598-631, added by #13549) stays green unchanged, and so do the multi-line .join('\n') expectations at :348, :352, :607, :627. The new tests also assert that \n and \t in the description survive unescaped (the helper's own pin for that is toolFormatting.test.ts:51, cited rather than duplicated), and that every id in aria-describedby resolves to a rendered element.

Evidence (Before & After)

Non-UI in the sense that nothing was rendered in a real browser; the before/after is the failing-then-passing test output quoted above and captured in the commit's local run log.

Before (unfixed main, tests added):

 ❯ components/messages/ToolApproval.test.tsx (83 tests | 2 failed | 80 skipped)
   ✓ ToolApproval accessibility > neutralises bidi and C0 controls in the rendered command block
   × ToolApproval accessibility > neutralises bidi and C0 controls in the model-supplied description text
   × ToolApproval accessibility > neutralises bidi and C0 controls in the exec warnings block
      Tests  2 failed | 1 passed | 80 skipped (83)

After (this branch):

 ✓ components/messages/ToolApproval.test.tsx (83 tests) 836ms
      Test Files  1 passed (1)
      Tests  83 passed (83)

Tested on

OS Status
🍏 macOS ⚠️ not tested
🪟 Windows ⚠️ not tested
🐧 Linux ✅ tested (jsdom component tests only)

Environment (optional)

Local pnpm workspace, vitest jsdom environment. No browser, no daemon, no live Web Shell session was started.

Risk & Scope

  • Main risk or tradeoff: the two sites now show \u202e-style escapes instead of the raw code point, which is the same visible tradeoff fix(web-shell): escape control characters in the managed approval args preview #13549 already accepted for the command block. Behaviour is unchanged for ordinary text — sanitizeControlChars is a no-op outside its character class, and \n/\t are deliberately preserved so multi-line descriptions and warnings still render as before. One caveat that claim did not carry, now recorded: \r is inside CONTROL_CHARS_REGEX (\x0b-\x1f, toolFormatting.ts:117-118) and :128-129 maps it to the two printable characters \ and r, so CRLF-terminated text at these two newly sanitised sites paints a visible \r at each line end instead of a clean break. Bare-LF text is unaffected. The warnings site is an unlikely CRLF source because buildOutsideWorkspaceWarning interpolates a filesystem path; the realistic trigger is a model-written multi-line description with Windows line endings. Normalising CRLF to LF before escaping would make the sentence above literally true, but it changes behaviour at every call site of a shared helper, so it is recorded here rather than folded into this PR.
  • Not validated / out of scope:
    • No live browser render was verified. The evidence is a jsdom component test asserting on textContent and the title attribute; how a real browser paints the escaped text was not observed.
    • Site C — the showsContent <pre> that renders contentText — is deliberately deferred, not dropped. Draft PR feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445 (head cb53b83edb, unchanged since 2026-10-05) rewrites exactly those lines into an isExitPlanApproval ? <div><Markdown content={contentText}/></div> : <pre> branch, so editing them here would put two writers on identical lines. Conditional follow-up: if feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445 merges first, sanitizeControlChars must also wrap its new <Markdown content={contentText}/> branch, or the third site stays raw.
    • The goal-approval branch is a fourth unsanitised sibling site, and this PR does not fix it. When isGoal, ToolApproval.tsx:655-660 passes raw goalObjective and contentText to GoalApprovalContent, which imports only useState, useI18n, Tabs and styles and renders the goal notice (:78), <p>{section.text}</p> (:83), <p>{objective || fullText}</p> (:87) and <p>{fullText}</p> (:91) verbatim - that file contains no sanitiser at all. Because aria-describedby includes commandId whenever isGoal (:632), and commandId is the .goalBody div (GoalApprovalContent.tsx:76), reordered text is also what a screen reader speaks. A payload has room: PROPOSE_GOAL_OBJECTIVE_MAX_CHARACTERS is 1500 (packages/core/src/goals/goal-tools.ts:485), whose own schema text says the user reads all of it in the approval dialog. Not fixed here because GoalApprovalContent.tsx is byte-identical to the merge base and the goal branch's JSX is in no hunk of this diff; feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445 does not touch that file either. Recorded as a fourth site on web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566, which previously enumerated only .desc, the exec-warnings <pre> and showsContent.
    • Item 3 of web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566 is untouched. CONTROL_CHARS_REGEX and the charCodeAt(0) replacement in toolFormatting.ts are unchanged, because widening the shared helper is a maintainer policy call (escaping \p{Cf} would visibly alter legitimate ZWJ/ZWNJ/U+00AD text at all seven production call sites) and two open PRs already edit that file. Consequently these vectors are still not neutralised anywhere, at any of the three sites: astral TAG characters (U+E0020-U+E007F), U+200B, U+2060, U+FEFF, U+00AD, U+061C, U+200E-U+200F, and U+2028/U+2029. They are not asserted in the new tests, so this PR neither fixes nor hides them.
    • The package typecheck exits 2 with 57 errors. All of them are in files this PR does not touch and all but a handful come from a stale hardlinked packages/sdk-typescript/dist in the local worktree (missing @qwen-code/sdk/daemon exports); zero are in client/components/messages/. A clean-install typecheck was not run locally — CI is the authority here.
  • Breaking changes / migration notes: none.

Linked Issues

Refs #13566 — deliberately not Fixes: the issue stays open because site C and item 3 remain.

中文说明

这个 PR 做了什么

Web Shell 审批卡片已经对即将执行的命令做了转义,但同一个弹窗里的另外两处仍在原样渲染模型提供的文本:命令块上方的描述行(rawInput.description,或由工具自身 getDescription() 构造的 title —— 对 shell 工具而言就是原样命令),以及命令旁边的 exec 警告块(其文本通过 buildOutsideWorkspaceWarning 插值了模型原始的 directory 参数)。这两处现在都走命令块已在用的同一个 sanitizeControlChars,正文与 title 悬浮提示都覆盖。渲染门控保持在原始值上,因此描述缺失时 aria-describedby 的 IDREF 不会悬空。

命令块上方的注释也声称超出了代码实际覆盖的范围 —— 说所有 producer 都原样传出 command 因而这里是「最后一道收口点」,并说精心构造的命令「不可能显示一串、放行另一串」。两点都不成立:CONTROL_CHARS_REGEX 只匹配 C0/ANSI、C1 与 bidi 嵌入/隔离控制符,而这个块还有未净化的同级展示点。注释现在只陈述该 helper 实际覆盖的范围,并明确写出零宽与分隔符码点仍会原样通过。

为什么需要

来自 #13566,它是 #13549 延期出来的遗留项。那个 PR 净化了命令块、转义了托管审批的参数预览;它的两条 review 线程(R1-1 / R1-2)是被合并关掉的而不是被修掉的,目前只有 #13566 在跟踪。这不是回归 —— #13549 之前命令块渲染的是完全未处理的 command —— 但这些同级展示点意味着精心构造的 description 或 directory 仍能在已转义命令旁边一个元素处、在其 tooltip 中、以及在屏幕阅读器要读的 aria-describedby 目标里,可见地重排文本。

评审测试计划

如何验证

在 ToolApproval.test.tsx 中新增了两个 jsdom 组件测试,并在改动任何源码之前先确认它们在未修复的 main 上失败(这就是复现):

× ToolApproval accessibility > neutralises bidi and C0 controls in the model-supplied description text
  → expected 'Delete temporary data\u202e\nls \u001…' not to contain '\u202e'
× ToolApproval accessibility > neutralises bidi and C0 controls in the exec warnings block
  → expected 'Runs outside the workspace in /tmp/\u…' not to contain '\u202e'

命令与结果(基线 main = 1753948e3e,修复提交 = 5d7d8d3244):

命令 退出码 结果
在 packages/web-shell 下 vitest run client/components/messages/ToolApproval.test.tsx --config vitest.config.ts --coverage.enabled=false(只加测试、源码未修) 1 2 failed / 1 passed / 80 skipped (83)
同上,跑全文件、源码未修 1 2 failed / 81 passed (83)
同上,跑全文件、修复之后 0 83 passed (83)
在 packages/web-shell 下 tsc -p tsconfig.json --noEmit 2 57 个既有错误,client/components/messages/ 下 0 个 —— 见「风险与范围」
对两个改动文件跑 eslint 0 干净
对两个改动文件跑 prettier --check / --write 0 干净

#13549 加的既有 bidi 测试(ToolApproval.test.tsx:598-631)保持绿色未改动,:348、:352、:607、:627 的多行 .join('\n') 期望同样保持绿色。新测试还断言描述中的 \n 与 \t 不被转义(helper 自己的钉住项在 toolFormatting.test.ts:51,这里引用而不重复),以及 aria-describedby 里每个 id 都能解析到真实渲染的元素。

前后证据

严格说不是 UI 变更 —— 没有在真实浏览器里渲染过;前后对比就是上面引用的「先失败、后通过」的测试输出。

修复前(未修复的 main,只加了测试):

 ❯ components/messages/ToolApproval.test.tsx (83 tests | 2 failed | 80 skipped)
   ✓ ToolApproval accessibility > neutralises bidi and C0 controls in the rendered command block
   × ToolApproval accessibility > neutralises bidi and C0 controls in the model-supplied description text
   × ToolApproval accessibility > neutralises bidi and C0 controls in the exec warnings block
      Tests  2 failed | 1 passed | 80 skipped (83)

修复后(本分支):

 ✓ components/messages/ToolApproval.test.tsx (83 tests) 836ms
      Test Files  1 passed (1)
      Tests  83 passed (83)

测试平台

OS Status
🍏 macOS ⚠️ 未测试
🪟 Windows ⚠️ 未测试
🐧 Linux ✅ 已测试(仅 jsdom 组件测试)

环境(可选)

本地 pnpm workspace,vitest 的 jsdom 环境。没有启动浏览器、daemon 或真实的 Web Shell 会话。

风险与范围

  • 主要风险或取舍:这两处现在显示 \u202e 这样的转义而不是原始码点,这与 fix(web-shell): escape control characters in the managed approval args preview #13549 为命令块已经接受的取舍完全一致。普通文本的行为不变 —— sanitizeControlChars 在其字符类之外是空操作,且刻意保留 \n/\t,所以多行描述与警告的渲染与之前相同。这句话原先漏掉一个取舍,现补记:\r 是在 CONTROL_CHARS_REGEX 之内的(\x0b-\x1f,toolFormatting.ts:117-118),而 :128-129 把它映射成 \ 与 r 两个可打印字符,因此以 CRLF 结尾的文本在这两处新净化的展示点上,每个行尾都会画出一个可见的 \r,而不是干净的换行。纯 LF 文本不受影响。警告那一处不太可能携带 CRLF,因为 buildOutsideWorkspaceWarning 插值的是文件系统路径;现实的触发路径是模型写出的、使用 Windows 换行的多行 description。在转义前把 CRLF 归一为 LF 可以让上面那句话字面成立,但那会改变共享 helper 全部调用点的行为,所以这里只作记录,不并入本 PR。
  • 未验证 / 不在范围内:
    • 没有验证真实浏览器渲染。 证据是断言 textContent 与 title 属性的 jsdom 组件测试;真实浏览器如何绘制转义后的文本未被观测。
    • 第三处 —— 渲染 contentText 的 showsContent <pre> —— 是刻意延期,不是放弃。 草稿 PR feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445(head cb53b83edb,自 2026-10-05 起未变)恰好把这几行重写成 isExitPlanApproval ? <div><Markdown content={contentText}/></div> : <pre> 分支,因此在这里改这些行会让两个写者落在完全相同的行上。条件性后续动作:如果 feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445 先合并,sanitizeControlChars 也必须包住它新增的 <Markdown content={contentText}/> 分支,否则第三处仍是原样渲染。
    • Goal 审批分支是第四处未净化的同级展示点,本 PR 不修它。 当 isGoal 时,ToolApproval.tsx:655-660 把原样的 goalObjective 与 contentText 传给 GoalApprovalContent;该文件只 import 了 useState、useI18n、Tabs 与 styles,并原样渲染 goal 提示(:78)、<p>{section.text}</p>(:83)、<p>{objective || fullText}</p>(:87)与 <p>{fullText}</p>(:91)—— 整个文件没有任何净化调用。由于 isGoal 时 aria-describedby 一定包含 commandId(:632),而 commandId 就是 .goalBody 这个 div(GoalApprovalContent.tsx:76),被重排后的文本也正是屏幕阅读器要读出的内容。载荷有足够空间:PROPOSE_GOAL_OBJECTIVE_MAX_CHARACTERS 为 1500(packages/core/src/goals/goal-tools.ts:485),其自身的 schema 文案写明用户会在审批弹窗里读完全部内容。不在这里修,是因为 GoalApprovalContent.tsx 与合并基线逐字节相同、goal 分支的 JSX 不在本 diff 的任何 hunk 内;feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445 也不触及该文件。已作为第四处记录到 web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566 —— 该 issue 此前只列了 .desc、exec 警告 <pre> 与 showsContent 三处。
    • web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566 的第 3 项完全没动。 toolFormatting.ts 里的 CONTROL_CHARS_REGEX 与基于 charCodeAt(0) 的替换保持原样,因为放宽共享 helper 是 maintainer 的策略决定(转义 \p{Cf} 会让全部 7 个生产调用点上合法的 ZWJ/ZWNJ/U+00AD 文本可见地改变),而且已有两个开着的 PR 在改那个文件。因此以下向量在三处都仍未被中和:星平面 TAG 字符(U+E0020-U+E007F)、U+200B、U+2060、U+FEFF、U+00AD、U+061C、U+200E-U+200F、U+2028/U+2029。新测试没有对它们做断言,所以本 PR 既不修掉它们、也不掩盖它们。
    • 包级 typecheck 退出码为 2、共 57 个错误。全部位于本 PR 未触及的文件中,且绝大多数来自本地 worktree 里硬链接复用的陈旧 packages/sdk-typescript/dist(缺少 @qwen-code/sdk/daemon 的导出);client/components/messages/ 下为 0 个。本地没有跑干净安装后的 typecheck —— 这一项以 CI 为准。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Refs #13566 —— 刻意不用 Fixes:第三处与第 3 项仍未完成,issue 需要保持开启。

…ibling render sites

The command block already escapes `command`, but two siblings in the same
dialog rendered model-supplied text raw: `.desc` (`rawInput.description`, or a
title built from the tool's own `getDescription()`, which for shell tools is
the verbatim command) and the exec-warnings `<pre>` (whose text interpolates
the model's raw `directory` argument). Both body and tooltip now go through
`sanitizeControlChars`. The render gates stay on the raw values so the
`aria-describedby` IDREF cannot dangle.

Also narrows the command block's comment to what `sanitizeControlChars`
actually covers (C0/ANSI, C1, and the bidi embedding/isolate controls): it is
neither the last choke point before an approver reads the command nor complete
coverage of the invisible-code-point set.

Refs #13566

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmuxn8oeq2o
@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Qwen Triage finished — view run. See the stage comments in this thread for the result.

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

@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Re-run at head aad10c09c1 — the previous pass reviewed 5d7d8d32, before the second commit landed. Every stage re-ran; this comment is updated in place rather than duplicated.

Thanks for the PR. The substance of my earlier read still stands, so this pass concentrates on what moved: the new commit, the two review threads you closed, and the verification lane I asked for.

Template looks good ✓ — every required section filled in, Tested-on table included, complete Chinese translation rather than a summary. The Risk & Scope section grew two bullets since the last pass (the \r caveat and the goal-approval site); both languages carry them, which is what keeps the section trustworthy instead of decorative.

Problem: observed, not theoretical, and now corroborated by more than your own run. #13566 is open and labelled type/bug / category/security / scope/web-shell / priority/P2; it exists because #13549 merged with two review threads closed by the merge rather than by a fix. Last pass I explicitly refused to treat "2 failed / 81 passed on unfixed main" as more than your claim. It is no longer only your claim: the /verify lane re-derived it independently at this head (see Stage 2). I still did not execute PR code myself — an unattended triage run does not — but the fail-then-pass reproduction is now witnessed by a second party rather than asserted by its author.

Direction: aligned, unchanged from the last pass. Sanitising untrusted text where it reaches a human decision surface is well-trodden upstream; claude-code's CHANGELOG carries entries of exactly this class, including one landing on the same remediation shape (terminal control characters "now show as \uXXXX escapes"), one refusing raw control characters "other than tab and newline" — the same carve-out sanitizeControlChars keeps — and a screen-reader fix where a control character dropped visible lines from speech, which is your aria-describedby angle. No entry names the Web Shell approval card specifically; the area is clearly relevant rather than peripheral.

Size: not applicable. Both source files sit under packages/web-shell/client/, so no core-infrastructure path is touched and the change stays inside one package. For the record: 34 production lines (23+/11−), 79 test lines, 8 docs lines (4+/4−), no generated or schema files. Well under every advisory threshold.

Approach: the scope still feels right, and the second commit is the one thing here that needed checking rather than assuming.

ci(docs): format the daemon event-schema table for the Prettier gate looks like unrelated drive-by churn on its face — a docs table in a diff about an approval card. It is not. At the previous head Lint & Static was red, and I proved at the time that the failure was pre-existing repo-wide formatting drift on docs/developers/daemon/09-event-schema.md landed by your own merge base, not a regression from this change; the repo-wide Prettier gate fails the PR for it regardless. This commit fixes exactly that file and nothing else, and Lint & Static is green now. One honest consequence worth recording: main has since received a byte-identical fix for the same table, so after merge this commit is a no-op — I confirmed that by fetching the file at your head and at main and diffing them (identical). That is noise in the merged history, not a defect, and a squash merge would erase it if anyone cares.

The design decisions I checked last pass still hold, and I re-verified them at this head rather than carrying them over: JSX gates and the aria-describedby list stay on the raw values while only the display strings are sanitised (the subtle part — reversed, the IDREF dangles); sanitizeControlChars over escapePreviewText, since the latter's class starts at U+007F and would let ESC/BEL through; and no widening of the shared helper, which is a policy call across seven call sites, not a range search.

Both review threads are now isResolved: true, and I checked the resolutions instead of taking them:

Site C's deferral also still holds at review time: #13445 remains open and draft, head cb53b83edb, untouched since 2026-10-05. So your conditional follow-up note is still live and still the right mitigation. Using Refs instead of Fixes remains what keeps the accounting honest — #13566 stays open to own site C, the goal site and item 3.

Risk: no elevated risk signals — I re-ran the revert-correlated path check against this head's file list and nothing matches.

Moving on to code review. 🔍

中文说明

本轮在 head aad10c09c1 上重跑 —— 上一轮评审的是 5d7d8d32,即第二个 commit 落地之前。所有阶段都重新执行;本评论是就地更新,而不是新发一条。

感谢贡献!上一轮的实质判断依然成立,所以本轮集中在变动部分:新 commit、你关掉的两条 review 线程、以及我此前要求的验证通道。

模板:完整 ✓ —— 各必需小节都填了,包含 Tested-on 表格,中文版是完整翻译而非摘要。「风险与范围」自上一轮起多了两条(\r 的取舍、goal 审批展示点),两种语言都写了 —— 这正是让该小节保持可信、而不是流于形式的地方。

问题:是已观测到的,不是理论性的,而且现在的佐证已不止你自己的一次运行。#13566 处于 open,带 type/bug / category/security / scope/web-shell / priority/P2 标签;它存在的原因是 #13549 合并时有两条 review 线程是被合并关掉的、而不是被修掉的。上一轮我明确说明:「未修复 main 上 2 failed / 81 passed」这些数字我只按你的陈述处理,不当作我复跑过的证据。现在它不再只是你的陈述:/verify 通道在该 head 上独立重推出了这个结果(见 Stage 2)。我本人仍未执行 PR 代码 —— 无人值守的 triage 运行不会执行 —— 但「先失败、后通过」的复现现在由第二方见证,而不只是作者自述。

方向:对齐,与上一轮一致。在不可信文本抵达人类决策界面处做净化,上游走得很熟;claude-code 的 CHANGELOG 有多条同类条目,包括一条落在完全相同补救形态上的(终端控制字符「现在显示为 \uXXXX 转义」),一条拒绝「tab 与 newline 之外」的原始控制字符 —— 与 sanitizeControlChars 保留的例外一致 —— 还有一条屏幕阅读器修复,是控制字符导致可见行在朗读中丢失,正对应你的 aria-describedby 角度。没有条目专门点到 Web Shell 审批卡片;这个领域显然相关,不是边缘话题。

规模:不适用。两个源文件都在 packages/web-shell/client/ 下,未触及任何核心基础设施路径,改动只在单个 package 内。记录一下:生产代码 34 行(23+/11−)、测试 79 行、文档 8 行(4+/4−),无生成文件或 schema 文件。远低于所有提示阈值。

方案:范围依然合理,而第二个 commit 是这里唯一需要核查、不能假定的东西。

ci(docs): format the daemon event-schema table for the Prettier gate 表面上像是夹带的无关改动 —— 一个关于审批卡片的 diff 里出现文档表格。它不是。在上一轮的 head 上 Lint & Static 是红的,而我当时证明了那个失败是你的 merge base 带进来的、docs/developers/daemon/09-event-schema.md 上既有的全仓格式漂移,不是本改动造成的回归;但全仓 Prettier 门禁仍会因此判本 PR 失败。这个 commit 只修了那一个文件,Lint & Static 现在绿了。有一点值得如实记录:main 此后已收到对同一张表格逐字节相同的修复,所以合并之后这个 commit 是空操作 —— 我通过分别拉取你 head 与 main 上的该文件并 diff 确认(完全相同)。这在合并历史里是噪音,不是缺陷;如果有人在意,squash 合并即可抹掉。

上一轮核过的设计取舍依然成立,而且我是在该 head 上重新核实、不是照搬:JSX 门控与 aria-describedby 列表保持在原始值上,只替换展示字符串(这是最微妙之处 —— 反过来写 IDREF 就会悬空);选 sanitizeControlChars 而非 escapePreviewText,因为后者字符类从 U+007F 起、会放过 ESC/BEL;不放宽共享 helper,那是牵动 7 个调用点的策略决定,不是范围搜索。

两条 review 线程现在都是 isResolved: true,而我是核查过这些结论、不是照单接受:

site C 的延期在评审时点依然成立:#13445 仍是 open 且 draft,head cb53b83edb,自 2026-10-05 未动。所以你写的条件性后续动作仍然有效,也仍然是正确的缓解手段。用 Refs 而不是 Fixes 依然是让清点保持诚实的地方 —— #13566 继续 open,承担 site C、goal 展示点与第 3 项。

风险:无升级风险信号 —— 我在该 head 的文件列表上重跑了与 revert 相关的路径检查,无匹配。

进入代码审查 🔍

— Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Code review

No blocking issues, and nothing new to report on the source: the only commit since my last pass is the docs formatting one, so ToolApproval.tsx and ToolApproval.test.tsx are byte-identical to what I reviewed at 5d7d8d32. I re-verified rather than carried the conclusion over, because "unchanged since I looked" is exactly the claim worth checking.

What I re-checked at this head:

  • Every read site of the two raw values. descriptionText is read raw only at the aria-describedby entry (:630) and the element gate (:648); the body and the title at :649-650 both use descriptionDisplay. execWarningsText is read raw only at its own derivation (:590), the aria-describedby entry (:633) and the element gate (:672); the body and the title at :676-678 both use execWarningsDisplay. No missed read site, no value computed and never used.
  • The two sites that stay raw are precisely the two the PR discloses. :658-659 is the goal branch (objective={goalObjective}, content={contentText || …}) and :688-690 is the showsContent <pre> (site C). I found no third undisclosed raw model-text site in the dialog, which is the thing that would have turned an honest partial fix into a misleading one.
  • The helper's contract, at source. CONTROL_CHARS_REGEX (toolFormatting.ts:117-118) is [\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069] — so it substitutes (\uXXXX, or \b/\f/\r) and never deletes. That is what makes the text ? sanitizeControlChars(text) : undefined shape safe: a truthy input always yields a truthy output, so the raw-gated element can never render referenced-but-empty and the IDREF can neither dangle nor point at nothing. It also confirms the rewritten comment now matches the regex exactly, and that the disclosed pass-throughs (zero-width, U+2028/U+2029, astral TAG, U+00AD, U+061C, U+200E/F) really are outside the class — the PR neither fixes nor hides them.

The two non-blocking notes from my last pass are unchanged and still non-blocking: the dropped ShellToolOutput cross-reference was accurate and useful (a two-word restore), and the new comment's negative examples read as illustrative rather than exhaustive. The third note I made — that the Risk & Scope sentence about \n/\t was untrue for CRLF text — is now moot: you corrected the sentence, and I verified the corrected version against the source rather than accepting it (see Stage 1).

Conventions respected: no over-abstraction, no duplicated logic, no new utility where an existing one fitted, no any, tests collocated, PascalCase.tsx for the component.

Test evidence

This is an unattended CI run, so per the gate's rules I did not build, run, or execute anything from this PR. The evidence below is the PR's own CI read through the API for the reviewed commit, plus one clearly-attributed third-party report. Snapshot taken once; I did not poll, and nothing is pending.

CI on aad10c09c1 is green across the board — zero failures. That is a change from the previous head, where Lint & Static was red on pre-existing formatting drift in a docs file this PR did not touch; the second commit fixed that file and the check now passes. Test (ubuntu-latest, Node 22.x) — the job that runs ToolApproval.test.tsx — is success, which closes the gap I flagged last pass: the two new tests pass on this head, and the pre-existing multi-line .join('\n') expectations stay green. web-shell E2E Smoke, Capture web-shell visuals and both Desktop Shell jobs are green too.

Skipped, and therefore gaps rather than passes: Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x) and Integration Tests (CLI, No Sandbox). The unit-suite signal is Linux-only on this PR. For a pure string transform inside a React component that is a low concern — there is no platform-dependent code path in the diff — but I am recording it as a gap, not as a pass. The remaining checks on the commit are bot orchestration jobs (route, label, assign, authorize, review-pr, …), all completed.

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Classify PR ✅ success
Test (macos-latest, Node 22.x) ⏭️ skipped
Test (windows-latest, Node 22.x) ⏭️ skipped
Integration Tests (CLI, No Sandbox) ⏭️ skipped

One row per check name (latest run); bot orchestration jobs omitted; failures would sort first. / 每个检查名一行(取最新一次运行),省略机器人编排 job,失败项排在最前。

The sandboxed lane I asked for last pass has now been run, and it settles the behavioural claim. @qwen-code /verify reported on this thread at 2026-10-07T14:24Z, pinned to this exact head aad10c09c13021ee809d5046334ad33e05757452 (base 1753948e3e). To be clear about provenance: that is @wenshao's report from the isolated verification lane, not evidence I produced — I executed nothing. Reading it as a third-party witness rather than as my own result, its four load-bearing findings are:

  • A/B proof. The same 83-test head file, with the base ToolApproval.tsx overlaid, reddens exactly the two new tests (2 failed / 81 passed), with error text byte-identical to the reproduction quoted in the PR description; the head arm is 83/83. So the tests are not vacuous and the fix is what turns them green.
  • Mutation matrix. Reverting each site's JSX to raw independently reddens exactly its own test (1 failed / 82 passed each way) — each hunk is pinned by its own test, with no cross-pinning and no survivor.
  • Coverage-boundary probe, 10/10. Both halves of the PR's honesty claims hold: the escaped set is as described (including \r → visible \ + r, which makes the newly disclosed CRLF caveat literally true), and every disclosed pass-through really does pass through — nothing was silently fixed or silently widened.
  • Regression attribution and trial merge. 52 files / 2801 cases across messages/ and every test file referencing ToolApproval show zero collateral on the base arm; a trial merge into a main ten commits ahead is conflict-free and re-tests green; web-shell tsc --noEmit reports 0 errors on a clean build, which supersedes the 57-error count in your Risk & Scope as an artifact of a stale hardlinked sdk-typescript/dist — worth striking from the record, since it currently understates the PR.

What nothing has covered: a real browser painting a crafted payload. Your description says plainly that no live render was verified, and the /verify report lists the same gap. Capture web-shell visuals is green and its base→head preview shows exactly one changed screenshot (collab-team-panel-light), with no approval-card image differing — which is the expected result for a sanitizer that is a no-op on ordinary text, and useful as proof of no unintended visual change, but it does not observe escaped-text paint because the mock flows carry no control characters. I am naming no lane for this one, deliberately: /verify has already run and is source-level, and /tmux drives the TUI rather than this browser surface, so neither would close it. My judgement on the residual risk is that it is low and I'll say why rather than just note the gap — the sanitizer's output is plain ASCII \uXXXX, React escapes text children and attribute values, and #13549 already shipped the identical transform for the command block one element away with a passing bidi test. There is no plausible paint divergence for \uXXXX. If a maintainer wants it observed anyway, that is a browser-session check, not a triage lane.

Real-scenario tmux testing: N/A — unattended CI run, and the gate forbids executing PR-derived code here. Nothing was driven locally and no terminal capture is offered in place of one.

中文说明

代码审查

没有阻塞性问题,源码也没有新内容可说:自我上一轮以来唯一的 commit 是文档格式化那个,所以 ToolApproval.tsx 与 ToolApproval.test.tsx 与我评审 5d7d8d32 时逐字节相同。我是重新核实、而不是沿用结论 —— 因为「自我上次查看以来未变」恰恰是值得核查的那类断言。

本轮在该 head 上重核的内容:

  • 两个原始值的每一处读取点。 descriptionText 只在 aria-describedby 条目(:630)与元素门控(:648)处以原始值读取;:649-650 的正文与 title 都用 descriptionDisplay。execWarningsText 只在自身推导(:590)、aria-describedby 条目(:633)与元素门控(:672)处以原始值读取;:676-678 的正文与 title 都用 execWarningsDisplay。没有漏掉的读取点,也没有算了却从不使用的值。
  • 仍保持原样渲染的两处,恰好就是 PR 自己披露的那两处。 :658-659 是 goal 分支(objective={goalObjective}、content={contentText || …}),:688-690 是 showsContent 的 <pre>(site C)。我没有在弹窗里找到第三处未披露的原样模型文本渲染点 —— 而那正是会把一个诚实的部分修复变成一个误导性修复的东西。
  • helper 的契约,看的是源码。 CONTROL_CHARS_REGEX(toolFormatting.ts:117-118)是 [\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069] —— 它做的是替换(\uXXXX,或 \b/\f/\r),从不删除。这正是 text ? sanitizeControlChars(text) : undefined 写法安全的原因:真值输入必然产生真值输出,所以受原始值门控的元素绝不会渲染成「被引用却为空」,IDREF 既不会悬空也不会指向虚无。这同时确认了重写后的注释与 regex 精确一致,且已披露的原样通过项(零宽字符、U+2028/U+2029、星平面 TAG、U+00AD、U+061C、U+200E/F)确实在字符类之外 —— 本 PR 既没有偷偷修掉它们,也没有掩盖它们。

上一轮那两条非阻塞备注没有变化,也仍然非阻塞:被删掉的 ShellToolOutput 交叉引用既准确又有用(两个词就能恢复);新注释里的反例读起来是举例而非穷举。我提过的第三条 —— 「风险与范围」里关于 \n/\t 那句对 CRLF 文本不成立 —— 现在已经失效:你修正了那句话,而我是对着源码核实修正版的、不是照单接受(见 Stage 1)。

约定方面无问题:没有过度抽象、没有重复逻辑、没有在既有实现可用时新造工具、没有 any、测试与源码同目录、组件文件名 PascalCase.tsx。

测试证据

这是无人值守的 CI 运行,因此按门禁规则我没有构建、运行或执行任何来自本 PR 的代码。下面的证据是通过 API 读到的、该 PR 自身在被评审 commit 上的 CI 结果,外加一份明确标注来源的第三方报告。快照只取一次;我没有轮询,也没有任何检查处于进行中。

aad10c09c1 上的 CI 全绿 —— 零失败。 这与上一个 head 不同:当时 Lint & Static 因一份本 PR 未触及的文档文件里既有的格式漂移而红;第二个 commit 修了那个文件,该检查现在通过。Test (ubuntu-latest, Node 22.x) —— 也就是跑 ToolApproval.test.tsx 的那个 job —— 是 success,这补上了我上一轮标记的缺口:两个新测试在该 head 上通过,既有的多行 .join('\n') 期望保持绿色。web-shell E2E Smoke、Capture web-shell visuals 与两个 Desktop Shell job 也都是绿的。

skipped 的项目,因此是缺口而不是通过:Test (macos-latest, Node 22.x)、Test (windows-latest, Node 22.x)、Integration Tests (CLI, No Sandbox)。本 PR 的单元测试信号只覆盖 Linux。对一个 React 组件里的纯字符串变换来说这风险很低 —— diff 里没有任何平台相关代码路径 —— 但我按缺口记录,不当作通过。该 commit 上其余检查都是机器人编排 job(route、label、assign、authorize、review-pr 等),全部完成。

上一轮我要求的那条沙箱验证通道现在已经跑过,并且它把行为层面的主张钉住了。 @qwen-code /verify 于 2026-10-07T14:24Z 在本线程发布了报告,绑定到完全相同的 head aad10c09c13021ee809d5046334ad33e05757452(base 1753948e3e)。关于来源需要说清:那是 @wenshao 从隔离验证通道产出的报告,不是我产出的证据 —— 我什么都没有执行。把它当第三方见证、而不是当我自己的结果来读,其中四条承重结论是:

  • A/B 证明。 同一份 83 用例的 head 测试文件,把被测源文件换成 base 版本后,恰好两个新测试变红(2 failed / 81 passed),报错文本与 PR 描述里引用的复现逐字节相同;head 臂 83/83。所以这些测试不是空转,而修复正是让它们变绿的东西。
  • 变异矩阵。 分别把两处的 JSX 回退成原样渲染,各自只让对应的那一个测试变红(每次 1 failed / 82 passed)—— 每个 hunk 都被它自己的测试钉住,没有交叉钉住,也没有幸存者。
  • 消毒边界探针,10/10。 PR 诚实性声明的两半都成立:被转义的集合与描述一致(包括 \r → 可见的 \ + r,这使得新披露的 CRLF 取舍字面为真),而每一个已披露的原样通过项确实原样通过 —— 没有偷偷修掉,也没有偷偷放宽。
  • 回归归因与试合并。 messages/ 目录与全部引用 ToolApproval 的测试文件共 52 文件 / 2801 用例,base 臂零误伤;向领先 10 个 commit 的 main 试合并无冲突且重跑全绿;web-shell tsc --noEmit 在干净构建下报 0 错误,这说明「风险与范围」里那 57 个错误是本地陈旧硬链接 sdk-typescript/dist 造成的环境问题 —— 这一条值得从记录里划掉,因为它目前在低估计这个 PR。

没有任何通道覆盖到的:真实浏览器绘制一个精心构造的载荷。 你的描述明确写了没有验证真实浏览器渲染,/verify 报告也列了同一个缺口。Capture web-shell visuals 是绿的,其 base→head 预览只显示一张截图有变化(collab-team-panel-light),审批卡片的截图没有差异 —— 对一个在普通文本上是空操作的 sanitizer 来说这正是预期结果,可以作为「没有意外视觉变化」的证明,但它并没有观测到转义文本的绘制,因为 mock 流程里不含控制字符。这一项我刻意不点名任何通道:/verify 已经跑过且是源码层面的,/tmux 驱动的是 TUI 而不是这个浏览器界面,两者都关不上这个缺口。关于残余风险我的判断是「低」,而且我说理由、不只记一句缺口:sanitizer 的输出是纯 ASCII 的 \uXXXX,React 会转义文本子节点与属性值,而 #13549 已经为一个元素之隔的命令块上线了完全相同的变换并带有通过的 bidi 测试。对 \uXXXX 而言不存在说得通的绘制差异。如果 maintainer 仍希望观测到,那是一次浏览器会话检查,不是 triage 通道。

真实场景 tmux 测试:N/A —— 无人值守 CI 运行,且门禁禁止在此执行 PR 派生代码。本地没有驱动任何东西,也没有用终端截图替代。

— Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — everything I held open last pass is now closed with evidence rather than assertion; what keeps it off 5/5 is two named nits and the fact that merging this does not close #13566.

Stepping back with the whole picture in view. Last pass I approved in principle but refused to assert a test result that did not exist yet — Test (ubuntu-latest) was still in flight, and the fail-then-pass reproduction rested on your local run alone. Both of those are now resolved, and resolved in the way that actually counts: CI settled green on this exact head, and the /verify lane independently re-derived the A/B result, then went further than I asked and proved each hunk is pinned by exactly its own test. That mutation matrix is the piece I would have wanted and did not think to request. A suite that passes identically with and without a diff is green and worthless; this one reddens precisely where the fix is removed.

The two review threads were the other open item, and this is where I want to be specific, because "author says resolved" is the cheapest thing in this job to accept. Neither thread produced a code change, and a zero-code response to a review finding is exactly where a gate should slow down. So I checked both against the source instead of against the reply:

  • The \r finding was never a demand to change behaviour — it named two complete fixes, and you took the documentation one. I re-derived the mechanism myself (\x0d inside the \x0b-\x1f band at toolFormatting.ts:117-118, the case '\r' mapping at :128-129, getDescriptionText trimming only the ends at :115-118) and the corrected Risk & Scope sentence is now true in both languages. Your reasoning for declining the normalisation is also the part I find persuasive rather than merely acceptable: escaping \r is the shared helper's pinned behaviour, the command block has done it since fix(web-shell): escape control characters in the managed approval args preview #13549, and normalising at two of seven call sites would make the card inconsistent with the block sitting one element away. That is a contract argument, not a convenience argument.
  • The goal-branch finding was an accounting gap, and accounting gaps are only fixed where the accounting lives. You recorded it on web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566 — I confirmed the comment is there — which is the part that survives the merge. A PR description does not.

On the independent-proposal test I should stay as straight as I was last pass: #13566 prescribed the approach in unusual detail, so there was little design space to differ in and I would have landed in the same place. What is the author's own is everything that makes the tests load-bearing rather than decorative — the exact-escaped-output assertions, the check that the sibling command block keeps its separate payload, and reaching .desc through the aria-describedby IDREF so the test doubles as proof the ARIA wiring still resolves. Those choices are why the mutation matrix came out clean.

Would I thank or curse whoever wrote this in six months? Thank. The overclaiming comment is gone and the two that replaced it state what the helper covers and why the gate must stay on the raw value — the second is precisely the invariant a future editor breaks by reflex, written down where they will read it. The mild curse is still the dropped ShellToolOutput cross-reference: accurate, useful, and now missing. Two words to restore, and I would not hold a PR for it.

The reservations, plainly, so they are not buried under a green board:

  • Merging this does not close the hole. Two of the dialog's four raw model-text sites are fixed. Site C waits on draft feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445, which is still open, still draft, and still unmoved since 2026-10-05 — if it stalls, site C has no owner beyond the open issue. The goal branch is now named and tracked but unfixed, and it is the one site no permission rule or approval mode skips. Item 3 (widening the helper) is a maintainer policy call that nobody has made. Refs instead of Fixes is what keeps all of that visible, and it is the right call — but a maintainer should read this merge as progress on web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566, not as closing it.
  • A small, disclosed legibility regression. CRLF-terminated text at the two newly sanitised sites now paints a visible \r per line end where a browser previously collapsed the break. It is cosmetic, it does not defeat the bidi neutralisation this PR exists to add (U+202E is escaped either way), the realistic trigger is narrow, and it is now honestly recorded in both languages. I accept the decline; I am noting that the tradeoff is real rather than theoretical.
  • No real browser observation of escaped-text paint, and no lane that would supply one — my reasoning for calling that low-risk is in Stage 2 rather than repeated here.
  • Unit-suite signal is Linux-only on this PR (macOS and Windows test jobs skipped). No platform-dependent path in the diff, so I treat it as a gap, not a concern.

Am I approving because it is genuinely good, or because I ran out of reasons to say no? The reasons to say no were raised last pass, and each was answered with a verified correction or an independent witness rather than with reassurance. That is the sequence I want to see.

Verdict: approve, now rather than deferred — no PR workflow run is in flight on this head, the fork-refactor guardrail does not apply (same-repository branch, fix type), and Stage 0 raised no escalation because no core-infrastructure path is touched. The approval is pinned to the reviewed commit through the reviews API. For the maintainer's awareness: main requires two approving reviews and this PR currently sits at REVIEW_REQUIRED, so this is one vote, not a merge — a human sign-off is still needed, and the site-C / goal-site / item-3 remainders on #13566 are the thing worth a human eye before the issue itself is closed.

中文说明

信心度:4/5 —— 上一轮我悬着的事项现在都用证据、而不是用断言关掉了;让它没到 5/5 的是两条点名的细项,以及「合并本 PR 并不等于关掉 #13566」这个事实。

退一步看整体。上一轮我在原则上批准,但拒绝去断言一个当时还不存在的测试结果 —— Test (ubuntu-latest) 仍在进行,而「先失败、后通过」的复现只靠你本地那一次运行。两者现在都已落定,而且是以真正算数的方式落定:CI 在这个确切的 head 上全绿,/verify 通道独立重推出了 A/B 结果,并且比我要求的走得更远 —— 证明了每个 hunk 都恰好被它自己的测试钉住。那份变异矩阵正是我本来会想要、却没想到去要的东西。一个「有没有 diff 都同样通过」的测试套件是绿的、也是没有价值的;而这一套在移除修复的地方会精确变红。

两条 review 线程是另一个未决项,这里我想说得具体些,因为「作者说已解决」是这份工作里最廉价、最容易被接受的东西。两条线程都没有产生代码改动,而「对 finding 的零代码回应」恰恰是门禁应当慢下来的地方。所以我是对着源码、而不是对着回复去核这两件事:

  • \r 那条 finding 从来不是要求改变行为 —— 它点名了两种完整修复,你选了文档那一种。我自己重推了机制(\x0d 落在 toolFormatting.ts:117-118 的 \x0b-\x1f 区间内、case '\r' 的映射在 :128-129、getDescriptionText 只对首尾 trim 在 :115-118),修正后的「风险与范围」那句在两种语言里都成立了。你拒绝归一化的理由,也是我认为是有说服力而不只是「可以接受」的部分:转义 \r 是共享 helper 被钉住的行为,命令块自 fix(web-shell): escape control characters in the managed approval args preview #13549 起就是这样,而在 7 个调用点里只改 2 个,会让卡片与一个元素之隔的命令块不一致。这是契约论证,不是图省事的论证。
  • goal 分支那条 finding 是清点缺口,而清点缺口只能在清点所在之处被修掉。你把它记到了 web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566 —— 我确认那条评论在那里 —— 这才是能活过合并的部分。PR 描述活不过。

关于「独立方案」这项检验,我要和上一轮一样坦白:#13566 已经异常详细地规定了做法,所以可供分歧的设计空间本就很小,我从零勾画也会落在同一处。属于作者自己的,是让测试承重而非装饰的那一切 —— 精确转义输出的断言、检查相邻命令块保持自己独立载荷的那一条、以及通过 aria-describedby 的 IDREF 定位 .desc 从而让测试同时成为「ARIA 接线仍能解析」的证明。这些选择正是变异矩阵能干净收场的原因。

六个月后我会感谢还是咒骂写这段代码的人?感谢。那段过度声明的注释没有了,取代它的两段说清了 helper 覆盖什么、以及门控为什么必须留在原始值上 —— 后者正是未来编辑者会凭反射改坏的那个不变量,而它被写在对方会读到的位置。轻微的咒骂仍然是被删掉的 ShellToolOutput 交叉引用:既准确又有用,现在没了。两个词就能恢复,我不会为这个压住一个 PR。

顾虑直说,以免被一块绿板埋掉:

  • 合并本 PR 并不等于封上缺口。 弹窗里四处原样渲染的模型文本展示点,修掉了两处。site C 等着草稿 feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445 —— 它仍是 open、仍是 draft、自 2026-10-05 仍未动;如果它停滞,site C 除了那个 open issue 之外没有归属者。goal 分支现在被点名并被跟踪,但未修,而它是唯一一个「任何权限规则或审批模式都不会跳过」的展示点。第 3 项(放宽 helper)是一个还没人做出的 maintainer 策略决定。用 Refs 而不是 Fixes,正是让这一切保持可见的原因,也是对的选择 —— 但 maintainer 应把这次合并读成 web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566 上的进展,而不是它的结束。
  • 一处小的、已披露的可读性回退。 以 CRLF 结尾的文本在这两处新净化的展示点上,现在每个行尾会画出一个可见的 \r,而浏览器此前会把这个换行合并掉。它是外观性的,不会破坏本 PR 存在的目的即 bidi 中和(U+202E 两种情况下都被转义),现实触发面很窄,而且现在两种语言里都如实记录了。我接受这个拒绝;我只是要说明这个取舍是真实的,不是理论上的。
  • 没有对转义文本绘制的真实浏览器观测,也没有能提供它的通道 —— 我判断其风险为低的理由写在 Stage 2,不在这里重复。
  • 本 PR 的单元测试信号只覆盖 Linux(macOS 与 Windows 的 test job 被 skipped)。diff 里没有平台相关路径,所以我按缺口处理,不当作顾虑。

我批准,是因为它真的好,还是因为我说不出「不」的理由了?说「不」的理由上一轮已经提过,而每一条得到的都是经核实的修正或独立见证,而不是安慰性的表态。这正是我想看到的顺序。

结论:批准,而且是现在批准、不是推迟 —— 该 head 上没有进行中的 PR workflow run,fork-refactor 保护规则不适用(同仓库分支、fix 类型),Stage 0 也没有上报,因为未触及任何核心基础设施路径。批准通过 reviews API 绑定到被评审的 commit。给 maintainer 的提示:main 要求两个 approving review,而本 PR 目前处于 REVIEW_REQUIRED,所以这是一票、不是一次合并 —— 仍需要人类签字;而在关掉 #13566 本身之前,site C / goal 展示点 / 第 3 项这些遗留正是值得人类过一眼的部分。

— Qwen Code · qwen3.8-max-2026-09-02

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

@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head aad10c0. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

collab-team-panel-light before/after

Full-resolution recordings (.webm) are attached to the workflow run.

— Qwen Code · web-shell visuals

@qwen-code-review-bot qwen-code-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: reverse audit — stopped before round 1 by the review time budget.

Test Plan (not a blocker): components/messages/ToolApproval.test.tsx — no such file or directory; client/components/messages/ToolApproval.test.tsx — no such file or directory; Tests 83 passed — this review observed 10902, 39433, 609 passed; 1 passed — this review observed 10902, 39433, 609 passed; 81 passed — this review observed 10902, 39433, 609 passed; and 1 more.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查:反向审计——评审时间预算不足,未能开始第 1 轮。

Test Plan(非阻断):components/messages/ToolApproval.test.tsx — no such file or directory; client/components/messages/ToolApproval.test.tsx — no such file or directory; Tests 83 passed — this review observed 10902, 39433, 609 passed; 1 passed — this review observed 10902, 39433, 609 passed; 81 passed — this review observed 10902, 39433, 609 passed; and 1 more。

— qwen3.8-max via Qwen Code /review (v0.25.0)

Comment thread packages/web-shell/client/components/messages/ToolApproval.tsx
Comment thread packages/web-shell/client/components/messages/ToolApproval.tsx
@wenshao

wenshao commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

Verdict: merge-ready — 16,950 scripted assertions executed, 0 unexpected failures. Verified head aad10c09c13021ee809d5046334ad33e05757452 (base 1753948e3ecbafd8ac75866dabb6289652ee2361); trial merge into current main (origin/main @ verification time, 10 commits ahead) is conflict-free and re-tested green.

中文摘要

结论:merge-ready(可合并) — 16,950 条脚本断言执行,0 个意外失败。

  • A/B 证明(核心主张成立):同一份 head 测试文件(83 个用例),把被测源文件换成 base 版本时,恰好两个新增测试变红(报错信息与作者在 PR 描述中引用的一字不差),换回 head 则 83/83 全绿。两处修复(描述行、exec 警告块)各自被且仅被对应的新测试钉住(单点回退各产生恰好 1 个红)。
  • 消毒边界探针:10/10 通过——PR 声明的转义范围(C0/C1/DEL/bidi 嵌入与隔离符)与声明的缺口(零宽字符、U+2028/29、星体 TAG 字符原样通过;\r 渲染为可见 \r)全部属实,无夸大。
  • 披露属实:第四处未净化站点(GoalApprovalContent,goal 审批分支)确实存在且为本 PR 之前既有(base..head 字节相同);web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566 第 3 项(helper 未加宽)属实。
  • 回归归因:messages/ 目录 + 全部 9 个引用 ToolApproval 的测试文件(52 文件 2801 用例)双臂对比:base 臂仅新增两测试红,其余 2799 全绿,零误伤。web-shell 全套件 head 臂 10,889 绿(另有 24 个 build-artifact 红系本机未构建 dist 所致,构建后 24/24 转绿,与 PR 无关)。
  • 试合并:PR 第二个 commit(docs 表格格式化)与 main 上已落地的修改字节相同( blob 哈希一致),合并后该 commit 为空操作;ToolApproval 相关文件 main 未触碰;试合并树重跑 83/83 绿。
  • 其他门禁:eslint 两改动文件干净;web-shell tsc --noEmit 0 错误(作者本地报告的 57 个"既有错误"是其 stale dist 环境所致,全新构建的 sdk dist 下不存在)。
  • 未覆盖:真实浏览器渲染观感(jsdom 之外)、Windows/Linux 本机运行、站点 C 与 goal 站点(作者已声明推迟)。

Central claim and A/B proof

Claim: the approval card's description line (.desc, body + title tooltip) and exec-warnings <pre> (body + title) rendered model-supplied text verbatim; the PR routes both through the same sanitizeControlChars the command block already uses, with render gates kept on the raw values so aria-describedby IDREFs cannot dangle.

Method: single head worktree; the base arm is the base ToolApproval.tsx overlaid on the head tree (source-level vitest run — no dist involved for this surface), proven to differ from head by exactly the PR hunks (git diff overlay vs head = 23+/11- in that one file; lockfiles byte-identical base↔head, so no dependency confound). Identical head test file (83 tests) drives both arms.

Arm Result Oracle
Base (1753948e ToolApproval.tsx + head tests) 2 failed / 81 passed (83) expected 'Delete temporary data\u202e…' not to contain '\u202e' (description); expected 'Runs outside the workspace in /tmp/\u…' not to contain '\u202e' (warnings) — byte-identical to the author's quoted repro
Head (aad10c09) 83 passed (83) all green
Trial merge into current main 83 passed (83) conflict-free; see "Trial merge" below

A/B witness

Mutation matrix (each hunk load-bearing)

Mutation at head Red test(s) Verdict
Revert description-site JSX to raw (title + body) exactly the description test (1 failed / 82 passed) hunk pinned by its own test
Revert exec-warnings-site JSX to raw (title + body) exactly the warnings test (1 failed / 82 passed) hunk pinned by its own test
Full base overlay (positive control) both new tests (2 failed / 81 passed) harness can go red

Mutation matrix

The new tests are not vacuous, and each pins its own site — no cross-pinning, no survivor.

sanitizeControlChars coverage-boundary probe (10/10)

A scripted battery against the real helper pins both halves of the PR's honesty claims:

  • Escaped (as stated): C0 (\x1b, \x07), DEL/C1 (\x7f, \x85), bidi embeddings/overrides (U+202A–E), bidi isolates (U+2066–69); \r → the two visible characters \ + r (the disclosed CRLF caveat is literally true); \n/\t preserved.
  • Disclosed pass-throughs (all confirmed, none silently fixed or widened): U+200B, U+2060, U+FEFF, U+00AD, U+061C, U+200E/U+200F, U+2028/U+2029, astral TAG U+E0020/U+E007F.

Coverage probe

Disclosure audit (author's "not covered" claims, independently checked)

Regression attribution (targeted suite)

All 9 test files importing ToolApproval plus the entire messages/ directory — 52 files, 2,801 tests per arm, same NODE_OPTIONS=--no-experimental-webstorage on both arms (this host's Node 25 jsdom quirk):

Arm Files Tests
Head 52 passed 2,801 passed
Base overlay 51 passed / 1 failed 2,799 passed / 2 failed = exactly the two new tests

Zero collateral: nothing outside the two new tests flips.

Targeted attribution

Full web-shell suite at head (413 files): 10,889 passed; the only red file was build-artifact.test.ts (24 tests), every failure ENOENT …/packages/web-shell/dist — an unscaffolded-worktree artifact. After building the package, that file re-runs 24/24 green. Not PR-related; disclosed for completeness.

Trial merge into current main

origin/main has advanced 10 commits past the base. git merge-tree is conflict-free. Two facts worth the merger's attention:

  1. Main touched docs/developers/daemon/09-event-schema.md since the base — with a change byte-identical to this PR's second commit (same result blob 9203cefc35). The docs commit is therefore a no-op on merge. (Locally, both the base and head content of that file pass the repo-pinned prettier --check and are byte-identical to prettier's own normalized output, so the commit message's "for the Prettier gate" motivation does not reproduce here; since the identical reformat independently landed on main, some gate path evidently did require it. Docs-only, zero code impact — noted, not blocking.)
  2. Main did not touch ToolApproval.tsx / ToolApproval.test.tsx. The merged tree was materialized locally (local-only merge commit aa61f9c8), workspace links realpath-verified to resolve inside the merged tree, and the focused file re-runs 83/83 green.

Gates

  • eslint on both changed files: clean.
  • tsc -p tsconfig.json --noEmit in packages/web-shell: exit 0, zero errors (the 57 pre-existing errors the author saw locally were their stale hardlinked sdk dist, as they suspected; with a freshly built @qwen-code/sdk dist the package typechecks fully clean).
  • prettier --check on the docs file: clean at head, in the merged tree, and (see above) also at base.

Not covered

  • Real-browser rendering — evidence is jsdom textContent/title assertions plus the helper-level probe; no visual browser pass (author disclosed the same).
  • Windows/Linux native runs — this round ran on macOS 15.7.9 (Intel), Node v25.8.0.
  • Site C and the goal-approval site — deliberately out of scope per the PR; confirmed they remain raw (disclosure verified, not re-litigated).
  • Full-suite base arm — attribution was done on the 52-file targeted superset of every file that can observe the change (all ToolApproval importers + messages/), not a second full-suite run.
  • First full-suite attempt wedged with zero live workers (this host's known spawn-stall class issue); restarted with file logging and completed. Environmental, both arms unaffected.

Methodology

macOS 15.7.9 (Intel), Node v25.8.0 with NODE_OPTIONS=--no-experimental-webstorage on every vitest invocation (known local jsdom conflict, applied identically to all arms). One worktree at PR head (pnpm install --frozen-lockfile --ignore-scripts, then built acp-bridge + sdk-typescript + web-shell dists); the base arm is the base ToolApproval.tsx overlaid in place (delta proven == the PR hunks) since this surface needs no compiled dist. Trial-merge tree is a local-only commit (aa61f9c8) with node_modules clonefile-copied from the head tree (lockfile byte-identical head↔merged, realpath-verified). Raw logs: tmp/pr13578-verify-20261007-212145/logs/ in the verifying checkout; harness probe file was removed after use. Assertion accounting: 16,950 executed scripted checks (test cases across all runs + lint/typecheck/format/realpath/lockfile checks), 0 unexpected failures; the 24 environmental build-artifact reds were root-caused to the missing dist and re-run green (24/24), so they are disclosed but not counted as failures.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@yiliang114
yiliang114 requested review from chiga0, qqqys and wenshao October 7, 2026 23:49
@qwen-code-review-bot

qwen-code-review-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 11007 passed · 0 failed · 11007 total

Flakiness gate: ✅ 1 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。

脚本断言:11007 通过 · 0 失败 · 11007 总计

抖动门:✅ 1 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #13578 — deep verification

Verdict: findings — the central claim is proven load-bearing (A/B: 8 rows flip in the intended direction, 25 unchanged, zero benign collateral) and every executed assertion passed (11,007 pass / 0 fail). No finding below is a defect introduced by this diff; the four findings are a gap in the PR's own site enumeration, a refinement of a tradeoff it documents, and two framing/accuracy points.

Verified head: aad10c09c13021ee809d5046334ad33e05757452 (git rev-parse HEAD^2, identical to the snapshot's headRefOid — no drift). Base arm: HEAD^1 = 8003d280.

中文摘要

结论:findings(有问题需要评审者注意,但都不是本 diff 引入的缺陷)。

A/B 结论:中心主张成立。把本 PR 新增的两处净化 guard 全部回退作为对照组,用同一套 census 探针在 head 与对照组各跑一遍:33 行对比中 8 行按预期翻转(.desc 的 rawInput.description 路径、.desc 的 title 回退路径、exec 警告 <pre>,以及 U+202E / BEL / ESC 三个向量),25 行不变,其中 8 条正常文本(ASCII、中文、emoji、重音符号、多行 LF、路径、ZWJ 家庭 emoji、软连字符)在两侧都逐字节相同 —— 零附带损伤。aria-describedby 在 4 种场景下悬空 id 均为 0。

变异矩阵(9 个 arm):M1 同时回退两处 guard → 恰好 2 个新测试变红,断言信息与 PR 描述引用的一字不差(证明新测试非空洞);M2 只回退 .desc → 只有描述测试红;M3 只回退警告 → 只有警告测试红;M4 只回退两个 title= → 两个测试都红(tooltip 断言是有效的);M5 阳性对照,回退 #13549 既有的命令块 guard → 既有 bidi 测试变红(证明测试框架确实收集并执行了本文件);M6 把渲染门控从原始值改到净化值 → 83/83 全绿(幸存者)。

Findings:① 存在第 5 处未净化的模型可控渲染点 —— 编辑 diff 预览(ToolApproval.tsx:702 的 <div>{block.path}</div> 与 DiffView.tsx:38),其 path 来自 write-file.ts:286 / edit.ts:459 的 filePath: this.params.file_path,PR 描述与 #13566 都没有列出它;实测 head 与对照组都是 RAW,即本 PR 没有改变它(不是回归)。② CR 可见化取舍比描述所写的更宽:ShellTool.getDescription()(shell.ts:2182)用 .replace(/\n/g,' ') 去掉 \n 却留下孤立 \r,因此描述里提出的「CRLF→LF 归一化」并不能修好 title 路径;候选修复 v2(/\r\n?/g)实测两处都干净、正常文本零变化、83/83 不变。③ U+200F RLM / U+200E LRM / U+061C ALM 在本 PR 修好的两处仍然原样通过(作者已在 Risk & Scope 记录为 #13566 第 3 项并有意延期)—— 因此「已净化」不应被读作「这两处不再可能重排文本」。④ 新注释称门控「必须留在原始值上,否则 IDREF 悬空」,实测两者不可区分(净化输出对非空输入永不为空)。

未覆盖:未在真实浏览器中渲染(只有 jsdom);浅克隆(depth 2)导致 2 个提交中只有 1 个可达,按聚合 diff 验证;未跑 Playwright e2e;未跑真实 daemon/ACP 端到端(producer 链是读代码论证的,渲染行为是实测的);未跑 web-shell 之外的仓库级 lint/typecheck;未在对照组重复整套 web-shell 测试。

Central claim

The .desc description element (body and title) and the exec-warnings <pre> (body and title) in the web-shell approval dialog no longer render raw C0/ANSI, C1 and bidi embedding/isolate controls from model-supplied text; the render gates stay on raw values so aria-describedby IDREFs still resolve.

Secondary claims: (a) ordinary text is unaffected (\n/\t preserved); (b) the pre-existing command-block test and the multi-line .join('\n') expectations stay green; (c) the narrowed comment states only what the helper covers.

Proven. A/B table (witness: 01-ab-head-vs-control.png):

census row control (both guards reverted) head (PR) Δ
SITE A .desc (rawInput.description) RAW-BOTH ESCAPED flip
SITE A2 .desc (title fallback = getDescription()) RAW-BOTH ESCAPED flip
SITE B command <pre> (#13549, positive control) ESCAPED ESCAPED same
SITE C exec-warnings <pre> RAW-BOTH ESCAPED flip
SITE D showsContent <pre> RAW-BOTH RAW-BOTH same (documented deferral)
SITE E goal body (objective) RAW-BODY RAW-BODY same (documented 4th site)
SITE F diff-preview path <div> RAW-BODY RAW-BODY same (not enumerated — Finding 1)
SITE G .name heading (toolName) RAW-BODY RAW-BODY same (host/MCP-supplied)
VECTOR U+202E / U+0007 / U+001B RAW escaped flip ×3
VECTOR U+200F, U+200E, U+061C, U+200B, U+2060, U+FEFF, U+00AD, U+E0041 RAW RAW same ×8 (Finding 3)
VECTOR U+2028, U+2029 RAW RAW same ×2 — compared directly from the two arm logs, see note
BENIGN ×8 (ascii, CJK, emoji+astral, accents, multiline-LF, path, ZWJ-family, soft-hyphen) IDENTICAL IDENTICAL same ×8 — zero collateral
CRLF description / lone-CR title invisible \r visible \r flip ×2 (Finding 2)
IDREF ×4 (control-only, whitespace-only, absent, warnings+desc) 0 dangling 0 dangling same ×4

Totals: 33 paired rows, 8 flipped (all intended), 25 unchanged. Census suite: head 6 passed (6) with ASSERTS-EXECUTED 43; control 3 failed | 3 passed (6) with the same 43 — the 3 reds are exactly the arms whose expectations the PR is meant to move.

Note on U+2028/U+2029: the census executes and logs all 13 vectors on both arms, but the pairing map in ab-sites.mjs keyed 33 rows, not 35 — those two vectors' logged rendered= field contains a literal line/paragraph separator, which cost them their row in the joined table. They are therefore compared directly from the raw arm logs instead: VECTOR U+2028 LINE SEP PASSES-THROUGH-RAW and VECTOR U+2029 PARA SEP PASSES-THROUGH-RAW appear identically in logs/census-head.log and logs/census-base-control.log, so the "same" verdict is measured, only the tabulation path differs. Counting them, the census compares 35 rows with 8 flips.

Producer traces confirm the payload really is model-written (not synthesised by me):

  • .desc ← getDescriptionText (ToolApproval.tsx:115) ← rawInput.description (a real run_shell_command parameter) or request.title ← ShellTool.getDescription() (packages/core/src/tools/shell.ts:2167-2182), which interpolates params.command, params.directory and params.description verbatim.
  • exec warnings ← extractContentText ← text content blocks ← buildPermissionRequestContent (packages/cli/src/acp-integration/session/permissionUtils.ts:150-157) ← confirmation.warnings ← buildOutsideWorkspaceWarning (packages/core/src/utils/shell-utils.ts:2093-2104), which interpolates the raw directory at :2102-2103 exactly as the description states.

Mutation matrix / vacuity

Witness: 02-mutation-matrix.png. Every mutation is interface-preserving (types unchanged), so a red test proves the guard is load-bearing rather than that the build broke.

arm mutation result failing test(s) classification
M0 none (control) exit 0, 83 passed (83) — baseline green
M1 revert both new guards = base behaviour exit 1, 2 failed | 81 passed description test, warnings test A/B base arm + vacuity check: both new tests are non-vacuous
M2 revert only .desc guard exit 1, 1 failed | 82 passed description test only guard load-bearing, attribution correct
M3 revert only exec-warnings guard exit 1, 1 failed | 82 passed warnings test only guard load-bearing, attribution correct
M4 keep bodies sanitised, revert only the two title= exit 1, 2 failed | 81 passed both new tests tooltip assertions are load-bearing
M5 positive control: revert the pre-existing #13549 command guard exit 1, 1 failed | 82 passed neutralises bidi and C0 controls in the rendered command block harness proven live in this file
M6 move the .desc render gate from raw to sanitised value exit 0, 83 passed (83) — survivor → Finding 4
M7 candidate fix v1: /\r\n/g → \n at the 2 new sites exit 0, 83 passed (83) — survivor → Finding 2 (suite pins nothing on the CR axis)
M7b candidate fix v2: /\r\n?/g → \n at the 2 new sites exit 0, 83 passed (83) — measured fix, see Finding 2

M1's failure messages reproduce the PR description's quoted output byte-for-byte:

expected 'Delete temporary data\u202e\nls \u001…' not to contain '\u202e'
expected 'Runs outside the workspace in /tmp/\u…' not to contain '\u202e'

M5 is the control the skill requires in the same file as the mutants: it reddens a test that the chosen command actually collects, so M1–M4's reds and M6/M7's greens are all trustworthy. Source was restored byte-identical after every arm (git status --porcelain clean apart from the scratch census file, since deleted).

Corrections to the description

Not code requests — the description states two things that do not hold in this environment:

  1. Typecheck. Risk & Scope reports tsc -p tsconfig.json --noEmit exiting 2 with 57 pre-existing errors and defers to CI. In this container (clean npm ci + npm run build at head) it exits 0 with 0 errors, and 0 in client/components/messages/. The 57 were, as the author suspected, the stale hardlinked packages/sdk-typescript/dist. Log: logs/typecheck-head.log.
  2. The old comment's overclaim was real, and this PR's narrowing is correct. The removed comment asserted the command block is "the last choke point before an approver reads what they are about to authorize". My census independently falsifies that: four sibling sites (D, E, F, G) render model- or server-supplied text raw in the same [role=alertdialog]. The replacement comment's positive claim — that zero-width and line/paragraph separators are not in the class and still pass through — is also confirmed by measurement (10 vectors RAW at head).

Findings

1. Minor — a fifth raw model-supplied site exists that neither the PR nor #13566 enumerates

The description records four sites (.desc, exec-warnings <pre>, showsContent, goal branch). There is a fifth in the same dialog, on the default code path, carrying model-supplied bytes:

  • packages/web-shell/client/components/messages/ToolApproval.tsx:702 — <div>{block.path}</div>
  • packages/web-shell/client/components/messages/tools/DiffView.tsx:38 — <span className={styles.content}>{line.content}</span> (the diff body, i.e. the model's old_string/new_string via buildUnifiedDiff)

Neither file contains a sanitizer (grep -n 'sanitize\|CONTROL' DiffView.tsx → no match). The path is model-written end to end: write-file.ts:286 and edit.ts:459 set filePath: this.params.file_path, which permissionUtils.ts:163-166 copies into the type: 'diff' block's path. It renders by default — hostOwnsEditDiffPreview = false (App.tsx:3377) — and my census rendered it with no customization provider at all.

Measured: SITE F diff-preview path <div> RAW-BODY body="/tmp/DiffPath‮txt+1-10-a0+b", identical at head and control — so this PR neither fixes nor causes it. Bounded impact: a plain <div> with no title and not referenced by aria-describedby, so the consequence is visible reordering only, without the tooltip and screen-reader amplification the other sites have. It matters because it is the path the approver reads to decide which file is about to be written — the same "display one string while authorizing different bytes" shape the narrowed comment now (correctly) declines to claim is closed.

Reproduce:

cd packages/web-shell && npx vitest run \
  client/components/messages/__verify13578census.test.tsx \
  --config vitest.config.ts --coverage.enabled=false
# (census.test.tsx is in this artifact dir; copy it into client/components/messages/ first)

Suggested follow-up: add site F to #13566 alongside site D/E, rather than widening this diff.

2. Minor — the CR-visibility tradeoff is wider than described, and the proposed remedy misses the realistic producer

The description records that \r is inside CONTROL_CHARS_REGEX (toolFormatting.ts:117-118) and maps to a visible \ + r (:128-129), names the realistic trigger as "a model-written multi-line description with Windows line endings", and proposes CRLF→LF normalisation as the fix it declined to fold in. Measured three-way (witness: 03-crlf-tradeoff-and-candidate-fix.png):

arm CRLF description renders as lone-CR title renders as benign rows changed suite
control (base, guards reverted) "line one\r\nline two" — invisible "ls -la (do the thing\r )" — invisible 0 2 failed | 81 passed
head (PR as merged) "line one\\r\nline two" — visible \r "ls -la (do the thing\\r )" — visible \r 0 83 passed
candidate v1 /\r\n/g → \n "line one\nline two" — clean "ls -la (do the thing\\r )" — still visible 0 83 passed
candidate v2 /\r\n?/g → \n "line one\nline two" — clean "ls -la (do the thing\n )" — clean 0 83 passed

Two things follow. First, the escaping artifact at these two sites went from 0 visible characters to 2 per line end — the tradeoff is real and newly introduced here, though the class is pre-existing (the command block has escaped \r since #13549; toolFormatting.test.ts:34 pins sanitizeControlChars('a\rb') === 'a\\rb'). Second, and not in the description: ShellTool.getDescription() at shell.ts:2182 does this.params.description.replace(/\n/g, ' '), which removes the \n and leaves a lone \r. So the title-fallback path reaches .desc with a bare CR that a CRLF-only normalisation cannot touch — candidate v1 leaves it visible. Candidate v2 covers both.

Both candidates are survivors: 83/83 green with and without them, which is the unpinned-axis signal — no committed test distinguishes head from head-plus-fix on the CR axis. If this is fixed, it should ship with a fixture, e.g. a description of 'line one\r\nline two' asserting .desc renders 'line one\nline two'.

Measured candidate v2 (call-site local; does not touch the shared helper)
   const descriptionDisplay = descriptionText
-    ? sanitizeControlChars(descriptionText)
+    ? sanitizeControlChars(descriptionText.replace(/\r\n?/g, '\n'))
     : undefined;
@@
   const execWarningsDisplay = execWarningsText
-    ? sanitizeControlChars(execWarningsText)
+    ? sanitizeControlChars(execWarningsText.replace(/\r\n?/g, '\n'))
     : undefined;

Verified: hostile fixtures still escaped (SITE A/SITE C = ESCAPED), benign fixtures byte-identical (0 of 8 CHANGED), suite counts unchanged (83 passed (83)).

3. Informational — bidi reordering is still possible at the two sites this PR "sanitises"

The author documents this in Risk & Scope as #13566 item 3 and defers it as a maintainer policy call. I measured the boundary rather than trusting the list, and it holds exactly as written — but it is worth surfacing prominently because the PR title reads as closing the reorder attack:

vector at head, in .desc
U+202E RTO escaped
U+200F RLM, U+200E LRM, U+061C ALM RAW
U+200B, U+2060, U+FEFF, U+00AD, U+2028, U+2029, U+E0041 (astral TAG) RAW
U+0007 BEL, U+001B ESC escaped

RLM/LRM/ALM are directional marks: they reorder adjacent neutral and weak runs (digits, punctuation, extension-like text) rather than strongly-directed Latin, so the residual is narrower than RTO's — but it is not zero, and it survives at precisely the two sites the PR now calls sanitised. The new code comment is accurate about this ("the bidi embedding/isolate controls"); the exposure is in the framing, not the code. The PR's new tests assert only U+202E/U+001B/U+0007, so as the author states, these vectors are neither fixed nor hidden.

4. Informational — the render-gate rationale is not observable today (M6 survivor)

The new comment says the element gate "must stay on the raw value or the IDREF dangles". Moving the gate to the sanitised value keeps 83 passed (83) — nothing distinguishes the two. Reason: sanitizeControlChars maps every matched character to ≥2 characters and passes unmatched characters through, so its output is non-empty whenever its input is; descriptionDisplay is truthy exactly when descriptionText is. Keeping the gate on the raw value is still the correct defensive choice (it would matter if the helper ever deleted rather than escaped), and the IDREF list itself is separately gated on raw descriptionText/execWarningsText — my IDREF probe found 0 dangling ids across 4 scenarios, including a description consisting solely of control characters. Only the word "must" overstates what is currently observable.

Gates

gate command result
workspace suite (head) npx vitest run --config vitest.config.ts in packages/web-shell 412 files / 10,916 tests passed, exit 0
changed-file suite (head) npx vitest run client/components/messages/ToolApproval.test.tsx … 83 passed (83), exit 0
changed-file suite (control) same, both guards reverted 2 failed | 81 passed (83), exit 1 — expected
typecheck npx tsc -p tsconfig.json --noEmit in packages/web-shell exit 0, 0 errors
eslint npx eslint on the 2 changed files exit 0
prettier npx prettier --check on the 2 changed files exit 0
gate liveness planted unused var + planted format break eslint exit 1 and names verifyProbeUnusedVariable; prettier exit 1, [warn] names ToolApproval.tsx; source restored byte-identical (sha256 1a501197eaa3a68e)

Witness: 04-gate-liveness.png. Both gates were proven able to fail before their green results were cited. Note prettier colourises [warn] with ANSI — a literal string match on the raw output gives a false negative, which my first harness run hit.

Merge state: main moved 204 files between the declared base 1753948e and the base tip 8003d280, but touched nothing under client/components/messages/ except EnhancedMarkdownTable.*; the merge ref is conflict-free and git diff HEAD^1..HEAD is exactly the two ToolApproval files.

Not covered

  • No real browser render. All evidence is jsdom (textContent, title attributes). How a browser paints the escaped text — and, conversely, how it painted the raw \r/bidi before — was not observed. This matches the author's own caveat.
  • Per-commit attribution. The checkout is depth 2 and shallow, so git rev-list HEAD^1..HEAD^2 returns 1 commit while the snapshot's commits array has 2 (5d7d8d32 the fix, aad10c09 a docs Prettier format). I verified the aggregate HEAD^1..HEAD diff, which git diff --name-only shows to be only the two ToolApproval files — the docs commit's content is already in the base tip, so it contributes nothing. 5d7d8d32's parent is the declared baseRefOid 1753948e, confirming the fix commit is what the aggregate diff contains.
  • No live daemon/ACP end-to-end run. The producer chain (model description/directory/file_path → core confirmation → permissionUtils → PermissionRequest → component) is argued from source and each hop was read; the rendering is measured. This reproduces the render behaviour, not a wire capture of a real model-supplied payload arriving over a socket.
  • Playwright e2e (test:e2e, test:e2e:visuals) not run — no browser launched in this container.
  • Sites D/E/F left raw. Not fixed and not tested beyond the census measurement; feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445's conditional interaction (if it merges first, its new <Markdown content={contentText}/> branch needs the same wrapper) could not be exercised — that PR is not in this tree.
  • Control-side full suite not repeated. Only the single-file A/B ran against the control; the full 412-file suite ran at head. Since head is green and the diff touches two files, a base-suite run could only have revealed a test the PR incidentally fixed.
  • Repo-wide gates outside packages/web-shell (core/cli lint, typecheck, tests) not run — the diff does not reach them.
  • \p{Cf} widening of the shared helper (the maintainer policy call the author defers) not evaluated for collateral across the helper's other 7 production call sites.

Methodology

CI verify job, node:22-bookworm container, refs/pull/13578/merge at depth 2, npm ci + npm run build already complete at head; node v22.23.3. The internal workspace link was checked before trusting any control: readlink -f node_modules/@qwen-code/qwen-code-core → /__w/qwen-code/qwen-code/packages/core, and since every mutation was applied to a file inside packages/web-shell/client and read back by vitest's own TS transform, no arm could silently load a different tree. Three harnesses drove the code, all mock-free with respect to the unit under test — real React 19 createRoot renders into real jsdom, no stub of ToolApproval or of sanitizeControlChars:

  • census.test.tsx (43 assertions, 6 test cases) mounts the real component with constructed PermissionRequest objects and locates each site structurally — .desc is reached through the aria-describedby IDREF that references it, which simultaneously proves the IDREF resolves. It prints one row per site/vector/benign-input/CRLF-case and asserts each row's expected state, so a deviation is a red rather than a silent change. Run twice: at head, and against a control build differing only by the two guards.
  • ab-sites.mjs runs that census on both arms, pairs the rows, and prints the flip table plus ab-summary.json.
  • mutation-matrix.mjs applies 9 interface-preserving mutations (each anchor asserted to match exactly once), runs the changed-file suite and — on the three arms where its expectations move — the census, then restores the source in a finally and verifies byte-identity.
  • gate-liveness.mjs plants one violation per gate, confirms it is reported and named, restores, and verifies by sha256.

Raw per-arm logs are in logs/ (M*.log, M*.census.log, census-head.log, census-base-control.log, typecheck-head.log, webshell-suite-head.log, gate-liveness.log); structured results in mutation-results.json and ab-summary.json. Images were captured with node scripts/verify-capture.mjs. The scratch census file was copied into client/components/messages/ for collection and deleted afterwards; git status --porcelain is clean.

Assertion accounting

assertions.json counts only scripted checks that executed, with no double counting:

source count
web-shell suite at head (includes the 83 ToolApproval tests) 10,916
census at head (per-row expectations) 43
A/B row comparisons head vs control 33
mutation-matrix arm expectations (M0–M7b) 9
gate-liveness rows 5
typecheck 1
total 11,007

The census figure is measured, not derived: the harness wraps every soft assertion in a counter and prints ASSERTS-EXECUTED 43 from an afterAll hook, on both the head and control arms (per-test deltas 12 / 4 / 13 / 8 / 2 / 4). Worth recording because the first instrumented run printed 26: two call sites used the multi-line expect\n.soft(...) form, which a literal expect.soft( rewrite did not wrap, so 4 + 13 = 17 assertions went uncounted. The gap was diagnosed by logging the counter at each test boundary and closed by matching expect\s*\.\s*soft\(; 43 then agreed with the independent hand derivation.

fail = 0: every red observed was an encoded expectation — the control arm's census reds, M1–M5's suite reds, and the planted gate violations. Two BAD-looking results during the round were harness bugs, not PR properties, and both were fixed and re-run: the gate-liveness prettier match (prettier colourises [warn], so a literal match on raw output false-negatives) and the counter wrapper above (it returned void, breaking the .toBe(...) chain and reddening 4 census tests until it returned the assertion object).

Flakiness gate log

rounds=5 files=1 skipped=0
file packages/web-shell/client/components/messages/ToolApproval.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/messages/ToolApproval.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/web-shell/client/components/messages/ToolApproval.test.tsx: PPPPP

verdict: pass
summary: 1 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/components/messages/ToolApproval.test.tsx: P (exit 0)

Evidence images

01-ab-head-vs-control

02-mutation-matrix

03-crlf-tradeoff-and-candidate-fix

04-gate-liveness

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

— Qwen Code · sandboxed verification

@qwen-code-review-bot qwen-code-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit 9df8789 Oct 8, 2026
190 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.

3 participants