Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 235 additions & 1 deletion .github/workflows/qwen-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3325,6 +3325,240 @@ jobs:
printf '%s%s\n' "$esc" "$truncated"
printf '</code></pre>\n\n</details>\n\n'
}
# Render report.md as MARKDOWN instead of an escaped <pre> dump.
# The report is a curated bilingual document (tables, nested
# <details>, headings); the pre/code embedding displayed it as a
# wall of raw source (#8140's comment was the exhibit). The section
# is wrapped in a collapsed <details> so it still costs one line in
# the conversation but renders as real markdown when opened.
#
# A node sanitizer (not sed) does the escaping so it can tell code
# regions apart from prose. CommonMark does NOT decode entities in
# code spans or fenced blocks — they render literally there — so
# escaping & < > @ unconditionally showed the reader &amp;&amp;,
# &lt;T&gt;, &#64;pkg inside the very commands, generic types, and
# scoped-package paths a verification report is read to copy (the
# #8140 symptom, relocated into code). The security floor now rests
# on four line-independent guarantees:
# 1. in PROSE every < is escaped, then only the structural tags
# the report uses as raw HTML (details/summary) are un-escaped
# back to live tags — no other tag can form in ordinary
# prose, so <img>/<script>/onerror never render there. A
# line-level inHtml flag tracks raw-HTML blocks opened by
# details/summary (at any indent, so list-nested folds
# enter the state too): inside one, neither fence lines
# NOR code spans are code — CommonMark/GitHub reads both
# as literal text in a raw-HTML block — so the sanitizer
# prose-escapes the whole line instead of splitting it
# through proseLine, closing the divergence where GitHub
# saw live HTML the sanitizer treated as inert code.
# & and > are left alone in prose (a decoded entity
# yields text, never markup, and an unescaped > cannot
# open a tag once < is escaped), which keeps &&, ->, and
# blockquotes readable. Code-span parity is a paragraph
# property in CommonMark but a line property here, so a prose
# line carrying an unmatched backtick run makes the rest of the
# paragraph's code/prose split unknowable; the scanner fails
# closed and prose-escapes every line until the next blank line
# (over-escaping is the safe direction — a multi-line code span
# shows its entities literally, inert not live). Outside HTML
# blocks, inside code spans/fences < & @ are left alone: <img>
# and @mentions are inert under a code/pre ancestor, and
# escaping them there only mangles the rendered text;
# 2. the comment-open token is broken EVERYWHERE, prose and code
# alike (<!-- becomes an escaped no-op, the autofix-proven
# neutralizer). The break must stay global: the upsert greps
# the RAW body for the running marker and a fence prints
# verbatim, so a forged marker inside a fence must not survive;
# 3. in prose @ gains a zero-width space (@&#8203;) — renders
# identically, never fires a mention (GitHub decodes &#64;
# back to @ before the mention filter runs, so the entity
# alone was inert; the ZWSP breaks the mention token). A
# mention cannot fire under a code/pre ancestor, so code
# keeps a literal @;
# 4. <details> folds are balanced over PROSE only — a </details>
# quoted in a code span/fence is inert text and is no longer
# counted — surplus closers are dropped (they would otherwise
# close the wrapping fold early) and unclosed opens are closed
# at the end, so a malformed report can neither swallow the
# footer nor escape its wrapper. The flat scanner diverges
# from GitHub's container-aware parser whenever a fence it
# holds open cannot exist in GitHub's view, so two signals
# exit non-zero (emit_report then degrades to the escaped
# fallback rather than guessing): a fence still open at EOF
# (GitHub closes a list-nested fence at the container's end,
# not at EOF), and a non-blank line that dedents below the
# fence opener's indent while it is open — the container
# boundary moved, so GitHub already closed the fence and a
# balancing closer later in the file would otherwise let the
# scanner pass unescaped prose through escCode.
# Accepted tradeoff (named, not accidental): rendering promotes the
# report from inert text to parsed markdown, so [links](…) and
# ![images](…) now render live where emit_block showed them literal.
# report.md is agent output from a sandbox that ran PR code; images
# are camo-proxied (mostly noise) and a phishing link under the bot
# identity is the residual surface. That surface exists only while
# the four guarantees hold, so the fail-closed bails above are what
# keep deferring link defusing defensible; defusing link targets is
# a deliberate follow-up, not done here. Two safe-but-ugly display
# costs are named, not bugs: a code span that opens on one line and
# closes on the next is prose-escaped (the reader sees &lt;T&gt;
# inside it — the #8140 symptom relocated but inert), and escCode
# breaks <!-- to &lt;!\-\- which renders LITERALLY in a fence
# (entities do not decode there), so a fenced comment example shows
# as &lt;!\-\-.
# An OVERSIZED report falls back to emit_block wholesale (cut
# markdown dangles fences/folds), as does any sanitizer failure.
emit_report() {
local file="$1" max="$2"
[ -n "$file" ] && [ -f "$file" ] || return 0
if [ "$(wc -c < "$file")" -gt "$max" ]; then
echo "::warning::emit_report fell back to escaped embedding (report exceeds size cap) for $file" >&2
emit_block 'Verification report (report.md, truncated)' "$file" "$max"
return 0
fi
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
local san_file
san_file="$(mktemp)"
# Node is present on every runner that runs this job (emit_block
# already shells out to it for the UTF-8-safe cut). The script
# rides inside a single-quoted shell string, so it uses double
# quotes throughout and carries no single quotes of its own.
local node_status=0
node -e '
const fs = require("node:fs");
const text = fs.readFileSync(process.argv[1], "utf8").replace(/[\u0000\u0001]/g, "");
function escProse(s) {
// Allowlisted tags are stashed behind a \u0001 sentinel (stripped
// from input) BEFORE the < pass and restored from it, so a literal
// &lt;details> the author typed stays escaped text instead of being
// promoted back to a live tag by the un-escape step.
return s
.replace(/<(\/?)(details|summary)>/g, "\u0001$1$2>")
.replace(/</g, "&lt;")
.replace(/\u0001(\/?)(details|summary)>/g, "<$1$2>")
.replace(/&lt;!--/g, "&lt;!\\-\\-")
.replace(/@/g, "@&#8203;");
}
function escCode(s) {
return s.replace(/<!--/g, "&lt;!\\-\\-");
}
let depth = 0;
function balance(s) {
return s.replace(/<\/?details>/g, function (t) {
if (t === "<details>") { depth += 1; return t; }
if (depth > 0) { depth -= 1; return t; }
return "";
});
}
function proseLine(line) {
let out = "";
let buf = "";
let i = 0;
const flush = function () {
if (buf) { out += balance(escProse(buf)); buf = ""; }
};
while (i < line.length) {
if (line[i] !== "`") { buf += line[i]; i += 1; continue; }
// A backslash-escaped backtick does not OPEN a code span (the
// CommonMark escape rule consumes it first) but still CLOSES one
// — an asymmetry a line-scoped scanner cannot model. Fail closed
// like an unmatched run so the whole line is prose-escaped.
let bs = 0, b = i - 1;
while (b >= 0 && line[b] === "\\") { bs += 1; b -= 1; }
if (bs % 2 === 1) { unmatched = true; buf += line[i]; i += 1; continue; }
let j = i;
while (j < line.length && line[j] === "`") j += 1;
const run = j - i;
let k = j, cs = -1, ce = -1;
while (k < line.length) {
if (line[k] === "`") {
let m = k;
while (m < line.length && line[m] === "`") m += 1;
if (m - k === run) { cs = k; ce = m; break; }
k = m;
} else k += 1;
}
if (cs === -1) { unmatched = true; buf += line.slice(i, j); i = j; }
else { flush(); out += escCode(line.slice(i, ce)); i = ce; }
}
flush();
return out;
}
const lines = text.split("\n");
const out = [];
let inFence = false, fc = "", fl = 0, fi = 0, inHtml = false;
let spanUnknown = false, unmatched = false;
for (const line of lines) {
if (!inFence) {
const blank = /^\s*$/.test(line);
if (inHtml && blank) inHtml = false;
if (blank) spanUnknown = false;
const m = inHtml ? null : line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
if (m && !(m[1][0] === "`" && m[2].indexOf("`") !== -1)) {
inFence = true; fc = m[1][0]; fl = m[1].length; fi = line.match(/^ */)[0].length;
out.push(escCode(line));
continue;
}
let rendered;
if (inHtml || spanUnknown) {
rendered = balance(escProse(line));
} else {
unmatched = false;
rendered = proseLine(line);
if (unmatched) { spanUnknown = true; rendered = balance(escProse(line)); }
Comment on lines +3506 to +3508

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.

[Suggestion] depth is double-counted when a prose line contains both a <details> tag and an unmatched backtick run, causing a surplus </details> closer at EOF. — Failure scenario: a report line like <details> + unclosed backtick is first processed by proseLine, whose flush() calls balance(escProse(buf)) and increments depth for the <details>. Because the run is unmatched, the line is re-rendered via balance(escProse(line)), counting the same <details> again. At EOF, repeat(depth) appends one extra closer, closing the wrapping fold one line early — trailing content lands outside the collapsed section (visual glitch, no security impact).

Suggested change
unmatched = false;
rendered = proseLine(line);
if (unmatched) { spanUnknown = true; rendered = balance(escProse(line)); }
const savedDepth = depth;
unmatched = false;
rendered = proseLine(line);
if (unmatched) { depth = savedDepth; spanUnknown = true; rendered = balance(escProse(line)); }
中文说明

当一行散文同时包含 <details> 标签和未匹配的反引号运行时,depth 会被重复计数,导致 EOF 处多出 </details> 闭合标签。触发场景:报告行如 <details> + 未闭合反引号,先经 proseLine 处理,其 flush() 调用 balance(escProse(buf)) 并为 <details> 递增 depth。由于运行未匹配,该行经 balance(escProse(line)) 重新渲染,再次计数同一 <details>。EOF 处 repeat(depth) 多追加一个闭合标签,使包装折叠提前一行关闭——尾部内容落在折叠区外(视觉问题,无安全影响)。

— qwen3.8-max-preview via Qwen Code /review

}
if (/^\s*<\/?(details|summary)\b/.test(rendered)) inHtml = true;
out.push(rendered);
} else {
if (/\S/.test(line) && line.match(/^ */)[0].length < fi) process.exit(3);
const cm = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/);
if (cm && cm[1][0] === fc && cm[1].length >= fl) inFence = false;
Comment on lines +3514 to +3515

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.

[Suggestion] The sanitizer's code-region parser is exercised only through backtick fences and single-backtick code spans; its tilde-fence branch and its multi-backtick span run-length matching have no test coverage, so a regression in either branch ships green. — Failure scenario: every fixture uses fences and single-backtick spans, so two mutations survive the suite (verified by probe): deleting `cm[1][0] === fc &&` here (any fence then closes any fence — a `~~~` fence containing a lone line followed by @everyone is misclassified, the mention treated as prose &#64;everyone instead of inert code), and changing m - k === run to === 1 at line 3428 (a two-backtick span wrapping <img src=x> is escaped to &lt;img src=x> instead of being left literal/inert). The sanitizer code itself is correct; this is purely a coverage gap on two parser branches. Fix: add two fixtures to scripts/tests/qwen-triage-workflow.test.js — a ~~~ tilde fence containing a ``` line plus an @mention/`` (assert the payload stays inert code, i.e. a literal `@`/`` survives in the raw output), and a prose line with a two-backtick span wrapping ``/`@` (assert it is left literal like the other code spans).

中文说明

净化器的代码区域解析器只通过反引号围栏和单反引号代码跨度被测试;其波浪号围栏分支与多反引号跨度的 run 长度匹配没有测试覆盖,因此这两个分支的回归会绿灯通过。触发场景:所有 fixture 都用 围栏和单反引号跨度,因此两处突变能在测试套件中存活(已用 probe 验证):删除此处的 `cm[1][0] === fc &&`(任意围栏都能闭合任意围栏——一个包含单独 行、后跟 @everyone~~~ 围栏会被误判,提及被当作散文 &#64;everyone 而非惰性代码);将第 3428 行的 m - k === run 改为 === 1(一个包裹 <img src=x> 的双反引号跨度会被转义成 &lt;img src=x> 而非保持字面/惰性)。净化器代码本身正确;这纯粹是两个解析器分支的覆盖缺口。修复:向 scripts/tests/qwen-triage-workflow.test.js 增加两个 fixture——一个包含 ``` 行加 @提及/`` 的 `~~~` 波浪号围栏(断言载荷保持惰性代码,即原始输出中存活字面 `@`/``),以及一行包含包裹 ``/`@` 的双反引号跨度的散文(断言其像其他代码跨度一样保持字面)。

— qwen3.8-max-preview via Qwen Code /review

out.push(escCode(line));
}
}
if (inFence) process.exit(3);

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.

[Critical] The EOF-open-fence guard (process.exit(3)) does not catch container-axis divergence when a closing fence appears after the divergent content. A fence opened inside a list item (indented), followed by column-0 prose content and then a closing fence, causes the flat scanner to stay inFence and apply escCode (no @ ZWSP, no < escaping) to what CommonMark/GitHub renders as live prose. At EOF inFence is false, so exit(3) does not fire and the unescaped content ships.

Failure scenario: a report containing - step one:\n\n ```bash\n npm test\n\nBack at top level: @everyone <img src=x onerror=alert(1)>\n\n```\nafter — GitHub closes the fence at the container boundary; the flat scanner stays inFence until the closing ``` and applies escCode. The `@everyone` fires a mention from the bot identity; the `` tag renders live in the comment. Probe-verified: the sanitizer outputs `@everyone` without ZWSP and `` unescaped on this input.

Track the fence opener's indent and treat a non-blank line at a lower indent as a divergence signal (exit non-zero to trigger the escaped fallback):

Suggested change
if (inFence) process.exit(3);
if (inFence) {
if (/\S/.test(line) && line.match(/^ */)[0].length < fi) process.exit(3);
}

(where fi is the fence opener's indent, captured at const fi = ... when the fence opens.)

中文说明

EOF 处开放围栏的守卫(process.exit(3))无法捕获容器轴分歧:当闭合围栏出现在分歧内容之后时,在列表项内(缩进)打开的围栏,后跟列 0 的散文内容和闭合围栏,会使平面扫描器保持 inFence 并对 CommonMark/GitHub 渲染为活跃散文的内容应用 escCode(无 @ ZWSP、无 < 转义)。EOF 时 inFencefalse,因此 exit(3) 不触发,未转义内容被发出。

触发场景:报告包含 - step one:\n\n ```bash\n npm test\n\nBack at top level: @everyone <img src=x onerror=alert(1)>\n\n```\nafter — GitHub 在容器边界关闭围栏;平面扫描器保持 inFence 直到闭合 ```,并应用 escCode。`@everyone` 以 bot 身份触发提及;`` 标签在评论中活跃渲染。已经过探针验证:净化器对此输入输出无 ZWSP 的 `@everyone` 和未转义的 ``。

修复:跟踪围栏打开者的缩进,将缩进更低的非空行视为分歧信号(以非零退出触发转义回退)。

— qwen3.8-max-preview via Qwen Code /review

let result = out.join("\n");
if (depth > 0) {
if (result && !result.endsWith("\n")) result += "\n";
result += "</details>\n".repeat(depth);
}
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
process.stdout.write(result);
' "$file" > "$san_file" || node_status=$?
if [ "$node_status" -ne 0 ]; then
if [ "$node_status" -eq 3 ]; then
echo "::warning::emit_report fell back to escaped embedding (report ended inside an open code fence) for $file" >&2
Comment on lines +3528 to +3529

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.

[Suggestion] Exit code 3 conflates two distinct sanitizer bailouts (EOF-open-fence and dedent-below-fence-indent) under a warning that describes only the EOF case. — Failure scenario: a list-nested fence whose content dedents below the opener indent triggers the dedent guard, but the log says "report ended inside an open code fence" — an oncall engineer would look for an unclosed fence at EOF, not find one, and waste time or dismiss the warning.

Use a distinct exit code (e.g. process.exit(4)) for the dedent guard with its own warning message: "emit_report fell back to escaped embedding (fence/container divergence: content dedented below fence indent)".

中文说明

退出码 3 将两种不同的净化器退出(EOF 处开放围栏和缩进低于围栏打开者)合并为一条仅描述 EOF 情况的警告。触发场景:列表嵌套围栏的内容缩进低于打开者时触发缩进守卫,但日志显示"报告在开放代码围栏内结束"——值班工程师会寻找 EOF 处未闭合的围栏,找不到后浪费时间或忽略该警告。建议对缩进守卫使用独立退出码(如 process.exit(4))并配以专属警告消息。

— qwen3.8-max-preview via Qwen Code /review

else
echo "::warning::emit_report fell back to escaped embedding (sanitize failed) for $file" >&2
fi
rm -f "$san_file"
emit_block 'Verification report (report.md, escaped fallback)' "$file" "$max"
return 0
fi
# Wrap in a collapsed <details> (one-line footprint, markdown when
# opened) and bound the WHOLE emitted section — wrapper, balanced
# report, appended closers — so the cap is a true bound on what
# lands in the comment. The old normal path skipped the header
# bytes the deficit branch did budget; folding the wrapper into
# the measured output removes that asymmetry and the separate
# fold-closer gate (closers now land in san_file before this gate).
local out_file
out_file="$(mktemp)"
{
printf '<details>\n<summary>Verification report</summary>\n\n'
cat "$san_file"
printf '\n</details>\n'
} > "$out_file"
rm -f "$san_file"
if [ "$(wc -c < "$out_file")" -gt "$max" ]; then
echo "::warning::emit_report fell back to escaped embedding (sanitized output exceeds size cap) for $file" >&2
rm -f "$out_file"
emit_block 'Verification report (report.md, truncated)' "$file" "$max"
return 0
fi
cat "$out_file"
rm -f "$out_file"
printf '\n'
}

# Host the agent's evidence images (if any) on a per-PR branch
# (pr-assets/<N>-verify, matching hand-run convention) and build
Expand Down Expand Up @@ -3695,7 +3929,7 @@ jobs:
if [ -n "${MISSING_REPORT_NOTE:-}" ]; then
printf '%s\n\n' "$MISSING_REPORT_NOTE"
fi
emit_block 'Verification report (report.md)' "$REPORT" 45000
emit_report "$REPORT" 45000
if [ -n "$EVIDENCE_SECTION" ]; then
printf '%s' "$EVIDENCE_SECTION"
fi
Expand Down
Loading
Loading