You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
web-shell: approval card leaves sibling model-supplied text unsanitised, and the command block comment overclaims coverage #13566
Deferred from #13549 (merged as 9ac9766), which escaped the managed approval args preview and sanitised the rendered command block. Two review threads on that PR (qwen-code-review-bot, R1-1 / R1-2) were closed by merge rather than by a fix, and nothing else tracks them. All three items below are verified against main at 17347ee.
This is not a regression. Before #13549 the command block rendered command with no sanitizer at all, so that PR strictly narrowed the hole. What is left is (a) a comment that claims more than the code delivers and (b) a bounded set of sibling render sites that are still raw.
1. The command block's comment overclaims coverage
packages/web-shell/client/components/messages/ToolApproval.tsx, just above const commandDisplay = sanitizeControlChars(command ?? ''):
// The command block renders `rawInput.command`, which every producer leaves
// verbatim — so this is the last choke point before an approver reads what
// they are about to authorize. Neutralise invisible controls here (C0/ANSI,
// C1, and the bidi embedding/isolate controls) so a crafted command cannot
// display one string while authorizing different bytes. ...
Neither absolute claim holds:
sanitizeControlChars matches /[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g (toolFormatting.ts:117-118) — C0/C1 plus the bidi embeddings/isolates only. It does not match \p{Cf} or U+2028/U+2029, so a command of cat notes.txt followed by astral TAG characters (U+E0020-U+E007F), or by U+200B / U+2060 / U+FEFF / U+00AD / U+061C / U+200E-U+200F / U+2028-U+2029, renders pixel-identically to the clean command in both the <pre> body and its title, while the shell receives the extra bytes. That is exactly "display one string, authorize different bytes".
It is not the last choke point: see item 2.
Smallest fix is to narrow the comment to the command string and to the vectors the helper actually neutralises (C0/ANSI, C1, bidi embeddings/isolates — the ones that visibly reorder a command). Widening coverage instead is a separate policy decision, see item 3.
2. Sibling model-supplied render sites in the same dialog are unsanitised
.desc — <div className={styles.desc} id={descId} title={descriptionText}>{descriptionText}</div>. getDescriptionText returns rawInput?.description (a real model-supplied run_shell_command parameter) and falls back to request.title. On the daemon/ACP path the emitter builds the title from invocation.getDescription(), and ShellTool.getDescription() returns the command verbatim — so web-shell: the managed approval dialog's primary content path (tool.args) is not bidi/control-character escaped #13517's own payload reaches .desc and reorders visibly one element above the now-sanitised command block, in its tooltip, and in the aria-describedby target a screen reader reads.
The showsContent<pre> renders contentText raw for non-managed producers.
Note .desc is referenced by aria-describedby, and the rule stated in the adjacent comment is to only reference ids whose elements actually render — so any sanitising there must keep the whole element gated, not just its text.
3. sanitizeControlChars misses the zero-width / separator set
Widening the shared helper touches every file that already uses it, and it is a policy choice rather than a range search: of the silent-append set, ZWJ, ZWNJ and U+00AD are also legitimate joiners, so escaping \p{Cf} visibly changes legitimate text. Two constraints if it is widened:
The replacement is built from ch.charCodeAt(0) (toolFormatting.ts:121-131). Adding \p{Cf} with the u flag without fixing that drops the low surrogate of astral matches — measured, a TAG-appended command becomes "cat notes.txt\udb40\udb40..." instead of "cat notes.txt\udb40\udc72...".
\n and \t must stay unescaped: toolFormatting.test.ts:51 pins expect(sanitizeControlChars('a\tb\nc')).toBe('a\tb\nc') and ToolApproval.test.tsx pins the multi-line .join('\n') expectation.
If only the render site should be extended, compose the two existing helpers locally instead of widening the shared one — escapePreviewText (exported from adapters/transcriptAdapter.ts:76) covers \p{Cf} + U+2028/U+2029, sanitizeControlChars covers C0/C1, both emit ASCII, and composition is order-safe and idempotent. For .desc and the warnings <pre> use sanitizeControlChars, not escapePreviewText: the latter's class starts at U+007F and would let ESC/BEL through.
Deferred from #13549 (merged as 9ac9766), which escaped the managed approval args preview and sanitised the rendered command block. Two review threads on that PR (
qwen-code-review-bot, R1-1 / R1-2) were closed by merge rather than by a fix, and nothing else tracks them. All three items below are verified againstmainat 17347ee.This is not a regression. Before #13549 the command block rendered
commandwith no sanitizer at all, so that PR strictly narrowed the hole. What is left is (a) a comment that claims more than the code delivers and (b) a bounded set of sibling render sites that are still raw.1. The command block's comment overclaims coverage
packages/web-shell/client/components/messages/ToolApproval.tsx, just aboveconst commandDisplay = sanitizeControlChars(command ?? ''):Neither absolute claim holds:
sanitizeControlCharsmatches/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g(toolFormatting.ts:117-118) — C0/C1 plus the bidi embeddings/isolates only. It does not match\p{Cf}or U+2028/U+2029, so acommandofcat notes.txtfollowed by astral TAG characters (U+E0020-U+E007F), or by U+200B / U+2060 / U+FEFF / U+00AD / U+061C / U+200E-U+200F / U+2028-U+2029, renders pixel-identically to the clean command in both the<pre>body and itstitle, while the shell receives the extra bytes. That is exactly "display one string, authorize different bytes".Smallest fix is to narrow the comment to the command string and to the vectors the helper actually neutralises (C0/ANSI, C1, bidi embeddings/isolates — the ones that visibly reorder a command). Widening coverage instead is a separate policy decision, see item 3.
2. Sibling model-supplied render sites in the same dialog are unsanitised
.desc—<div className={styles.desc} id={descId} title={descriptionText}>{descriptionText}</div>.getDescriptionTextreturnsrawInput?.description(a real model-suppliedrun_shell_commandparameter) and falls back torequest.title. On the daemon/ACP path the emitter builds the title frominvocation.getDescription(), andShellTool.getDescription()returns the command verbatim — so web-shell: the managed approval dialog's primary content path (tool.args) is not bidi/control-character escaped #13517's own payload reaches.descand reorders visibly one element above the now-sanitised command block, in its tooltip, and in thearia-describedbytarget a screen reader reads.<pre>—title={execWarningsText}and body, inside the sameisExec && commandfragment fix(web-shell): escape control characters in the managed approval args preview #13549 edited.buildOutsideWorkspaceWarning(directory)interpolates the model's rawdirectoryargument andgetPermissionContentpasses it through verbatim.showsContent<pre>renderscontentTextraw for non-managed producers.Note
.descis referenced byaria-describedby, and the rule stated in the adjacent comment is to only reference ids whose elements actually render — so any sanitising there must keep the whole element gated, not just its text.3.
sanitizeControlCharsmisses the zero-width / separator setWidening the shared helper touches every file that already uses it, and it is a policy choice rather than a range search: of the silent-append set, ZWJ, ZWNJ and U+00AD are also legitimate joiners, so escaping
\p{Cf}visibly changes legitimate text. Two constraints if it is widened:ch.charCodeAt(0)(toolFormatting.ts:121-131). Adding\p{Cf}with theuflag without fixing that drops the low surrogate of astral matches — measured, a TAG-appended command becomes"cat notes.txt\udb40\udb40..."instead of"cat notes.txt\udb40\udc72...".\nand\tmust stay unescaped:toolFormatting.test.ts:51pinsexpect(sanitizeControlChars('a\tb\nc')).toBe('a\tb\nc')andToolApproval.test.tsxpins the multi-line.join('\n')expectation.If only the render site should be extended, compose the two existing helpers locally instead of widening the shared one —
escapePreviewText(exported fromadapters/transcriptAdapter.ts:76) covers\p{Cf}+ U+2028/U+2029,sanitizeControlCharscovers C0/C1, both emit ASCII, and composition is order-safe and idempotent. For.descand the warnings<pre>usesanitizeControlChars, notescapePreviewText: the latter's class starts at U+007F and would let ESC/BEL through.中文说明
从 #13549(已合并为 9ac9766)延期出来的三项。该 PR 转义了托管审批卡片的参数预览,并净化了渲染出的命令块;那两条 review 线程(
qwen-code-review-botR1-1 / R1-2)是被合并关掉的,不是被修掉的,目前没有别的条目在跟踪。以下三项都在main17347ee 上核过。这不是回归:#13549 之前命令块渲染的是完全未处理的
command,所以那个 PR 严格收窄了缺口。剩下的是(a)一段声称超出代码实际覆盖范围的注释,以及(b)一组有界的、仍然原样渲染的同级展示点。sanitizeControlChars的字符类只有 C0/C1 加 bidi 嵌入/隔离符,不含\p{Cf}与 U+2028/U+2029,所以「隐形追加」载荷(TAG U+E0020-U+E007F、U+200B、U+2060、U+FEFF、U+00AD 等)在<pre>与其title中渲染出的像素与干净命令完全一致,而 shell 收到多出来的字节——正是注释声称不可能的「显示一串、放行另一串」;同时它也不是「最后一道收口点」。最小修法是把注释收窄到命令字符串与该 helper 实际中和的向量。.desc(rawInput?.description或回退到request.title,daemon 路径上 title 来自ShellTool.getDescription(),即原样命令)、exec 警告<pre>(buildOutsideWorkspaceWarning(directory)插值模型原始directory)、以及showsContent分支的<pre>。注意.desc被aria-describedby引用,净化时必须保持整个元素的渲染门控。sanitizeControlChars缺零宽/分隔符集合:放宽共享 helper 会牵动所有调用方,且本质是策略选择(ZWJ、ZWNJ、U+00AD 同时是合法连字符)。若放宽,必须同一次改掉基于charCodeAt(0)的替换(否则星平面匹配会丢低代理项),并保持\n/\t不转义(有测试钉住)。若只想扩渲染点,在本地组合escapePreviewText(sanitizeControlChars(...))即可;.desc与警告两处要用sanitizeControlChars,因为escapePreviewText的字符类从 U+007F 起,会放过 ESC/BEL。