Repository navigation
fix(web-shell): sanitise model-supplied text at the approval card's sibling render sites - #13578
Conversation
…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
|
Re-run at head 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 Problem: observed, not theoretical, and now corroborated by more than your own run. #13566 is open and labelled 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 Size: not applicable. Both source files sit under Approach: the scope still feels right, and the second commit is the one thing here that needed checking rather than assuming.
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 Both review threads are now
Site C's deferral also still holds at review time: #13445 remains open and draft, head 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 感谢贡献!上一轮的实质判断依然成立,所以本轮集中在变动部分:新 commit、你关掉的两条 review 线程、以及我此前要求的验证通道。 模板:完整 ✓ —— 各必需小节都填了,包含 Tested-on 表格,中文版是完整翻译而非摘要。「风险与范围」自上一轮起多了两条( 问题:是已观测到的,不是理论性的,而且现在的佐证已不止你自己的一次运行。#13566 处于 open,带 方向:对齐,与上一轮一致。在不可信文本抵达人类决策界面处做净化,上游走得很熟;claude-code 的 CHANGELOG 有多条同类条目,包括一条落在完全相同补救形态上的(终端控制字符「现在显示为 规模:不适用。两个源文件都在 方案:范围依然合理,而第二个 commit 是这里唯一需要核查、不能假定的东西。
上一轮核过的设计取舍依然成立,而且我是在该 head 上重新核实、不是照搬:JSX 门控与 两条 review 线程现在都是
site C 的延期在评审时点依然成立:#13445 仍是 open 且 draft,head 风险:无升级风险信号 —— 我在该 head 的文件列表上重跑了与 revert 相关的路径检查,无匹配。 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewNo blocking issues, and nothing new to report on the source: the only commit since my last pass is the docs formatting one, so What I re-checked at this head:
The two non-blocking notes from my last pass are unchanged and still non-blocking: the dropped Conventions respected: no over-abstraction, no duplicated logic, no new utility where an existing one fitted, no Test evidenceThis 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 Skipped, and therefore gaps rather than passes:
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.
What nothing has covered: a real browser painting a crafted payload. Your description says plainly that no live render was verified, and the 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 是文档格式化那个,所以 本轮在该 head 上重核的内容:
上一轮那两条非阻塞备注没有变化,也仍然非阻塞:被删掉的 约定方面无问题:没有过度抽象、没有重复逻辑、没有在既有实现可用时新造工具、没有 测试证据这是无人值守的 CI 运行,因此按门禁规则我没有构建、运行或执行任何来自本 PR 的代码。下面的证据是通过 API 读到的、该 PR 自身在被评审 commit 上的 CI 结果,外加一份明确标注来源的第三方报告。快照只取一次;我没有轮询,也没有任何检查处于进行中。
skipped 的项目,因此是缺口而不是通过: 上一轮我要求的那条沙箱验证通道现在已经跑过,并且它把行为层面的主张钉住了。
没有任何通道覆盖到的:真实浏览器绘制一个精心构造的载荷。 你的描述明确写了没有验证真实浏览器渲染, 真实场景 tmux 测试:N/A —— 无人值守 CI 运行,且门禁禁止在此执行 PR 派生代码。本地没有驱动任何东西,也没有用终端截图替代。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
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 — 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:
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 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 The reservations, plainly, so they are not buried under a green board:
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- 中文说明信心度:4/5 —— 上一轮我悬着的事项现在都用证据、而不是用断言关掉了;让它没到 5/5 的是两条点名的细项,以及「合并本 PR 并不等于关掉 #13566」这个事实。 退一步看整体。上一轮我在原则上批准,但拒绝去断言一个当时还不存在的测试结果 —— 两条 review 线程是另一个未决项,这里我想说得具体些,因为「作者说已解决」是这份工作里最廉价、最容易被接受的东西。两条线程都没有产生代码改动,而「对 finding 的零代码回应」恰恰是门禁应当慢下来的地方。所以我是对着源码、而不是对着回复去核这两件事:
关于「独立方案」这项检验,我要和上一轮一样坦白:#13566 已经异常详细地规定了做法,所以可供分歧的设计空间本就很小,我从零勾画也会落在同一处。属于作者自己的,是让测试承重而非装饰的那一切 —— 精确转义输出的断言、检查相邻命令块保持自己独立载荷的那一条、以及通过 六个月后我会感谢还是咒骂写这段代码的人?感谢。那段过度声明的注释没有了,取代它的两段说清了 helper 覆盖什么、以及门控为什么必须留在原始值上 —— 后者正是未来编辑者会凭反射改坏的那个不变量,而它被写在对方会读到的位置。轻微的咒骂仍然是被删掉的 顾虑直说,以免被一块绿板埋掉:
我批准,是因为它真的好,还是因为我说不出「不」的理由了?说「不」的理由上一轮已经提过,而每一条得到的都是经核实的修正或独立见证,而不是安慰性的表态。这正是我想看到的顺序。 结论:批准,而且是现在批准、不是推迟 —— 该 head 上没有进行中的 PR workflow run,fork- — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterFull-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
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)
|
Verdict: merge-ready — 16,950 scripted assertions executed, 0 unexpected failures. Verified head 中文摘要结论:merge-ready(可合并) — 16,950 条脚本断言执行,0 个意外失败。
Central claim and A/B proofClaim: the approval card's description line ( Method: single head worktree; the base arm is the base
Mutation matrix (each hunk load-bearing)
The new tests are not vacuous, and each pins its own site — no cross-pinning, no survivor.
|
| 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.
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:
- Main touched
docs/developers/daemon/09-event-schema.mdsince the base — with a change byte-identical to this PR's second commit (same result blob9203cefc35). The docs commit is therefore a no-op on merge. (Locally, both the base and head content of that file pass the repo-pinnedprettier --checkand 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.) - Main did not touch
ToolApproval.tsx/ToolApproval.test.tsx. The merged tree was materialized locally (local-only merge commitaa61f9c8), workspace links realpath-verified to resolve inside the merged tree, and the focused file re-runs 83/83 green.
Gates
eslinton both changed files: clean.tsc -p tsconfig.json --noEmitinpackages/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/sdkdist the package typechecks fully clean).prettier --checkon 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/titleassertions 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
ToolApprovalimporters +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.
|
@qwen-code /triage |
|
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 reportPR #13578 — deep verificationVerdict: Verified head: 中文摘要结论: A/B 结论:中心主张成立。把本 PR 新增的两处净化 guard 全部回退作为对照组,用同一套 census 探针在 head 与对照组各跑一遍:33 行对比中 8 行按预期翻转( 变异矩阵(9 个 arm):M1 同时回退两处 guard → 恰好 2 个新测试变红,断言信息与 PR 描述引用的一字不差(证明新测试非空洞);M2 只回退 Findings:① 存在第 5 处未净化的模型可控渲染点 —— 编辑 diff 预览( 未覆盖:未在真实浏览器中渲染(只有 jsdom);浅克隆(depth 2)导致 2 个提交中只有 1 个可达,按聚合 diff 验证;未跑 Playwright e2e;未跑真实 daemon/ACP 端到端(producer 链是读代码论证的,渲染行为是实测的);未跑 web-shell 之外的仓库级 lint/typecheck;未在对照组重复整套 web-shell 测试。 Central claim
Secondary claims: (a) ordinary text is unaffected ( Proven. A/B table (witness:
Totals: 33 paired rows, 8 flipped (all intended), 25 unchanged. Census suite: head Note on U+2028/U+2029: the census executes and logs all 13 vectors on both arms, but the pairing map in Producer traces confirm the payload really is model-written (not synthesised by me):
Mutation matrix / vacuityWitness:
M1's failure messages reproduce the PR description's quoted output byte-for-byte: 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 ( Corrections to the descriptionNot code requests — the description states two things that do not hold in this environment:
Findings1. Minor — a fifth raw model-supplied site exists that neither the PR nor #13566 enumeratesThe description records four sites (
Neither file contains a sanitizer ( Measured: 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 producerThe description records that
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 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 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 ( 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:
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 Gates
Witness: Merge state: main moved 204 files between the declared base Not covered
MethodologyCI verify job,
Raw per-arm logs are in Assertion accounting
The census figure is measured, not derived: the harness wraps every soft assertion in a counter and prints
Flakiness gate logEvidence imagesHarness scripts and raw logs are in the workflow run artifacts (7-day retention). — Qwen Code · sandboxed verification |
qwen-code-review-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship. ✅









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 owngetDescription()— for shell tools that is the verbatim command), and the exec-warnings block next to the command (whose text interpolates the model's rawdirectoryargument viabuildOutsideWorkspaceWarning). Both now pass through the samesanitizeControlCharshelper the command block already uses, in their body text and in theirtitletooltip. The render gates stay on the raw values, so thearia-describedbyIDREF cannot dangle when a description is absent.The command block's comment also claimed more than the code delivers — that every producer leaves
commandverbatim 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_REGEXmatches 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
commandwith no sanitizer at all — but the sibling sites mean a crafteddescriptionordirectorycan still visibly reorder text one element away from the now-escaped command, in its tooltip, and in thearia-describedbytarget a screen reader reads.Reviewer Test Plan
How to verify
Two jsdom component tests were added to
ToolApproval.test.tsxand were confirmed to fail against unfixedmainbefore any source change (this is the reproduction):Commands and results (base
main=1753948e3e, fix commit =5d7d8d3244):vitest run client/components/messages/ToolApproval.test.tsx --config vitest.config.ts --coverage.enabled=false(run inpackages/web-shell, tests only, source unfixed)tsc -p tsconfig.json --noEmit(run inpackages/web-shell)client/components/messages/— see Risk & Scopeeslinton the two changed filesprettier --check/--writeon the two changed filesThe 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\nand\tin the description survive unescaped (the helper's own pin for that istoolFormatting.test.ts:51, cited rather than duplicated), and that every id inaria-describedbyresolves 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):After (this branch):
Tested on
Environment (optional)
Local
pnpmworkspace,vitestjsdom environment. No browser, no daemon, no live Web Shell session was started.Risk & Scope
\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 —sanitizeControlCharsis a no-op outside its character class, and\n/\tare deliberately preserved so multi-line descriptions and warnings still render as before. One caveat that claim did not carry, now recorded:\ris insideCONTROL_CHARS_REGEX(\x0b-\x1f,toolFormatting.ts:117-118) and:128-129maps it to the two printable characters\andr, so CRLF-terminated text at these two newly sanitised sites paints a visible\rat each line end instead of a clean break. Bare-LF text is unaffected. The warnings site is an unlikely CRLF source becausebuildOutsideWorkspaceWarninginterpolates a filesystem path; the realistic trigger is a model-written multi-linedescriptionwith 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.textContentand thetitleattribute; how a real browser paints the escaped text was not observed.showsContent<pre>that renderscontentText— is deliberately deferred, not dropped. Draft PR feat: render plan approval as markdown and capture plans as Todos under Session Workflow #13445 (headcb53b83edb, unchanged since 2026-10-05) rewrites exactly those lines into anisExitPlanApproval ? <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,sanitizeControlCharsmust also wrap its new<Markdown content={contentText}/>branch, or the third site stays raw.isGoal,ToolApproval.tsx:655-660passes rawgoalObjectiveandcontentTexttoGoalApprovalContent, which imports onlyuseState,useI18n,Tabsandstylesand 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. Becausearia-describedbyincludescommandIdwheneverisGoal(:632), andcommandIdis the.goalBodydiv (GoalApprovalContent.tsx:76), reordered text is also what a screen reader speaks. A payload has room:PROPOSE_GOAL_OBJECTIVE_MAX_CHARACTERSis 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 becauseGoalApprovalContent.tsxis 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>andshowsContent.CONTROL_CHARS_REGEXand thecharCodeAt(0)replacement intoolFormatting.tsare 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.packages/sdk-typescript/distin the local worktree (missing@qwen-code/sdk/daemonexports); zero are inclient/components/messages/. A clean-install typecheck was not run locally — CI is the authority here.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上失败(这就是复现):命令与结果(基线
main=1753948e3e,修复提交 =5d7d8d3244):packages/web-shell下vitest run client/components/messages/ToolApproval.test.tsx --config vitest.config.ts --coverage.enabled=false(只加测试、源码未修)packages/web-shell下tsc -p tsconfig.json --noEmitclient/components/messages/下 0 个 —— 见「风险与范围」eslintprettier --check/--write#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,只加了测试):修复后(本分支):
测试平台
环境(可选)
本地
pnpmworkspace,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(headcb53b83edb,自 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}/>分支,否则第三处仍是原样渲染。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三处。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 既不修掉它们、也不掩盖它们。packages/sdk-typescript/dist(缺少@qwen-code/sdk/daemon的导出);client/components/messages/下为 0 个。本地没有跑干净安装后的 typecheck —— 这一项以 CI 为准。关联 Issue
Refs #13566 —— 刻意不用
Fixes:第三处与第 3 项仍未完成,issue 需要保持开启。