Skip to content

fix(core): redact error text in usage-statistics telemetry sink - #11649

Merged
yiliang114 merged 8 commits into
mainfrom
fix/11198-telemetry-error-redaction
Sep 12, 2026
Merged

fix(core): redact error text in usage-statistics telemetry sink#11649
yiliang114 merged 8 commits into
mainfrom
fix/11198-telemetry-error-redaction

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Usage-statistics telemetry forwards raw tool and provider error text to the RUM endpoint. This PR replaces the known free-form error fields (error_message, error_excerpt, and top-level message) with a fixed ***REDACTED*** marker at the single enqueue boundary before an event can leave the process. (The hook error property is removed at its producer logHookCallEvent rather than marker-replaced, so hook_call#*.properties.error is no longer collected at all.)

Why it's needed

A failed shell command can contain a credential in a URL, an authorization header, a flag, an environment assignment, truncated output, or an unanticipated form. Parsing that untrusted text with a growing regex denylist cannot prove that the credential is gone and can itself become a CPU denial-of-service path. Replacing the whole error string is smaller and fails closed.

Reviewer Test Plan

How to verify

Run the focused unit suite:

cd packages/core && npx vitest run src/telemetry/qwen-logger/qwen-logger.test.ts

The regression drives the public enqueue boundary with all currently known error-text properties plus a top-level exception message. It asserts that every raw value is replaced while a non-text classification field is unchanged.

Evidence (Before & After)

Before: an event whose error text contained git clone https://x-access-token:ghs_testsecret123@github.com/... was enqueued with the complete command and token.

After: the entire error-text field is enqueued as ***REDACTED***; no input substring is retained or parsed.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Unit tests only — npx vitest run inside packages/core.

Risk & Scope

The implementation now replaces whole error fields, superseding the earlier shape-based masking proposal recorded on #11198. The privacy/diagnostics tradeoff still needs maintainer confirmation; this PR must not automatically close that issue before the decision is recorded.

  • Main risk or tradeoff: usage-statistics events retain the fact that an error occurred and their structured classifications, but no longer retain free-form error diagnostics. This is deliberate because the channel is default-on and any partial text policy can leak credentials.
  • Not validated / out of scope: snapshots and stack are separate structured fields and are not changed here; any sensitive producer-to-sink path through them should be handled separately.
  • Breaking changes / migration notes: none for the public API; backend users must rely on structured error fields instead of raw text.

Linked Issues

Refs #11198

中文说明

本 PR 做了什么

usage-statistics 遥测会把工具和 provider 的原始错误文本发送到 RUM endpoint。本 PR 在事件离开进程前的唯一入队边界,将当前已知的自由文本错误字段(error_messageerror_excerpt 和顶层 message)统一替换为固定的 ***REDACTED*** 标记。(hook 的 error 属性在其生产者 logHookCallEvent 处被移除、而非替换为标记,因此 hook_call#*.properties.error 不再被采集。)

为什么需要它

失败的 shell 命令可能以 URL、Authorization header、命令参数、环境变量、截断输出或任意其他形式携带凭据。不断扩展正则黑名单无法证明凭据已被完整清除,还会为不可信错误文本引入 CPU 拒绝服务风险。整段替换更小,也能失败关闭。

Reviewer 测试计划

如何验证

运行聚焦的单元测试:

cd packages/core && npx vitest run src/telemetry/qwen-logger/qwen-logger.test.ts

回归测试直接走公开的入队边界,覆盖当前所有已知错误文本属性和顶层异常消息;它断言原始值全部被替换,同时非文本分类字段保持不变。

证据(Before & After)

Before:错误文本包含 git clone https://x-access-token:ghs_testsecret123@github.com/... 的事件,会带着完整命令和 token 入队。

After:整段错误文本被替换为 ***REDACTED***,不保留也不解析任何输入片段。

测试环境

操作系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

仅单元测试——在 packages/core 内运行 npx vitest run

风险与范围

当前实现替换整个错误字段,已不同于 #11198 先前记录的按内容模式遮罩方案。隐私与诊断信息之间的取舍仍需维护者确认,因此本 PR 不应在决策记录之前自动关闭该 issue。

  • 主要风险或取舍:usage-statistics 事件仍保留“发生错误”的事实和结构化分类,但不再保留自由文本诊断信息。该取舍是有意的,因为此通道默认开启,任何保留部分文本的策略都可能泄漏凭据。
  • 未验证 / 超出范围:snapshotsstack 是独立的结构化字段,本 PR 不修改;如果它们存在敏感的 producer-to-sink 路径,应单独处理。
  • 破坏性变更 / 迁移说明:公共 API 无变化;后端分析应依赖结构化错误字段,而不是原始错误文本。

关联 Issue

Refs #11198

@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 11, 2026
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

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

@yiliang114
yiliang114 force-pushed the fix/11198-telemetry-error-redaction branch from 887b430 to 7df466e Compare September 11, 2026 10:05
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Re-run on the same head again — still 41714d9f, no commits since. The gate inputs are unchanged, so this pass is short on the gate and long on one correction: the round-2 /verify report landed at 23:33, after my last full pass was written, and it overturns one of my own judgements. I had called the 10-character flag-value floor "a policy choice, not a bug". Measured across four build arms, it reopens 9 short-secret shapes — including --password, a vector this PR claims for itself in its own description. I have re-derived the mechanism from the diff by hand (Stage 2), and it holds.

Template looks good ✓ — all nine headings from .github/pull_request_template.md are present.

Stage 1-pre: #11198 is OPEN (priority/P1, category/security, status/ready-for-human), so there is no merged-fix subsumption path to consider. Nothing to close here.

Problem: observed, not theoretical, and re-verified in the base tree this pass rather than carried forward. grep -n "redact\|REDACTED" over packages/core/src/telemetry/qwen-logger/qwen-logger.ts on main returns zero hits — there is no redaction of any kind on the path today. The producers are all there: :553 (error_message from tool calls), :675 + :682 (api_error, top-level and property), :695, :859, :887, :1102 (hook error, log-prompts gated), :708 (a retry message with no secret). Destination is the RUM host at :79, gated only by getUsageStatisticsEnabled(), default true. A failing git clone https://x-access-token:ghs_...@github.com/... uploads the token today, by default, with no opt-in. Not a hypothesis.

Direction: telemetry is one of the areas this gate escalates rather than decides, so the escalation stands — @zjunothing is assigned and is the sole core-telemetry owner in .github/issue-owners.json (paths: packages/core/src/telemetry/). Redacting at the sink is the right layer; I have no quarrel with it. What I cannot settle from the diff is the policy — and the floor question is now part of that policy, with a measurement attached rather than an opinion.

Size: core path → Stage 0 applies. Production 110 lines (qwen-logger.ts), test 252 lines (qwen-logger.test.ts), generated/schema 0. Title is fix, not refactor, so no Tier-1 hard block; 110 production lines is far under 500, so no size escalation and no large-PR advisory. The author holds admin, so the external-PR tier was never binding here either. Two files, no unrelated edits, no drive-by refactor, no formatting churn — scope is genuinely minimal for the stated goal.

Approach: matches the proposal I wrote before opening the diff — same choke point, and it reuses redactUrlCredentials / REDACTED_URL_CREDENTIAL (extension/redaction.ts) and stripAnsiAndControl (utils/textUtils.ts) instead of writing a fifth URL redactor. The choke point is verified, not assumed: in the base tree this.events.push(event) at :191 is the only admission write, :188 shift and :1162 pop only evict, and :1158 unshift replays events already redacted on the way in.

One question that still stands, neither new nor a blocker: the description claims "any error-text key — including ones added later — is scrubbed". The implementation is a static allowlist (ERROR_TEXT_PROPERTY_KEYS = ['error_message', 'error_excerpt', 'error']) plus top-level message. A future sibling key is covered only if someone remembers to extend that array — exactly the #10916 error_excerpt regression shape this PR cites as motivation. #10916 is still open and error_excerpt has zero producers in packages/*/src. Either soften the claim or pin it with a guard test that fails when a new error-text-ish property key shows up unlisted.

Risk: no Stage 1e high-risk path match — both changed files are telemetry. Input-side blowup is bounded: truncateToolOutput caps the tool path at 25,000 chars (config.ts:797, user-configurable) and getErrorMessage routes every branch through the 1,000-char truncateStringifiedErrorMessage (errors.ts:13); with the {0,64} quantifier bounds, /verify measured the worst case at 0.7 ms at the 25k ceiling versus 121.9 ms on the unbounded revision. The residual risk is now on the coverage side, and it is measured rather than argued — see Stage 2.

Moving on to code review. 🔍

中文说明

又是同一个 head 上的重跑 —— 仍是 41714d9f,没有新 commit。gate 的输入没变,所以这一轮 gate 部分写得短,而把篇幅留给一处更正:第二轮 /verify 报告在 23:33 落地,晚于我上一轮完整评审的写作时间,而它推翻了我自己的一个判断。 我此前把 flag 取值的 10 字符下称为"策略选择,不是 bug"。经过四个构建臂的实测,它重新打开了 9 种短取值密钥形态 —— 其中包括 --password,而这个向量正是本 PR 描述里为自己认领的。我已经从 diff 手工复核了机制(见 Stage 2),成立。

模板完整 ✓ —— .github/pull_request_template.md 的九个标题都在。

Stage 1-pre:#11198 处于 OPENpriority/P1category/securitystatus/ready-for-human),因此不存在"已被合并修复覆盖"的路径,这里没有任何可关闭的东西。

问题: 已观测到的,不是理论性的,而且这一轮是在 base 代码里重新核实的,不是照搬上一轮。在 main 上对 packages/core/src/telemetry/qwen-logger/qwen-logger.ts 执行 grep -n "redact\|REDACTED" 返回零命中 —— 今天这条路径上完全没有任何脱敏。生产者全都在::553(工具调用的 error_message)、:675 + :682(api_error,顶层属性)、:695:859:887:1102(hook 的 error,受 log-prompts 开关控制)、:708(一条不含密钥的重试消息)。目的地是 :79 的 RUM 域名,唯一开关是 getUsageStatisticsEnabled(),默认 true。一条失败的 git clone https://x-access-token:ghs_...@github.com/... 今天就会把 token 上传出去,默认开启、无需 opt-in。不是假设。

方向: 遥测属于本 gate 只转交、不自行判定的领域,所以转交继续有效 —— @zjunothing 已被指派,且是 .github/issue-owners.jsoncore-telemetry 的唯一 owner(paths: packages/core/src/telemetry/)。在 sink 侧收口这个层次选择我完全认同。我无法从 diff 判定的是策略 —— 而下限问题现在也属于这个策略,并且附带的是实测数据而不是观点。

规模: 触及核心路径 → 适用 Stage 0。生产代码 110 行(qwen-logger.ts),测试 252 行,生成/schema 0 行。标题是 fix 而非 refactor,不触发 Tier-1 硬阻断;110 行远低于 500,既不触发基于规模的转交也不触发大 PR 提示。作者持 admin,因此面向外部 PR 的分级在这里本来也不是约束。两个文件,无无关改动、无顺手重构、无格式化噪音 —— 相对目标而言范围确实最小。

方案: 与我在打开 diff 之前写下的方案一致 —— 同一个收口点,并且复用了 redactUrlCredentials / REDACTED_URL_CREDENTIALextension/redaction.ts)和 stripAnsiAndControlutils/textUtils.ts),而不是再写第五个 URL 脱敏器。收口点是核实过的,不是假设的:在 base 代码里 :191this.events.push(event) 是唯一的准入写入,:188shift:1162pop 只做淘汰,:1158unshift 回放的是入队时已脱敏的事件。

有一个问题依然存在,不是新问题也不是阻断项:描述里说"任何错误文本字段 —— 包括以后新增的 —— 都会被清除"。实现是一个静态 allowlist(ERROR_TEXT_PROPERTY_KEYS = ['error_message', 'error_excerpt', 'error'])加顶层 message。以后新增的同族字段只有在有人记得往数组里加时才被覆盖 —— 而这正是本 PR 引为动机的 #10916 error_excerpt 回归形状。#10916 目前仍 open,且 error_excerptpackages/*/src生产者。要么弱化这个说法,要么加一个守卫测试:当新的错误文本属性键未登记时让它失败。

风险: Stage 1e 高风险路径无命中 —— 两个改动文件都是遥测。输入侧的爆炸是有界的:truncateToolOutput 把工具路径限制在 25,000 字符(config.ts:797,可由用户配置),getErrorMessage 的每个分支都经过 1,000 字符的 truncateStringifiedErrorMessageerrors.ts:13);加上 {0,64} 量词上界后,/verify 在 25k 上限处实测最坏情况为 0.7 ms,而无界版本是 121.9 ms。残余风险现在在覆盖面一侧,而且是实测出来的,不是推论 —— 见 Stage 2。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Reviewed statically at 41714d9f. Per this gate's rules I built, ran, and executed nothing from this PR — every test statement below is either the PR's own CI read through the API, or the round-2 /verify report read as evidence and then hand-checked against the diff. Anything I could not check is labelled as such.

Code review

I wrote my own proposal before opening the diff (sink-side choke point; reuse redactUrlCredentials + stripAnsiAndControl; allowlist of high-confidence shapes; bound every quantifier; normalise ANSI and continuations before matching; assert secret-absence rather than marker-absence). The PR lands on the same design. The five round-2 Criticals from /review are genuinely closed at this head, and I re-derived each from the diff and the base tree rather than reading the replies:

  • Line-continuation leak (R1-5) — closed. redactTelemetryError joins \ + newline + leading whitespace away first (text.replace(/\\\r?\n[ \t]*/g, '')), and SECRET_VALUE's unquoted alternative refuses a leading backslash, so a lone trailing \ is never consumed as a complete value.
  • The inverted test (R2-1) — closed in substance. 12 not.toContain secret-absence assertions now carry the shapes that used to leak. The two remaining exact-equality tests run on inputs containing no secret at all, so they can no longer excuse a leak.
  • Newline vs. URL-credential ordering (R1-7) — closed, and load-bearing. stripAnsiAndControl is applied per line with a split/rejoin on \n. That matters more than it looks: CONTROL_CHARS_RE in utils/textUtils.ts:11 does delete \n and \t, so applying it to the whole string would destroy the delimiters every whitespace-based pattern depends on.
  • Quote-awareness (R2-2) — closed for the reported shapes. Quoted alternatives come first in both SECRET_VALUE and SECRET_FLAG_VALUE.
  • Exact-spelling gap (R1-4) — closed. The keyword sits inside {0,64}?…{0,64}, so --github-api-key and --_authToken are caught, not only literal --token / --password.

Completeness claims verified rather than trusted, in the base tree this pass:

  • The choke point is the only one. this.events.push(event) at :191 is the sole admission write; :188 shift and :1162 pop only evict; :1158 unshift replays already-redacted events. Every raw-error-text producer is reachable by the allowlist: :553, :675 + :682, :695, :859, :887, :1102.
  • main has no redaction at all. grep -n "redact\|REDACTED" on the base qwen-logger.ts0 hits. So the "strictly smaller leak surface than main" claim is not a comparison between two partial states; it is against zero.
  • In-place mutation does not alias a caller's object. createRumEvent shallow-spreads the partial event, and every producer passes a freshly-built inline properties literal; the hook path's local properties const (:1092) is not retained after the call.
  • It fails closed. redactEventErrorText is the first statement inside enqueueLogEvent's try. If it throws, the catch logs and the event is never pushed.
  • Blanket top-level message redaction is safe today. Only three producers set it: :675 and :695 (both error text) and :708 (Content retry failed after N attempts — no keyword, no secret, passes through untouched).

⚠️ The one finding that matters this pass: a measured coverage regression, and my prior call on it was wrong

Last pass I listed the 10-character flag-value floor under "policy choices, not bugs". I was wrong to file it that way, and the reason is now measurable rather than arguable. From the diff:

SECRET_VALUE      = (?:"[^"]+"|'[^']+'|[^\s"'\`\\][^\s]*)        <- Authorization path: no floor
SECRET_FLAG_VALUE = (?:"[^"]{10,}"|'[^']{10,}'|[^\s"'\`\\][^\s]{9,})   <- flag path: 10-char floor

Hand-derived against the flag pattern (--[A-Za-z0-9_-]{0,64}?(?:token|password|secret|credential|key)[A-Za-z0-9_-]{0,64}(?:[=:]|\s+)(?:\\\s*)?):

input value length outcome at head why
mysql --password hunter2 7 leaks verbatim unquoted alt needs 1 + {9,} = ≥10
--token abc123 6 leaks verbatim same
--api-key sk-12345 8 leaks verbatim same
--password="pw123" 5 inside quotes leaks verbatim quoted alt needs {10,}; unquoted alt refuses a leading "
--aws-access-key AKIA_testsecret123 18 redacted clears the floor

Neither of the other two patterns rescues these: ENV_SECRET_PATTERN requires key=value (these are space-separated), and AUTHORIZATION_PATTERN requires the literal authorization. So the shapes fall through entirely.

This matters because --password and --token are named in the PR description as covered vectors ("secret-bearing flags (--token, --_authToken, --password)"). A reader of that description would believe a short password is scrubbed. It is not.

Two baselines, stated separately, because they give different answers and conflating them is how this got mis-filed:

  • Against main (the merge base): not a regression. Base has zero redaction; head closes all 8 vectors the description names for realistic long credentials. /verify measured real-credential leaks 41/41 at base → 23/41 at head. Merging this is still a strict improvement.
  • Against the branch's own earlier revisions: a regression, and a measured one. /verify built 566d6a74 and 31827e9d as separate arms. Both redacted 12/12 short-flag shapes and sat at 10/41 real-credential leaks. Head redacts 3/12 and sits at 23/41. Both earlier revisions used the unbounded SECRET_VALUE for flags; the last commit swapped in SECRET_FLAG_VALUE to stop over-matching --max-tokens 8192, and the floor overshot.

The fix is measured, not conjectural. /verify built a fifth arm with the floor lowered to 5 ({4,}) and kept the {0,64} bounds: 52/52 assertions pass, real-credential leaks 23/41 → 14/41, all 9 reopened shapes re-closed, all 8 description vectors stay redacted, benign text byte-identical 12/12, and the delta's own goal survives — --max-tokens 8192 (4 chars) and max_tokens=8192 still pass through untouched. That is the point: floor 5 dominates floor 10 on every axis measured. It is not a trade-off between coverage and over-matching, because 8192 is only 4 characters. One line.

The suite cannot see it — which is why it survived to head

This is the part I would treat as blocking-adjacent on a P1 security fix. Every positive redaction assertion in the new tests uses a value at or above the floor:

  • aws --aws-access-key AKIA_testsecret123 (18 chars)
  • npm publish --_authToken npm_testsecret123 (18 chars)
  • --password P@ss'w0rd123 — the only short-looking one, and the value is actually 12 chars, asserted with not.toContain('w0rd123')

There is no test anywhere in the 25 new cases with a flag secret under 10 characters. /verify confirmed it empirically: mutant M2 (floor 10 → floor 5) survived green, 69/69. The suite passes identically with either floor, so CI cannot catch this class of change in either direction — that is the "green and worthless" shape, and it is why a regression could land in the final commit of a security PR without anything turning red.

The cheap repair is a fixture pair that pins the boundary from both sides: a short secret that must redact (--password hunter2) and a short non-secret that must not (--max-tokens 8192). /verify wrote one and measured it RED at head / GREEN at floor 5.

What still stands (all non-blocking)

  1. The claim outruns the implementation. "Including ones added later" is not true of a static three-key allowlist, and no guard test was added. This is the difference between closing the class and closing today's instances — and closing the class is the PR's entire argument for the sink layer. Relatedly, error_excerpt in that allowlist has zero producers in packages/*/src (repo-wide census: 1 hit, the declaration itself; mutant M8 survived green). Forward-looking for the still-open fix(core): halt turns on repeated identical tool errors #10916 — harmless, but by this repo's simplicity rule it is speculative code and shouldn't be described as covering something live.
  2. One narrow fail-open. A secret flag value whose first quoted run is shorter than the floor and contains an escaped same-type quote — --password="ab \"cd\" efghij" — matches none of the three value alternatives, so it passes through. Unusual shape; the Authorization path has no floor so it is unaffected.
  3. Quotes are consumed, not preserved. Measured 4/4 shapes unbalanced, wider than round 1 reported: --_authToken "npm_…" and --password="…" lose both quotes (2→0). Predates the delta (from 31827e9d), and the PR's own test asserts the unbalanced string as expected, so it is deliberate. Diagnostic impact only — the secret is genuinely gone.
  4. JSON-quoted header names ({"authorization":"Bearer x"}, {"GITHUB_TOKEN":"…"}) don't match, because a " sits between name and separator. Reported in an earlier round, declined by the author; part of the 10-shape residual (F1) that base also leaks — incompleteness, not a regression.
  5. Bare-newline-split URL credentials (https://user:\npass@host) are no longer caught, since newlines are now deliberately preserved. Shell puts the command on one line, so this isn't the issue's vector — recording it as the visible cost of the (correct) newline fix.
  6. A redundant but harmless second pass. After SECRET_FLAG_PATTERN writes --token=***REDACTED***, ENV_SECRET_PATTERN re-matches it and replaces it with the identical string. Idempotent — just a wasted pass worth knowing about if the pattern set grows.
  7. {0,64} bounds are load-bearing but unpinned. /verify measured them at 121.9 ms → 0.7 ms at the 25k production ceiling, and mutants M4/M5/M9 all survived green. The bound is real protection with no test on it.

Nothing here is a correctness or security regression against main. Measured against base, the leak surface is strictly smaller. The finding above is about how much smaller it could be for one line, and about a suite that cannot tell the difference.

CI test evidence

This is the PR's own CI on the reviewed commit, fetched through the API just now. I ran nothing myself. Required Linux checks are green; the picture is unchanged from my last pass.

The caveat is coverage, not correctness: Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x), and Integration Tests (CLI, No Sandbox) are all skipped on this head, so the 25 new redaction tests have executed on Linux only. For a pure string-transformation function with no platform-dependent API that is a low-risk gap — but it is a gap, and I am not going to describe Linux-only as cross-platform. The description marks Windows ⚠️ and Linux ⚠️; /verify likewise drove an LF-only corpus and did not exercise CRLF end to end (the continuation joiner does handle \\\r?\n).

review-pr is in_progress; it is a pull_request_target bot job and is correctly excluded from the PR's own CI count (PENDING = 0).

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

Sandboxed verification — already run, and it is what changed this pass

Unlike my last pass, I no longer have to name /verify as the remedy for an unsettled claim: it ran, twice, and round 2 is substantive. Advisory evidence, not a review or a CI check — but it is A/B measurement against real built arms, which is strictly more than static review can give, and I have hand-checked its central finding against the diff rather than taking it on faith (the regex table above is mine, not quoted).

What it settled:

  • The redaction is load-bearing. Removing it moves the wire payload from clean to leaking; the four enqueue carriers (properties.error_message, top-level message on both exception and resource events, properties.error) are clean at head and leaking at base. The choke point is genuinely single.
  • The floor regression is real and is one line to fix (measured fix5 arm above).
  • The suite cannot see the floor (M2 survived 69/69) — the mutation result that makes the missing boundary fixture a concrete ask rather than a nit.
  • The scary guesses do not hold. stack bypass disproved (no stack: assignment anywhere in packages/core/src/telemetry/, non-test — which also means the PR's declared stack exclusion excludes nothing); retry-path bypass disproved (push post-redaction, unshift re-queues redacted events); in-place mutation corrupting a caller disproved (42 internal call sites, external callers only in tests); ReDoS disproved and now hardened by the bounds.

What it did not cover, and I am not going to paper over it:

  • Trial merge into current main. main was unreachable in the verify container, so conflict-freedom is unconfirmed and nothing was re-measured on a merged tree. Worth a maintainer re-check before landing, since this branch has sat a day.
  • Full packages/core suite, npm run lint, repo-level npm run typecheck, and all integration tests — only the focused qwen-logger.test.ts (69/69 at head) and tsc --noEmit for packages/core (exit 0, proven live by planting a type error) were run.
  • No live network flush: flushIfNeeded/flushToRum were hard-blocked and counted (63 blocked attempts per arm). The oracle is createRumPayload(), which flushToRum() serializes verbatim — the wire body was reconstructed, not captured off a socket.
  • The OTLP path (attributes['error.message'] in loggers.ts, plus session-tracing.ts / daemon-tracing.ts spans) is a separate, opt-in sink this PR neither touches nor claims. Still unredacted, still divergent from this policy, still with no tracking issue.

A re-run of @qwen-code /verify after the floor fix is what would settle the last gap — specifically that the 9 reopened shapes close and that --max-tokens 8192 still passes through. The author holds admin, so both /verify and /tmux are available; /verify is the one that matches this claim.

中文说明

41714d9f 上做静态审查。按本 gate 的规则,我没有构建、运行或执行本 PR 的任何代码 —— 下面所有关于测试的陈述,要么是通过 API 读取的本 PR 自己的 CI,要么是把第二轮 /verify 报告当作证据、再手工对照 diff 复核过的结论。凡是我无法核实的都明确标注。

代码审查。 我在打开 diff 之前先写了自己的方案(sink 侧收口;复用 redactUrlCredentials + stripAnsiAndControl;高置信形状 allowlist;给每个量词加上界;在匹配之前归一化 ANSI 与续行;断言"密钥不存在"而不是"标记不存在")。PR 落在同一个设计上。/review 第二轮那 5 条 Critical 在当前 head 上确实关闭了,我逐条从 diff 和 base 代码重新推导:续行泄漏(R1-5)已关闭,redactTelemetryError 先把 \ + 换行 + 前导空白合并掉;反向断言的测试(R2-1)实质上已关闭,现有 12 条 not.toContain 密钥不存在断言;换行与 URL 凭据的顺序(R1-7)已关闭且是承重的,stripAnsiAndControl 逐行应用,因为 CONTROL_CHARS_RE 确实会删掉 \n;引号感知(R2-2)就已报告形状关闭;精确拼写缺口(R1-4)已关闭,关键词位于 {0,64}?…{0,64} 之间。

完整性声明这一轮是在 base 代码里核实的,不是采信::191this.events.push(event) 是唯一准入写入,:188 shift:1162 pop 只淘汰,:1158 unshift 回放已脱敏事件;main 上完全没有脱敏grep redact 零命中),所以"泄漏面严格小于 main"不是两个部分状态之间的比较,而是与零比较;就地修改不会污染调用方对象;失败时 fail-closed(脱敏是 try 里的第一条语句);对顶层 message 的无差别脱敏今天是安全的(只有三个生产者,其中 :708 不含密钥)。

⚠️ 这一轮真正重要的发现:一处实测出来的覆盖面回退,而我上一轮对它的归类是错的。 上一轮我把 flag 取值的 10 字符下限放在"策略选择,不是 bug"里。这个归类是错的,而且现在它可测、不再只是可辩。从 diff 看:SECRET_VALUE(Authorization 路径)没有下限,而 SECRET_FLAG_VALUE 有 10 字符下限({9,} 加首字符)。手工推导:mysql --password hunter2(7 字符)、--token abc123(6)、--api-key sk-12345(8)、--password="pw123"(引号内 5)全部原样泄漏;而 --aws-access-key AKIA_testsecret123(18)被脱敏。另外两个模式也救不了它们:ENV_SECRET_PATTERN 要求 key=value(这些是空格分隔),AUTHORIZATION_PATTERN 要求字面的 authorization。之所以重要:--password--tokenPR 描述里点名认领的覆盖向量,读到那句描述的人会以为短口令会被清除 —— 实际不会。

两个基线要分开说,因为它们给出不同答案,而把它们混在一起正是我上一轮归错类的原因:

  • 相对 main(合并基线):不是回退。 base 完全没有脱敏;head 对真实长凭据关闭了描述点名的全部 8 个向量。/verify 实测真实凭据泄漏 base 41/41 → head 23/41。合并它仍然是严格改善。
  • 相对本分支自己更早的版本:是回退,而且是实测的。 /verify566d6a7431827e9d 各自构建成独立对照臂,两版都脱敏 12/12 个短取值 flag 形态、泄漏数都是 10/41;head 只脱敏 3/12、泄漏 23/41。两个早期版本对 flag 用的是无下限的 SECRET_VALUE,最后一个 commit 为了不再误伤 --max-tokens 8192 换成了 SECRET_FLAG_VALUE,而下限 overshoot 了。

修复是实测过的,不是猜想。 /verify 构建了第五个臂:把下限降到 5({4,})、保留 {0,64} 边界,结果 52/52 断言通过,真实凭据泄漏 23/41 → 14/41,9 个被重新打开的形态全部重新关闭,8 个描述向量保持脱敏,无害文本逐字节一致 12/12并且 delta 自己的目标依然成立 —— --max-tokens 8192(4 字符)和 max_tokens=8192 仍原样通过。这正是关键:下限 5 在每一个实测维度上都优于下限 10。它不是"覆盖面 vs 过度匹配"的取舍,因为 8192 只有 4 个字符。一行代码。

测试套件看不见它 —— 这也是它能活到 head 的原因。 在一个 P1 安全修复上,我会把这一条视为接近阻断。新增测试里每一条正向脱敏断言用的取值都在下限之上:AKIA_testsecret123(18)、npm_testsecret123(18),唯一看着短的 --password P@ss'w0rd123 实际是 12 字符、且用 not.toContain('w0rd123') 断言。25 个新用例里没有任何一个使用 10 字符以下的 flag 密钥。 /verify 实测确认:变异体 M2(下限 10 → 5)全绿存活,69/69。两种下限下套件结果完全相同,所以 CI 在任何一个方向上都抓不到这类改动 —— 这就是"绿了但没用"的形状,也是一处回退能落在安全 PR 的最后一个 commit 上而没有任何东西变红的原因。便宜的补救是一对钉住边界两侧的 fixture:一个必须脱敏的短密钥(--password hunter2)和一个必须保留的短非密钥(--max-tokens 8192)。/verify 写了一个并实测:head 上 RED、下限 5 上 GREEN。

仍然存在(全部非阻断): 1. 说法超出实现 —— "包括以后新增的"对静态三键 allowlist 不成立,且没有守卫测试;error_excerpt 零生产者(全仓库 1 处命中即声明本身,变异体 M8 全绿存活),是为仍然 open 的 #10916 前瞻准备的推测性代码。2. 一处狭窄 fail-open —— --password="ab \"cd\" efghij"。3. 引号被吞而非保留,实测 4/4 形态不配对,flag 形态两个引号全丢(2→0),范围比第一轮报告的更宽;早于 delta,且 PR 自己的测试把不配对字符串断言为期望值,属有意为之,只影响诊断可读性。4. JSON 引号形式的头名不匹配,属 base 也泄漏的 10 形态残差(F1),是不完整而非回退。5. 被裸换行拆分的 URL 凭据不再被捕获,是(正确的)换行修复的可见代价。6. 一次冗余但无害的重复处理(幂等)。7. {0,64} 边界是承重的但没有测试钉住 —— 实测 121.9 ms → 0.7 ms,而变异体 M4/M5/M9 全部存活。

以上没有一条是相对 main 的正确性或安全性回归。真正的问题是:一行代码就能让它变小多少,以及一个分辨不出差别的测试套件

CI 测试证据。 以上是刚刚通过 API 读取的、本 PR 在被审 commit 上自己的 CI,我本人没有运行任何东西。必需的 Linux 检查是绿的,与我上一轮一致。需要留意的是覆盖面而非正确性:Test (macos-latest, Node 22.x)Test (windows-latest, Node 22.x)Integration Tests (CLI, No Sandbox) 在这个 head 上全部 skipped,所以 25 个新脱敏测试只在 Linux 上执行过。对不依赖平台 API 的纯字符串变换来说这是低风险缺口 —— 但它确实是缺口,我不会把"仅 Linux"说成"跨平台"。描述里 Windows 与 Linux 都标 ⚠️/verify 同样只跑了 LF 语料,没有端到端验证 CRLF(续行合并确实处理了 \\\r?\n)。review-pr 处于 in_progress,是 pull_request_target 机器人任务,已正确排除在计数外(PENDING = 0)。

沙箱验证 —— 已经跑过,而且它正是改变这一轮结论的东西。 与上一轮不同,我不必再把 /verify 作为某个未决主张的补救手段点名:它跑了两次,第二轮是实质性的。它只是评审证据,不构成评审或 CI 检查 —— 但它是针对真实构建臂的 A/B 实测,严格强于静态审查能给出的东西,而我对它的核心发现是手工对照 diff 复核过的(上面那张正则表是我推导的,不是引用的)。

它解决的:脱敏是承重的(移除后 wire payload 由干净变泄漏;四个入队载体在 head 上干净、在 base 上泄漏,收口点确实唯一);下限回退真实存在且一行可修;套件看不见下限(M2 存活 69/69);几个更吓人的猜测不成立 —— stack 旁路(packages/core/src/telemetry/ 非测试代码中无任何 stack: 赋值,这同时意味着 PR 声明的 stack 排除项其实排除了空集)、重试路径旁路、就地修改污染调用方、ReDoS(且被新边界加固)。

没有覆盖、我不会掩盖的:与当前 main 的试合并(容器内 main 不可达,因此冲突与否未确认,也没有在合并后的树上重测 —— 这个分支已经放了一天,落地前值得维护者复核);packages/core 全量套件、npm run lint、仓库级 npm run typecheck 和所有集成测试(只跑了聚焦的 qwen-logger.test.ts,head 上 69/69,以及 packages/coretsc --noEmit,exit 0,并通过植入类型错误证明其有效);没有真实网络上报(flushIfNeeded/flushToRum 被硬阻断并计数,每臂 63 次;oracle 是 createRumPayload(),wire body 是重建的而非抓包);OTLP 路径(loggers.tsattributes['error.message'],以及 session-tracing.ts / daemon-tracing.ts 的 span)是本 PR 既不触及也不认领的另一条 opt-in 上报路径 —— 仍未脱敏、策略仍不一致、仍没有跟踪 issue。

修完下限后再跑一次 @qwen-code /verify 就能解决最后一个缺口 —— 具体是确认那 9 个被重新打开的形态关闭、且 --max-tokens 8192 仍原样通过。作者持 admin/verify/tmux 都可用;与这个声明匹配的是 /verify

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

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the design is right and the five round-2 Criticals are genuinely closed, but this head under-redacts a vector the PR claims for itself (--password), its own suite cannot detect the difference, and telemetry policy is a maintainer call this gate escalates rather than makes.

Stepping back. My independent proposal and this PR converge on the same design, and I looked for a materially simpler path and did not find one — the obvious alternative (fingerprint the error text instead of redacting shapes) is not a simplification, it is a different policy that trades away all diagnostic value, and #11198 names exactly that as the open decision. The code is 110 production lines: one function, four patterns, one mutation helper at a choke point I re-verified in the base tree this pass (this.events.push at :191 is the only admission write). It is not trying too hard, and the comments explain why rather than narrating what, which is what regex-heavy security code needs. Six months from now I would thank the author for putting this at the sink instead of at sixty call sites.

What changed since my last pass, and why the verdict reasoning is different even though the verdict is not. The round-2 /verify report landed at 23:33, after that pass was written. My defer review at 23:56 said the blocker was "ownership, not quality". That is no longer accurate, and I would rather correct it than let it stand as my latest word on this PR. There is now a quality finding with a measurement behind it: the final commit swapped the flag path from the unbounded SECRET_VALUE to SECRET_FLAG_VALUE with a 10-character floor, which reopened 9 short-secret shapes — --password hunter2, --token abc123, --api-key sk-12345, --password="pw123". I hand-derived each from the diff rather than quoting the report. Last pass I filed this floor under "policy choices, not bugs"; that was wrong, because floor 5 was then measured to dominate floor 10 on every axis — 9/9 reopened shapes re-closed, 8/8 description vectors still redacted, benign text byte-identical 12/12, and --max-tokens 8192 still passing through, since 8192 is 4 characters. It is not a trade-off. It is one line.

Two things keep this from being a clean approve, and one thing keeps it from being a hard block:

  • The suite cannot see the floor. Every positive redaction assertion uses a value ≥10 chars; the one short-looking fixture (--password P@ss'w0rd123) is 12 chars. Mutant M2 (floor 10 → 5) survived green 69/69. On a P1 credential fix, a boundary that CI cannot distinguish in either direction is how a coverage regression lands in the final commit with nothing turning red. The repair is a two-case fixture pair pinning both sides of the boundary.
  • Telemetry is an area this gate escalates rather than decides, and Usage-statistics telemetry uploads raw tool-error text (including shell command lines) to RUM without redaction #11198 carries status/ready-for-human while naming the redaction policy as the actual work. That caps this at 3/5 regardless of code quality. For accuracy: the author holds admin, so Stage 0's core-module tier was never the binding constraint — 110 production lines under a fix title would not have triggered it anyway.
  • Against main this is still a strict improvement, and I am not going to pretend otherwise. Base has zero redaction (grepped: 0 hits); head closes all 8 named vectors for realistic long credentials and moves measured real-credential leaks from 41/41 to 23/41. The floor regression is a regression against the branch's own earlier revision, not against what ships today. So the honest framing is "one line from being clearly mergeable", not "broken".

Am I approving because I ran out of reasons to say no? No. Am I blocking because I found a reason to? Also no — and that distinction is the verdict. Defer: not approving, and not submitting a second CHANGES_REQUESTED. The bot account already has two standing CHANGES_REQUESTED reviews (7df466e0, 566d6a74), reviewDecision is CHANGES_REQUESTED, and mergeStateStatus is BLOCKED. The PR cannot merge as things stand, so a third review from the same account adds no gate — only noise. This gate's duplicate rule says exactly that: if a CHANGES_REQUESTED from the bot already exists, skip re-submitting. I am recording the new finding as a COMMENTED review pinned to 41714d9f instead, for one reason: my 23:56 defer review is pinned to the same commit and asserts "ownership, not quality", and GitHub reviews cannot be edited. Leaving an inaccurate standing statement from this account on a P1 security PR is worse than one more entry in the review list.

One consequence worth flagging to whoever picks this up, because it cuts the other way: both standing CHANGES_REQUESTED reviews are pinned to commits whose named Criticals are all now closed, so they are legitimately dismissable as stale. If they are dismissed, this PR becomes mergeable with the floor regression still in it. That is the one scenario where my not stacking a review could hurt, so I am naming it rather than leaving it implicit — the boundary fixture and the floor decision should be settled before anything is dismissed.

Mechanics, recorded so the numbers aren't taken on faith: PENDING is 0 — the only event == pull_request run on this head (Qwen Code CI) completed success, and the still-running review-pr is a pull_request_target bot job correctly excluded from the count. Nothing is waiting on CI, and I have deliberately left no approve-on-green marker: that marker is an approval with a precondition, and my verdict is not approve. The approval guardrail evaluates to ok (not cross-repository, title is fix not refactor), so the fork-refactor rule is not what blocks approval here.

On volume, since the gate asks: the author has 48 open PRs, including a second telemetry-privacy PR the same day (#11670, fix(telemetry): gate request_text/response_text on logPrompts) and the still-open #10916 that this PR's error_excerpt allowlist entry waits on. I evaluated this one on its own diff. But the volume is a reason for the maintainer to settle the redaction policy once across #11649, #11670 and #10916 rather than per-PR — the scarce resource here is maintainer attention, not review cycles.

⏸️ Deferring to @zjunothing — already assigned, and the sole owner of core-telemetry in .github/issue-owners.json (paths: packages/core/src/telemetry/). For transparency on the mechanism: that resolver is label-driven and this PR carries only review/self-reported, so it returned nothing again this run; I am naming the existing assignee from the policy file, not guessing a login. Three questions I cannot answer from the diff, and the merge call is yours:

Also for the record: a stage=rerun-summary comment from 23:02 states the bot has "neither a verdict nor a deferral" on this head. That was true when written and is stale now — the 23:56 COMMENTED review is a deferral pinned to 41714d9f, and this pass adds a second. Ignore that comment; it is a workflow artifact, not a judgement.

@yiliang114 — the architecture is right, and the last two commits closed real defects rather than papering over them. One line before merge, in this order:

  1. Lower the flag floor to 5 (SECRET_FLAG_VALUE: {10,}{5,}, {9,}{4,}), keeping the {0,64} bounds. Measured: 9/9 reopened shapes re-closed, benign text byte-identical 12/12, --max-tokens 8192 and max_tokens=8192 still untouched.
  2. Add the boundary fixture pair so CI can tell the floors apart — --password hunter2 must redact, --max-tokens 8192 must not. Without it, item 1 can silently regress again, exactly as it just did.
  3. Reconcile "including ones added later" with the static allowlist — soften the claim or add a guard test that fails when a new error-text property key appears unlisted.
  4. Get macOS / Windows / CLI-integration to actually execute so the suite isn't Linux-only.

Items 5–7 in Stage 2 (quote consumption, the escaped-quote fail-open, the unpinned {0,64} bounds) are judgement calls you can take or leave; the policy question above is the maintainer's, not mine and not yours. @qwen-code /verify after items 1–2 would close the last gap — that the reopened shapes actually close and the benign ones actually survive.

中文说明

信心度:3/5 —— 设计是对的,第二轮那 5 条 Critical 确实关闭了,但这个 head 对 PR 自己认领的向量(--password)脱敏不足,它自带的测试套件分辨不出差别,而遥测策略属于本 gate 只转交、不自行判定的维护者决定。

退一步看整体。我自己的独立方案和这个 PR 收敛到同一个设计,我也认真找过是否有明显更简的路径,没有找到 —— 那个显而易见的替代方案(对错误文本做指纹化而不是按形状脱敏)不是简化,而是另一种策略,它会把全部诊断价值换掉,而 #11198 正是把这个决定列为待决问题。生产代码 110 行:一个函数、四个正则、一个放在收口点上的修改辅助函数,而这一轮我在 base 代码里重新核实了收口点(:191this.events.push 是唯一准入写入)。它没有过度用力,注释解释的是为什么而不是复述做了什么 —— 这正是正则密集的安全代码需要的。六个月后我会感谢作者把这件事放在 sink 侧而不是六十个调用点。

自我上一轮以来变了什么,以及为什么结论没变但结论的理由变了。 第二轮 /verify 报告在 23:33 落地,晚于上一轮的写作时间。我 23:56 的 defer 评审说阻碍是"归属权,不是质量"。这句话现在不准确了,与其让它留作我在这个 PR 上的最后表态,我更愿意更正它。 现在有一条背后有实测数据的质量发现:最后一个 commit 把 flag 路径从无下限的 SECRET_VALUE 换成了带 10 字符下限的 SECRET_FLAG_VALUE,重新打开了 9 种短取值密钥形态 —— --password hunter2--token abc123--api-key sk-12345--password="pw123"。每一条我都是从 diff 手工推导的,不是引用报告。上一轮我把这个下限归在"策略选择,不是 bug"里;那是错的,因为随后实测出下限 5 在每一个维度上都优于下限 10 —— 9/9 被重新打开的形态全部关闭、8/8 描述向量保持脱敏、无害文本逐字节一致 12/12,并且 --max-tokens 8192 仍原样通过,因为 8192 只有 4 个字符。这不是取舍,是一行代码。

两件事让它无法干净批准,一件事让它不至于硬阻断:

  • 套件看不见这个下限。 每一条正向脱敏断言用的取值都 ≥10 字符;唯一看着短的 fixture(--password P@ss'w0rd123)是 12 字符。变异体 M2(下限 10 → 5)全绿存活 69/69。在一个 P1 凭据修复上,一个 CI 在任一方向都分辨不出的边界,正是一处覆盖面回退能落在最后一个 commit 上而没有任何东西变红的原因。补救是钉住边界两侧的两个 fixture。
  • 遥测属于本 gate 只转交、不自行判定的领域,而 Usage-statistics telemetry uploads raw tool-error text (including shell command lines) to RUM without redaction #11198status/ready-for-human,并把脱敏策略本身列为要做的工作。无论代码质量如何,这把分数压在 3/5。为准确起见:作者持 admin,所以 Stage 0 的核心模块分级从来不是约束 —— 110 行生产代码、fix 标题本来也不会触发它。
  • 相对 main 它仍然是严格改善,这一点我不打算掩饰。 base 完全没有脱敏(grep 零命中);head 对真实长凭据关闭了全部 8 个点名向量,并把实测真实凭据泄漏从 41/41 降到 23/41。下限回退是相对本分支自己更早版本的回退,不是相对今天会上线的代码。所以诚实的表述是"离明确可合并只差一行",而不是"坏掉了"。

我是不是因为说不出反对理由才批准?不是。我是不是因为找到了理由就阻断?也不是 —— 而这个区别就是结论。defer:不批准,也不再提交第二次 CHANGES_REQUESTED 本机器人账号已有两个生效的 CHANGES_REQUESTED 评审(7df466e0566d6a74),reviewDecisionCHANGES_REQUESTEDmergeStateStatusBLOCKED。PR 目前根本合不了,所以同一账号的第三个评审不会增加任何门禁 —— 只会增加噪音。本 gate 的重复规则正是这么说的:如果机器人已有 CHANGES_REQUESTED,就跳过重新提交。我改为把新发现记录成一个钉在 41714d9f 上的 COMMENTED 评审,理由只有一个:我 23:56 的 defer 评审钉在同一个 commit 上、断言"归属权,不是质量",而 GitHub 的评审无法编辑。在一个 P1 安全 PR 上留下本账号一句不准确的生效表态,比评审列表多一条更糟。

有一个后果值得提醒接手的人,因为它指向另一个方向:两个生效的 CHANGES_REQUESTED 都钉在其点名 Critical 已全部关闭的 commit 上,所以它们作为陈旧评审被 dismiss 是合理的。一旦被 dismiss,这个 PR 就会在下限回退仍然存在的情况下变成可合并。 这是我不叠加评审唯一可能带来坏处的场景,所以我把它点名说出来,而不是留在言外之意里 —— 边界 fixture 和下限决定应当在任何东西被 dismiss 之前定下来。

机制部分,记录在此以免数字被当作信条:PENDING0 —— 这个 head 上唯一 event == pull_request 的运行(Qwen Code CI)已 success 完成,仍在跑的 review-prpull_request_target 机器人任务,已正确排除在计数之外。没有任何东西在等 CI,我也刻意没有留下 approve-on-green 标记:那个标记是带前置条件的批准,而我的结论不是批准。批准护栏评估为 ok(非跨仓库,标题是 fix 而非 refactor),所以挡住批准的不是 fork-refactor 规则。

关于数量,既然 gate 要问:作者有 48 个 open PR,包括同一天另一个遥测隐私 PR(#11670),以及本 PR 的 error_excerpt allowlist 项所等待的、仍然 open 的 #10916。我只按这个 PR 自己的 diff 评估它。但这个数量确实构成一个理由:维护者应该在 #11649#11670#10916 之间一次性把脱敏策略定下来,而不是逐个 PR 定 —— 这里稀缺的资源是维护者的注意力,不是评审轮次。

⏸️ 转交给 @zjunothing —— 已被指派,且是 .github/issue-owners.jsoncore-telemetry 的唯一 owner(paths: packages/core/src/telemetry/)。机制上透明说明:那个解析器由标签驱动,而本 PR 只带 review/self-reported,所以这一轮它依旧什么都没返回;我是从策略文件里点出既有 assignee,而不是猜一个 login。三个我无法从 diff 回答的问题,合并决定权在你:

另外记录在案:23:02 那条 stage=rerun-summary 评论说机器人在这个 head 上"既没有裁决也没有 defer"。它在写下时是真的,现在已过期 —— 23:56 的 COMMENTED 评审就是钉在 41714d9f 上的 defer,本轮又加了一个。请忽略那条评论,它是工作流产物,不是判断。

@yiliang114 —— 架构是对的,最后两个 commit 修的是真实缺陷而不是掩盖。合并前按此顺序,一行代码起步:

  1. 把 flag 下限降到 5SECRET_FLAG_VALUE{10,}{5,}{9,}{4,}),保留 {0,64} 边界。实测:9/9 被重新打开的形态关闭,无害文本逐字节一致 12/12,--max-tokens 8192max_tokens=8192 仍不受影响。
  2. 加上边界 fixture 对,让 CI 能分辨两种下限 —— --password hunter2 必须脱敏,--max-tokens 8192 必须不脱敏。没有它,第 1 条可以再次静默回退,正如刚刚发生的那样。
  3. 把"包括以后新增的"与静态 allowlist 对齐 —— 弱化说法,或加一个守卫测试,当新的错误文本属性键未登记时让它失败。
  4. 让 macOS / Windows / CLI 集成真正执行,使测试套件不再只有 Linux。

Stage 2 的第 5–7 条(引号被吞、转义引号的 fail-open、没有测试钉住的 {0,64} 边界)是判断题,你可以接受也可以不接受;上面那个策略问题属于维护者,不属于我,也不属于你。做完第 1–2 条后跑一次 @qwen-code /verify 就能关闭最后一个缺口 —— 确认被重新打开的形态确实关闭、无害的形态确实保留。

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

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

@qwen-code-ci-bot qwen-code-ci-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.

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

中文说明

仅完成部分审查,审查缺口已披露。

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

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

Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Close the highest-severity review findings on the redaction table:

- Add `error` to ERROR_TEXT_PROPERTY_KEYS so hook failure text written to
  properties['error'] is redacted at the enqueue choke point (R1-1).
- Skip any auth scheme (Basic/token/Digest/…) before the value, not only
  Bearer, so `Authorization: Basic <creds>` no longer leaks (R1-2).
- Add `key` to the env key alternation so *_API_KEY / *_ACCESS_KEY* are
  redacted (R1-6).
- Run redaction on the raw text before stripAnsiAndControl so the
  whitespace-delimited patterns still see their newline/tab delimiters (R1-7).
- Share one secret-value pattern that accepts a quote-opened value (R1-3) and
  let the flag name contain the secret keyword anywhere (R1-4).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtwznj86xz
yiliang114 and others added 2 commits September 11, 2026 22:46
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtx1sp0ry2
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtx4nkoby7
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 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: 92 passed · 10 failed · 102 total

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

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

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

脚本断言:92 通过 · 10 失败 · 102 总计

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

Verification report

PR #11649 Deep Verification — fix(core): redact error text in usage-statistics telemetry sink

Verdict: findings — assertions 92 pass / 10 fail / 102 total.
Verified head: 566d6a74ad0964a709050180b3fdc8002233edeb (git rev-parse HEAD^2)
A/B control (base tip): d229c1bbb2b85d0bc09ea3020d48aec110dfb72e (HEAD^1)

The central claim passes its A/B: the redaction is load-bearing. On the control build every one of 20 credential-bearing error texts reaches the telemetry wire body verbatim; at head 10 of them are scrubbed, including all 8 vectors the PR description names. The 10 failing assertions are not regressions and not harness faults — they are credential shapes that still reach the third-party backend at head, several of them inside coverage this PR claims for itself.

中文摘要

结论:findings —— 断言 92 通过 / 10 失败 / 共 102。已验证 head:566d6a74;A/B 对照基线:d229c1bbHEAD^1)。

A/B 结论:核心主张成立,脱敏确实是 load-bearing 的。在对照构建上,20 条含凭据的错误文本全部原样进入遥测上报体;在 head 上其中 10 条被清除,且 PR 描述所点名的 8 个泄漏向量全部被关闭(见下表「A/B 单元表」)。四个入队载体(properties.error_message、顶层 message(exception 与 resource 两种事件)、properties.error)在 head 上全部干净,在基线上全部泄漏 —— 收口点确实唯一且有效。

findings(详见正文 Findings 一节):

  1. 20 个真实凭据形态中仍有 10 个泄漏,其中数个落在本 PR 自己声明的覆盖范围内(代码注释点名「HTTP headers、provider error bodies」):JSON 形态的 {"authorization":"Bearer …"}、JSON 形态的环境变量转储、X-Api-Key: … 冒号形态、以及长度 <10 的短密钥(阈值实测为 10,9 字符即泄漏)。
  2. 描述中「任何错误文本字段——包括以后新增的——都会被清除」与实现不符:实现是硬编码的 3 键白名单,新增键不会被脱敏。
  3. error_excerpt死键:全仓库无任何生产者,删除它的变异体 61/61 全绿存活。
  4. 引号被吞掉导致输出引号不配对(实测 2→1),且 PR 自带测试把这个不配对结果写成了期望值。
  5. 已声明的过度匹配取舍实测为 6/6(含 KEYWORD_FILTER=integration,unit 这类普通配置)。

已排查、结论为「不成立」的更严重猜测stack 旁路(全仓库无任何代码给 Rum 事件写 stack)、重试路径旁路(this.events 仅在 enqueueLogEvent 内写入)、ReDoS 挂死(最差档位 20k 字符 121.6 ms,且错误文本受 25,000 字符截断上限约束)、原地修改污染调用方对象(enqueueLogEvent 无生产环境外部调用者)。

未覆盖范围:逐 commit 归因(浅克隆,本地仅 1 个 commit,元数据声明 4 个);与当前 main 的试合并(main 本地不可达);仓库级 lint / 全量 packages/core 测试套件;真实网络上报(无凭据,且不应外发测试数据);OTLP 侧 error.message 属性属另一条上报路径,本 PR 未触及,未验证。

Central claim and A/B

Central claim. Error text enqueued into the usage-statistics (RUM) sink is scrubbed of credentials at a single choke point, so secrets in shell command lines no longer leave the process.

Secondary claims. (a) The choke point is genuinely single, covering every carrier. (b) Non-sensitive text is preserved verbatim.

Oracle. flushToRum() does body = safeJsonStringify(await this.createRumPayload()) and POSTs it to gb4w8c3ygj-default-sea.rum.aliyuncs.com. So createRumPayload() is the wire body. The harness drives the real log*Event() methods on the compiled dist/ build, then serializes the enqueued event and searches it for the literal secret. No mock of the unit under test; nothing is stubbed except Config.

Witness: 01-ab-cells-base-leaks-20-of-20-head-leaks-10-of-20.png, 03-raw-probe-head-vs-base-vs-candidate-fix.png.

id shape base (d229c1bb) head (566d6a74) flip
A1-url-creds https://x-access-token:ghs_…@github.com LEAK redacted closed
A2-auth-bearer -H "Authorization: Bearer ghs_…" LEAK redacted closed
A3-auth-basic Authorization: Basic dXNlcjpwYXNz… LEAK redacted closed
A4-secret-flag --_authToken npm_… LEAK redacted closed
A5-env-secret GITHUB_TOKEN=ghs_… LEAK redacted closed
A6-dsn postgres://user:supersecret123@db LEAK redacted closed
A7-api-key-env OPENAI_API_KEY=sk-proj-… LEAK redacted closed
A8-aws-secret-key AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/… LEAK redacted closed
B9-npmrc-authtoken //registry.npmjs.org/:_authToken=npm_… LEAK redacted closed
B11-git-extraheader -c http.extraheader="AUTHORIZATION: bearer …" LEAK redacted closed
B1-json-auth-header {"authorization":"Bearer ghs_…"} LEAK LEAK unchanged
B2-json-env-dump {"GITHUB_TOKEN":"ghs_…"} LEAK LEAK unchanged
B3-prose-api-key invalid api key sk-proj-… provided LEAK LEAK unchanged
B4-short-db-password DB_PASSWORD=hunter2x (8 chars) LEAK LEAK unchanged
B5-curl-u curl -u alice:supersecret123 LEAK LEAK unchanged
B6-curl-user-long curl --user alice:supersecret123 LEAK LEAK unchanged
B7-header-colon X-Api-Key: ak_… LEAK LEAK unchanged
B8-password-colon password: supersecret123 LEAK LEAK unchanged
B10-mysql-short-p mysql -pSuperSecret123 -h db LEAK LEAK unchanged
B12-bare-jwt token rejected: eyJhbGci…SECRET.payload.sig LEAK LEAK unchanged

Secret vectors: base leaks 20/20, head leaks 10/20 → 10 closed by this PR, 10 still open.

Choke-point carrier coverage (secondary claim (a) — holds). All four real carriers, same secret, driven end to end:

carrier base head
properties.error_message via logToolCallEvent LEAK clean
top-level message via logInvalidChunkEvent LEAK clean
top-level message via logApiErrorEvent (a resource event) LEAK clean
properties.error via logHookCallEvent LEAK clean

The enqueueLogEvent sink is structurally single: this.events is written at exactly one site (line 271, inside enqueueLogEvent) plus the retry path at line 1255, which unshifts events that were already redacted on their first pass. All 42 internal call sites route through it. Note the message redaction is typed as a cast to RumExceptionEvent but at runtime reads/writes event.message, so it correctly also covers RumResourceEvent.message — the logApiErrorEvent row above is that case, and it is clean.

Corrections

These are corrections to the PR description, not requests to change code.

  1. "…so any error-text key — including ones added later — is scrubbed before it leaves the process" is not what the implementation does. ERROR_TEXT_PROPERTY_KEYS is a hardcoded three-element allowlist (error_message, error_excerpt, error). A key added later is not scrubbed until someone remembers to add it to this list. The description's framing — "closes the hole once instead of once per error-text key … the shape of the earlier regressions, where a newly added error field re-opened the leak" — describes a property this change does not have. What the PR genuinely achieves is centralising the list at the sink (one place to update instead of N call sites), which is a real improvement but a weaker contract than stated. A reviewer accepting this PR should know the maintenance obligation survives.

  2. "snapshots and stack fields are intentionally not redacted" — the stack half of that sentence is moot. No code anywhere in packages/core/src/telemetry/ assigns stack to a Rum event (grep -rn "stack:" packages/core/src/telemetry/ --include=*.ts returns nothing outside tests). The declared exclusion excludes nothing. This matters because a Node stack's first line is the message, so had stack been populated it would have been a live bypass of the message redaction — I checked specifically for that and it does not exist.

  3. The Risk section's tradeoff list names only over-matching. It does not name under-matching. Measured under-matching is 10 of 20 shapes (table above), including shapes inside the coverage the code's own doc comment claims: ERROR_TEXT_PROPERTY_KEYS is documented as covering "command lines, HTTP headers, provider error bodies, hook failures", yet only the authorization header name is matched (B7 X-Api-Key: leaks), and JSON-shaped bodies leak outright (B1, B2).

Findings

F1 — 10 of 20 realistic credential shapes still reach the third-party backend (highest)

The mechanism works; its coverage is narrower than the surrounding prose implies. Grouped by cause:

  • JSON-shaped text (B1, B2). All three patterns require a bare name followed by [:=]. A provider error body or an env dump is JSON, where a quote sits between the name and the separator: {"authorization":"Bearer ghs_…"}, {"GITHUB_TOKEN":"ghs_…"}. Neither matches. This is the sharpest case because the doc comment on ERROR_TEXT_PROPERTY_KEYS explicitly names "provider error bodies", and logApiErrorEvent forwards event.error_message — a provider's JSON error body — into both the top-level message and properties.error_message.

  • Colon-separated header/config forms (B7, B8). X-Api-Key: ak_… and password: supersecret123. Only authorization gets the [:=] treatment; the env pattern requires a literal =.

  • The \S{10,} length floor (B4). Bisected exactly: value length ≤ 9 leaks, ≥ 10 redacted.

    valueLen= 7  LEAKS     -> DB_PASSWORD=xxxxxxx psql failed
    valueLen= 9  LEAKS     -> DB_PASSWORD=xxxxxxxxx psql failed
    valueLen=10  redacted  -> DB_PASSWORD=***REDACTED*** psql failed
    

    The comment justifies the floor as sparing tokens_used=8192 (4 chars). A floor of 5 would spare that counter too while closing 5–9-character secrets. Reproduce: node tmp/pr11649-verify-20260911-185251/probe.mjs /__w/qwen-code/qwen-code /tmp/x.json B4-short-db-password.

  • Non-secret-named credential flags (B5, B6, B10). curl -u user:pass, curl --user user:pass, mysql -pPass. The flag pattern keys on the flag name containing a secret keyword; --user and -p do not.

  • Prose markers and bare tokens (B3, B12). invalid api key sk-proj-…, a bare JWT. No = and no --, so nothing matches.

This is incompleteness, not a regression — base leaks all 20, so the PR strictly improves the situation, and a regex allowlist can never be exhaustive. It is reported as the top finding because the residual set intersects the PR's own stated coverage, and because the description's "closes the hole once" framing invites a reviewer to believe the class is closed.

Measured candidate fix for the JSON shape (B1) — apply with its own fixture

Tolerating an optional quote on either side of the separator closes B1 with zero measured collateral:

 const AUTHORIZATION_PATTERN = new RegExp(
-  String.raw`\b(authorization\s*[:=]\s*)(?:[A-Za-z0-9._~+/-]+\s+)?` +
+  String.raw`\b(authorization["']?\s*[:=]\s*["']?)(?:[A-Za-z0-9._~+/-]+\s+)?` +
     SECRET_VALUE,
   'gi',
 );

Measured through the same real-dist harness (not eyeballed), all three required results:

check result
hostile fixture goes clean B1-json-auth-header flips LEAK → redacted; leaks 10 → 9; all 8 claimed vectors still redacted
benign fixtures byte-identical 12/12 C* cases still identical — zero new collateral; over-match set unchanged at the same 6 D* cases
affected suite counts unchanged 61 passed (61) with and without the patch

⚠️ That third row is a warning, not reassurance. Applying this fix leaves the suite green on both sides, which proves the suite pins nothing along the JSON-shape axis. Confirmed directly as a mutation: FIX-json-auth-shape SURVIVED (61/61 green). So this fix must ship with its own fixture, e.g.:

it('redacts a JSON-shaped authorization header', () => {
  expect(
    TEST_ONLY.redactTelemetryError(
      '{"error":{"headers":{"authorization":"Bearer ghs_testsecret123"}}}',
    ),
  ).not.toContain('ghs_testsecret123');
});

The same is true for B2/B7/B8: closing colon-separated and JSON-shaped KEY: value forms means deciding how much ordinary key: value prose one is willing to over-redact, which is a design call, not a one-line patch. I did not measure a fix for those.

F2 — error_excerpt is a dead key (Suggestion)

Full-repo census (grep -rn "error_excerpt", excluding node_modules/.git/tmp) returns 5 hits, all of them the same declaration: the source line, packages/core/dist, two copies inside packages/vscode-ide-companion/dist/extension.cjs, and dist/chunks/. There is no producer — nothing anywhere writes properties.error_excerpt.

The mutation matrix agrees: M8-error_excerpt-key (delete it from the list) SURVIVED at 61/61 green, while the other 11 of 12 production guards were all killed. Classification per the matrix rules: dead code — the clause cannot decide any outcome. Not a coverage gap (there is no behaviour to assert) and not redundant defence (no sibling hunk covers it, because nothing produces the key).

Matrix tally, from 02-mutation-matrix-11-of-12-guards-killed-error_excerpt-survives.png: 12 of 13 mutants killed — 11 of 12 production guards plus the positive control M1; the unmutated control M0 was GREEN (61/61), so the kills are meaningful; the sole survivor is M8. Both controls landing in the same file as the mutants is what makes M8's survival interpretable rather than a harness fault: M1 (drop error_message from the same list) turned exactly one test red, proving the runner does collect tests that exercise this file.

AGENTS.md Simplicity First is explicit about this shape: "No features beyond what was asked. No 'flexibility' or 'configurability' that wasn't requested." Dropping the entry, or landing it together with the key it anticipates, would match. Harmless as-is.

F3 — Redaction consumes the closing quote, producing unbalanced output (Nit)

SECRET_VALUE accepts an optional trailing quote, so when the opening quote sits before the matched region the closing quote is eaten:

in : curl -H "Authorization: Bearer ghs_testsecret123" https://api.github.com/repos
out: curl -H "Authorization: ***REDACTED*** https://api.github.com/repos
quote count in=2 out=1  -> UNBALANCED

The PR's own test asserts this unbalanced string as the expected value, so it is deliberate rather than an oversight. Diagnostic impact only — no security consequence, and the secret is genuinely gone. Worth a note because a reader diffing redacted error text against a real command line will see a quote vanish.

F4 — Acknowledged over-match, sized (informational)

The Risk section accepts over-matching. Measured: 6/6 benign inputs containing a secret keyword in a harmless position are mutated, and 12/12 benign inputs without one are preserved byte-identical (secondary claim (b) — holds). Witness: 04-benign-preserved-vs-acknowledged-overmatch.png.

in : vitest run --keyword-search integration --reporter=verbose failed with exit code 1
out: vitest run --keyword-search ***REDACTED*** --reporter=verbose failed with exit code 1
in : python train.py --tokenizer gpt2 --epochs 3
out: python train.py --tokenizer ***REDACTED*** --epochs 3
in : env KEYWORD_FILTER=integration,unit npm test
out: env KEYWORD_FILTER=***REDACTED*** npm test

The KEYWORD_FILTER row is the one that exceeds the description's framing: the Risk section describes the false-positive surface as following "an authorization, secret-flag, or secret-key marker", which reads as flag-shaped, but the env pattern fires on any variable whose name merely contains key/token/secret/credential and whose value is ≥10 chars. Ordinary CI config (KEYWORD_FILTER, MONKEYPATCH_MODE, TOKENIZER_PATH) is scrubbed. Still within the spirit of the accepted tradeoff, so informational — but the description's characterisation of the surface is narrower than the code's behaviour.

Scarier consequences I checked that do not hold

Bounding F1 matters more than maximising it. Each of these was a plausible escalation and each was disproved:

  • stack bypass — disproved. No stack: assignment exists anywhere in packages/core/src/telemetry/. Since a Node stack's first line is the message, a populated stack would have bypassed the message redaction entirely. It is never populated.
  • Retry-path bypass — disproved. this.events has exactly two writers: line 271 inside enqueueLogEvent (post-redaction) and line 1255 unshift in requeueFailedEvents, which re-queues events already redacted on first enqueue. No unredacted path to the wire.
  • ReDoS hang — disproved as a hang. 32-rung ladder over 8 hostile shapes at 2 k / 3 k / 5 k / 20 k characters, 0 rungs over a 15 s cap. Worst: H2-flag-many-keywords-no-terminator at 20 000 chars = 121.6 ms. The curve is superlinear (1.2 → 2.9 → 7.6 → 121.6 ms, ≈ quadratic), and the input is genuinely outsider-authored — for shell exit/signal failures shell.ts:3099 sets error.message to llmContent, i.e. the command's own output, so a malicious build script controls the text. But it is bounded: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000 chars, which extrapolates to ≈190 ms worst case. A one-off cost on an already-failing command, not a hang.
  • In-place mutation corrupting a caller's object — disproved. redactEventErrorText mutates event.properties[key] in place, but every log*Event builds that object fresh, and enqueueLogEvent has no production external callers (grep finds only integration.test.circular.ts and the .d.ts). Nothing outside the sink can observe the mutation.
  • snapshots as an alternate credential carrier — not observed. The four snapshots payloads carry execution_summary, output-length counters, token counts, and truncated_sequence (a truncated kitty escape sequence). None carries shell error text. truncated_sequence is raw terminal bytes and is the only one that is even nominally free-form; it is outside this PR's scope and I did not attempt to construct a leak through it.

Not covered

  • Per-commit attribution. The checkout is depth 2 (git rev-parse --is-shallow-repositorytrue). git rev-list HEAD^1..HEAD^2 returns 1 commit while $QWEN_VERIFY_CONTEXT lists 4 — exactly the shallow-boundary undercount the method warns about, so the per-commit claims (R1-1 … R1-7 in 868e8bd7) were verified only in aggregate against HEAD^1..HEAD. Several do map onto measurements above (R1-2 ↔ M12, R1-7 ↔ M13, R1-1 ↔ M7), but I did not exercise the commits individually.
  • Base OID discrepancy. The metadata's baseRefOid is 20ecdaf6b2fbbfbd276bf05294e7b672632087e4, which is not reachable locally (git cat-file -t → fatal). I used the merge-ref base tip HEAD^1 = d229c1bb, which is the correct control for a refs/pull/11649/merge checkout. The A/B is unaffected, but the PR's declared branch point could not be inspected.
  • Trial merge into current main. Not possible at depth 2 — main is unreachable locally, so I could not confirm the merge is conflict-free or re-measure on a merged tree. HEAD^1 (d229c1bb) is by construction the base tip at the moment GitHub built the merge ref, so the control is the right one for this checkout; but how far that tip now sits behind main, and whether main has since touched qwen-logger.ts, are not measured here. A maintainer should re-check before landing.
  • Gates. Ran only the focused suite packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts (61 passed / 61, re-confirmed on the restored tree) and tsc --build for packages/core (EXIT=0) as the typecheck. Did not run the full packages/core suite, npm run lint, npm run typecheck at repo level, or any integration test.
  • No live network flush. Nothing was POSTed to the RUM endpoint — no credentials in this container, and sending test fixtures to a real third-party backend would be inappropriate. The oracle is createRumPayload(), which flushToRum() serializes verbatim; the wire body was reconstructed, not captured off a socket.
  • Other exfiltration paths. loggers.ts sets attributes['error.message'] = event.error_message for the OTLP exporters, and session-tracing.ts/daemon-tracing.ts carry error text into spans. Those are separate, opt-in sinks this PR does not touch and does not claim to touch; I did not verify whether they redact.
  • Base-build environment caveat. Building the control tree reported ~40 TS2307 Cannot find module '@opentelemetry/*' errors. Cause identified, not hand-waved: those packages live in packages/core/node_modules (nested, unhoisted) in the main tree, and a fresh git worktree does not get one. They are confined to files qwen-logger.ts does not import; qwen-logger.js emitted complete (verified by tail and by the control arm exercising the real code path). The control arm's validity rests on that emit, and probe.mjs asserts the module realpath is inside the base tree before running.

Methodology

CI merge-ref checkout at d07fcb5e; npm ci and npm run build pre-completed at head. Control built from git worktree add tmp/base-tree HEAD^1, wired to the root node_modules by symlink, packages/core rebuilt there with scripts/build_package.js; the worktree was removed after the cells were captured and git status --porcelain is clean.

Because node_modules/@qwen-code/qwen-code-core realpaths to /__w/qwen-code/qwen-code/packages/core (the head tree), every harness imports the module by absolute dist/ path and probe.mjs hard-fails if realpathSync(module) escapes the declared tree — that assertion is recorded in the ledger as control-arm-is-unfixed-build. qwen-logger.ts imports only relative paths and third-party packages, so no internal workspace link is crossed. The control was additionally confirmed to be the un-fixed build by symbol census: redactTelemetryError|redactEventErrorText appears 0 times in base dist and 6 times in head dist, and base TEST_ONLY lacks the export.

probe.mjs drives the compiled QwenLogger through real log*Event() calls with a fake Config (the only stub), clears the singleton deque between cases so each serialized payload carries exactly one event, and searches that payload for the literal secret. corpus.mjs holds 38 cases in four groups: 8 claimed vectors, 12 siblings, 12 benign, 6 acknowledged over-match. compare.mjs builds the cell table and the ledger; assertion encoding puts the security property on the head arm (a leaking shape is a real fail, never a documented pass) and the expected red on the base arm (base leaking is a passing control cell).

mutate.mjs applies 13 single-point source mutants plus an unmutated control, runs the focused vitest suite per mutant, and restores from the pristine copy each time — final byte-identity asserted (source restored byte-identical: true) and re-verified with git status. vacuity-detail.mjs re-runs selected mutants to print the actual AssertionError text, confirming each kill fails the intended assertion with an expected-vs-actual behavioural mismatch rather than a compile or import break; it also carries the FIX-json-auth-shape candidate. redos-ladder.mjs runs 8 hostile shapes × 4 size rungs under a 15 s per-rung cap.

Raw logs: base-probe.log, head-probe.log, head-plus-fix-probe.log, head-restored-probe.log, redos-head.log, ab-table.log, mutate.json, assertions-detail.json (per-assertion ledger). Harnesses are .mjs and rerunnable as-is.

Integrity check: after restoring the source and rebuilding, the head probe was re-run and reproduces the original results byte-for-byte (restored dist reproduces original head results: true, leaks 10 = 10, fidelity identical) — so the mutation and candidate-fix work left no residue in the reported numbers.

Flakiness gate log

rounds=5 files=1 skipped=0
file packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: (cd packages/core) npx --no-install vitest run ./src/telemetry/qwen-logger/qwen-logger.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: 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/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 2 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 3 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 4 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 5 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)

Evidence images

01-ab-cells-base-leaks-20-of-20-head-leaks-10-of-20

02-mutation-matrix-11-of-12-guards-killed-error_excerpt-survives

03-raw-probe-head-vs-base-vs-candidate-fix

04-benign-preserved-vs-acknowledged-overmatch

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-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.

5 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

Not explored to full depth (tool budget reached): "agent 6b": confirming whether any upstream producer caps the length of error.message / error_excerpt before it reaches enqueueLogEvent (bears on the severity of the …; "agent 6b": running npx vitest run src/telemetry/qwen-logger/qwen-logger.test.ts in packages/core to confirm the added tests pass as written — I replicated the exact patt….

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

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 5 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):"agent 6b"confirming whether any upstream producer caps the length of error.message / error_excerpt before it reaches enqueueLogEvent (bears on the severity of the …"agent 6b"running npx vitest run src/telemetry/qwen-logger/qwen-logger.test.ts in packages/core to confirm the added tests pass as written — I replicated the exact patt…

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

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

Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
yiliang114 and others added 2 commits September 12, 2026 04:57
Harden the usage-statistics error-text redaction pass against the review
findings on this branch:

- Normalise shell line continuations (`\` + newline + leading whitespace)
  ahead of every pass, so a credential split across a continuation (including
  a URL credential) can't survive as a glued cleartext run.
- Make SECRET_VALUE quote-aware (a quoted run matches to its closing quote),
  so interior quotes and quoted values containing spaces redact whole instead
  of leaking a suffix after the marker.
- Give the env pattern the same quoted alternative behind its floor.
- Skip an optional leading backslash in each separator so a shell-escaped
  nested quote (`Authorization: Bearer \"secret\"`) still redacts.
- Strip ANSI/C0 per line (after widening tabs to spaces) before the patterns
  run, so an escape or control character can't hide or reassemble a credential
  the pattern pass never saw.

Fix the line-continuation test to assert the secret is absent rather than
merely that no marker is emitted, and add regression cases for interior
quotes, quoted values with spaces, ANSI/C0, and continuations.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtxennouyn
- Bound the non-secret name chars around the secret keyword in
  SECRET_FLAG_PATTERN and ENV_SECRET_PATTERN (previously unbounded `*`,
  now `{0,64}`) to prevent quadratic backtracking on adversarial error
  text that runs through the synchronous telemetry redaction pass.
- Give SECRET_FLAG_PATTERN the same 10-char value floor as
  ENV_SECRET_PATTERN so `--max-tokens 8192` and `max_tokens=8192` are
  handled consistently (both pass through untouched).
- Align the ENV_SECRET_PATTERN docblock examples with the actual 10-char
  floor (the old `ghs_xxx` / `sk_xxx` examples were shorter than the floor
  and were never redacted).

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtxgstgfyr
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 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: 171 passed · 23 failed · 194 total

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

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

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

脚本断言:171 通过 · 23 失败 · 194 总计

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

Verification report

PR #11649 Deep Verification (round 2) — fix(core): redact error text in usage-statistics telemetry sink

Verdict: findings — assertions 171 pass / 23 fail / 194 total.
Verified head: 41714d9fd0f7eaadd89dddb2646e79802315cb2d (git rev-parse HEAD^2)
A/B control (base tip): d0e3935703776ef1529e840ef9ebda024ee64406 (HEAD^1)
Previously verified head (round 1): 566d6a74ad0964a709050180b3fdc8002233edebalso built and measured as an arm this round
Delta under test this round: 31827e9d41714d9f (one commit, two hunks)

The central claim still passes its A/B, but the newest commit reopened 13 credential shapes that the previously verified head had closed. Against base the PR is a clear improvement (real-credential leaks 41/41 → 23/41, all 8 vectors the description names are closed, all four enqueue carriers clean). Against the branch's own earlier revision it is a regression: 9 short secrets behind --password / --token / --api-key flags — a vector the PR description claims for itself — now reach the third-party backend verbatim. The cause is one character-class change and the fix is measured below.

中文摘要

结论:findings —— 断言 171 通过 / 23 失败 / 共 194。已验证 head:41714d9f;A/B 对照基线:d0e39357HEAD^1);上一轮已验证 head:566d6a74本轮也单独构建为一个对照臂实测)。

A/B 结论:核心主张依然成立,脱敏是 load-bearing 的。本轮做了四臂对照(base / 上一轮 head / delta 父提交 / 当前 head,另加候选修复臂),见下表「A/B(四个构建臂)」。以真实凭据形态计(A+B+R+N 共 41 个,D 组是 PR 自己承认的过度匹配、其取值本身无害,故不计入泄漏口径):base 41/41 全泄漏 → 上一轮 head 10/41 → delta 父提交 10/41 → 当前 head 23/41。四个入队载体(properties.error_message、顶层 message 的 exception 与 resource 两种、properties.error)在 head 上全部干净、在 base 上全部泄漏,收口点确实唯一。

本轮最重要的发现(新增 F5):最新一个 commit 41714d9fSECRET_FLAG_PATTERN 新加了 10 字符取值下限(SECRET_FLAG_VALUE{9,}),而此前两版用的是无下限的 SECRET_VALUE。结果是 9 个短取值 flag 密钥被重新打开mysql --password hunter2--token abc123--api-key sk-12345--password="pw123" 等(实测边界:取值 9 字符泄漏、10 字符脱敏)。--password 正是 PR 描述点名的覆盖向量之一。这不是从正则字面量推断出来的:上一轮 head 566d6a74 与 delta 父提交 31827e9d 都已各自构建成独立对照臂实测,两版对这 12 个短取值形态全部脱敏,当前 head 只剩 3 个。已实测候选修复(下限降到 5,保留 {0,64} 边界):52/52 断言通过,真实凭据泄漏 23/41 → 14/41 —— 9/9 回归全部重新关闭、8/8 描述向量保持脱敏、12/12 无害文本逐字节不变、且 delta 自己的目标(--max-tokens 8192--tokenizer gpt2 不再被误伤)依然保持。

其余 findings:F6 {0,64} 边界在名字段达 65 字符时开始漏(4 例,真实长 flag 名不受影响,严重度低);F7 测试套件无法区分下限 10 与下限 5(变异体 M2 全绿存活 69/69),因此该回归对本 PR 自带测试完全不可见 —— 已给出并实测验证了能区分两者的 fixture(head 上 RED、下限 5 上 GREEN);F8 {0,64} 边界经实测确属 load-bearing(生产截断上限 25k 字符处 121.9 ms → 0.7 ms),但无任何测试钉住(M4/M5/M9 全绿)。

上一轮发现的复核:F1 原样成立(同 10 个形态);F2 error_excerpt 仍是死键(全仓库仅 1 处声明,M8 存活);F3 引号被吞成立且范围比上轮更宽(4/4 形态引号不配对,flag 形态两个引号全丢 2→0,上轮只测了 Authorization 的 2→1);F4 过度匹配已改善(6/6 → 4/6,无害文本逐字节保持 11/12 → 12/12);上轮「ReDoS 不构成挂死」的结论被 delta 的边界取代(曲线由二次变平);上轮未能覆盖的逐 commit 归因本轮已覆盖(已 fetch 到全部 6 个 PR commit)。

已排查、结论仍为「不成立」的更严重猜测stack 旁路(packages/core/src/telemetry/ 内无任何 stack: 赋值)、重试路径旁路(this.events.push 仅 1 处,位于 enqueueLogEvent 内、脱敏之后;1267 行 unshift 只回填已脱敏事件)、原地修改污染调用方对象(42 处内部调用点,生产代码无外部调用者,仅测试文件)。

未覆盖范围:与当前 main 的试合并(main 本地不可达);仓库级 lint 与全量 packages/core 测试套件;真实网络上报;OTLP 侧 error.message 属另一条上报路径,本 PR 未触及。

Previous-finding status (round 1 → this head)

Round 1 verified 566d6a74. Two commits landed since: 31827e9d (quote/continuation/ANSI) and 41714d9f (the delta under test). Every row below was re-measured at the new head — rebuilt, re-run, not diffed against the old report.

# Round-1 finding Sev Status at 41714d9f Re-measured evidence
F1 10 of 20 realistic credential shapes still reach the backend highest stands — identical set Same 10 shapes leak (B1,B2,B3,B4,B5,B6,B7,B8,B10,B12); B9/B11 still redacted. ab-table.log
F1-fix Measured candidate fix for the JSON shape (B1) not adopted; superseded in priority B1 still leaks at head; the new F5 regression is the higher-value fix
F2 error_excerpt is a dead key Suggestion stands Full-repo census now 1 hit (the declaration, qwen-logger.ts:115); mutant M8 SURVIVED GREEN 69/69
F3 Redaction consumes the closing quote → unbalanced output Nit stands, scope wider than round 1 reported Round 1 measured one shape (2→1). Now 4/4 unbalanced; flag shapes lose both quotes (--_authToken "npm_…" 2→0, --password="…" 2→0). Identical on prev and head, so it predates the delta. fixture-and-f3.log
F4 Acknowledged over-match 6/6 info improved Measured prev → head on this round's corpus: over-match 6/6 → 4/6 (round 1 independently reported 6/6 on its own corpus, so the starting point agrees). --tokenizer gpt2 and --max-tokens 8192 now pass through; benign byte-identity on this round's 12-case corpus 11/12 → 12/12 (C4 = run --max-tokens 8192 max_tokens=8192 flipped MUTATED → identical)
C1 "any error-text key — including ones added later" ≠ the hardcoded 3-key allowlist stands ERROR_TEXT_PROPERTY_KEYS is still ['error_message','error_excerpt','error']
C2 Declared stack exclusion excludes nothing stands grep -rn "stack:" packages/core/src/telemetry/ --include=*.ts (excl. tests) → 0 hits
C3 Risk section names only over-matching, not under-matching worsened Under-matching grew from 10/20 to 23/41 real-credential shapes
ReDoS "disproved as a hang" (worst 121.6 ms) superseded — the delta fixed the curve At the 25 k production truncation cap: prev 121.9 ms → head 0.7 ms (~170×). Ladder below
In-place mutation cannot corrupt a caller stands (re-confirmed) 42 internal this.enqueueLogEvent( sites; external callers only in integration.test.circular.ts and the unit test
Per-commit attribution not possible at depth 2 now covered git fetch --depth=12 origin refs/pull/11649/head succeeded; all 6 PR commits in the metadata are locally reachable and match git log
Trial merge into current main still not covered main unreachable locally; see Not covered

Central claim and A/B (four build arms)

Central claim. Error text enqueued into the usage-statistics (RUM) sink is scrubbed of credentials at a single choke point.

Secondary claims. (a) The choke point is genuinely single. (b) Non-sensitive text survives verbatim. (c) New this round: the {0,64} bounds prevent quadratic backtracking. (d) New this round: flag and env value floors are unified so --max-tokens 8192 and max_tokens=8192 behave the same.

Oracle. flushToRum() does body = safeJsonStringify(rumPayload) and POSTs to gb4w8c3ygj-default-sea.rum.aliyuncs.com, so createRumPayload() is the wire body. The harness drives the real log*Event() methods on the compiled dist/ of each arm and searches the serialized payload for the literal secret. Nothing about the unit under test is stubbed except Config; flushIfNeeded/flushToRum are hard-blocked and their invocations counted (63 blocked attempts per arm, identical across arms — nothing left the container).

Witness: 01-three-arm-ab-base-vs-prev-vs-head.png (the per-case cell table, base/prev/head). The round1 and fix5 arms are tabulated below and in round1-probe.json / fixcheck.log respectively.

arm commit real-credential leaks (of 41) R group: short flag secrets redacted (of 12) benign byte-identical (of 12) over-matched (of 6)
base (control) d0e39357 41 / 41 0 12/12 0 (no redaction at all)
round1 (head round 1 verified) 566d6a74 10 / 41 12 8/12 6
prev (delta's parent) 31827e9d 10 / 41 12 11/12 6
head (under test) 41714d9f 23 / 41 3 12/12 4
fix5 (measured candidate, §F5) head + floor 5 14 / 41 12 12/12 4

The round1 arm exists so the regression claim is a measurement, not an inference from reading a pattern literal: both earlier revisions of this branch redacted all 12 short-flag shapes and sat at 10/41 real-credential leaks; head redacts 3 and sits at 23/41. Its 22 control assertions (12 R + 8 A + arm-identity + realpath) are all passing cells in the ledger — an earlier revision failing to redact would have falsified the claim. round1's lower benign score (8/12) is the over-match the delta then fixed, and it is the one thing the delta genuinely improved.

Per-group leak counts (ab-table.log; for group D the value is benign, so "LEAK" = preserved = good):

grp  n   base  prev  head
A    8   8     0     0      <- the 8 vectors the description names: all closed, all stay closed
B    12  12    10    10     <- round-1 residual F1: unchanged
R    12  12    0     9      <- NEW: 9 short flag secrets reopened by the delta
D    6   6     0     2      <- benign over-match: 2 now correctly preserved
N    9   9     0     4      <- NEW: {0,64} boundary, 65-char name segments

Choke-point carrier coverage (secondary claim (a) — holds). Same secret driven end to end through all four real carriers:

carrier base prev head
properties.error_message via logToolCallEvent LEAK clean clean
top-level message via logInvalidChunkEvent (exception) LEAK clean clean
top-level message via logApiErrorEvent (resource) LEAK clean clean
properties.error via logHookCallEvent LEAK clean clean

Structurally single: this.events.push appears at exactly one site (line 283, inside enqueueLogEvent, after redactEventErrorText); the only other writer is unshift at line 1267 in requeueFailedEvents, which re-queues events already redacted on first pass. All 42 internal call sites route through it.

Backtracking ladder (secondary claim (c) — holds, with a caveat). 8 hostile shapes × 4 rungs (2 k / 3 k / 5 k / 20 k), one rung per child process under timeout 30, plus a 25 k rung at the real production ceiling (DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000, config.ts:793). Zero rungs hit the 30 s cap on either arm. Raw: redos-ladder.jsonl.

shape prev 2 k → 20 k → 25 k head 2 k → 20 k → 25 k
H1-env-many-keywords-no-eq 1.1 → 48.7 → 75.8 ms 0.5 → 0.6 → 0.6 ms
H2-flag-many-keywords-no-sep 1.2 → 68.3 → 121.9 ms 0.5 → 0.6 → 0.7 ms
H3–H8 (6 further shapes) flat, 0.5 – 0.9 ms flat, 0.5 – 1.0 ms

The bound is genuinely load-bearing on the curve — H2's per-kchar cost rises 0.62 → 3.42 ms on prev (superlinear) and falls 0.26 → 0.03 ms on head (flat). Caveat: the thing it fixes was never a hang. The unbounded worst case at the largest input production can produce is 121.9 ms, on an already-failing command. The input is outsider-authored — shell.ts:3102 sets error.message = llmContent for exit/signal failures, i.e. the failing command's own output — so the hardening is worthwhile, but it is defence-in-depth, not a fix for an exploitable stall.

Corrections

Corrections to the PR description / commit message, not requests to change code.

  1. Round-1 correction C1 still stands. "any error-text key — including ones added later — is scrubbed" is not what the code does: ERROR_TEXT_PROPERTY_KEYS is a hardcoded three-element allowlist. What the PR achieves is centralising that list at the sink, which is a real but weaker contract; the maintenance obligation survives.
  2. Round-1 correction C2 still stands, and is now load-bearing for F2. The Risk section says stack is "intentionally not redacted"; no code in packages/core/src/telemetry/ assigns stack to a Rum event. The declared exclusion excludes nothing.
  3. The delta commit's "unify flag/env value floors" is literally accurate but leaves a three-way asymmetry. AUTHORIZATION_PATTERN still uses the floorless SECRET_VALUE, so at head: Authorization: Bearer xyz (3 chars) is redacted (R11), Authorization: Basic YWJj (4 chars) is redacted (R12), while --password hunter2 (7 chars) is not (R1). There are now three different value floors in one function — 1 char for authorization, 10 for flags, 10 for env. A reader taking "unified" at face value would expect one.
  4. The commit's stated cost/benefit is inverted in effect. The message frames the floor as consistency hygiene ("--max-tokens 8192 and max_tokens=8192 … both pass through untouched"). That goal is achieved — but the same edit is what reopens 9 credential shapes (F5). The consistency was obtained by moving the flag pattern down to the env pattern's floor rather than by choosing a floor that satisfies both goals; §F5 measures one that does.

Findings

F5 — The delta commit reopens 9 short-secret flag leaks that both earlier revisions closed (highest)

41714d9f replaced SECRET_VALUE with a new SECRET_FLAG_VALUE carrying a ≥10-character floor:

prev (31827e9d): SECRET_VALUE      = (?: "[^"]+"  | '[^']+'  | [^\s"'\`\\][^\s]*  )   <- no floor
head (41714d9f): SECRET_FLAG_VALUE = (?: "[^"]{10,}" | '[^']{10,}' | [^\s"'\`\\][^\s]{9,} )  <- >=10 chars

Any secret of ≤9 characters behind a --*token|password|secret|credential|key* flag now reaches the RUM backend verbatim. Measured through the real dist, boundary bisected — and measured on both earlier revisions of this branch, not just the delta's parent (round1-probe.json): at 566d6a74 all 12 R shapes were redacted, at 31827e9d all 12 were redacted, at head 3 are.

id error text prev head
R1 mysql --password hunter2 -h db.internal failed redacted LEAK
R2 deploy.sh --password=Passw0rd exited 1 redacted LEAK
R3 npm publish --token abc123 failed redacted LEAK
R4 client --api-key sk-12345 refused redacted LEAK
R5 vault --secret s3cr3tval denied redacted LEAK
R6 svc --credential cr3dX rejected redacted LEAK
R7 mysql --password="pw123" -h db failed redacted LEAK
R8 npm publish --_authToken npm_abc12 failed redacted LEAK
R9 run --password xxxxxxxxx end (9 chars) redacted LEAK
R10 run --password yyyyyyyyyy end (10 chars) redacted redacted

Reproduce: node tmp/pr11649-verify-20260911-224720/probe.mjs /__w/qwen-code/qwen-code head R1-flag-password-7

Why this is the top finding, bounded honestly:

  • It is not a regression against main — base leaks all 41 shapes, so the PR still strictly improves the situation. It is a regression against this branch's own previous revision, which is what a follow-up round exists to catch.
  • --password is explicitly named in the PR description as a covered vector ("secret-bearing flags (--token, --_authToken, --password)").
  • Short values are the common case for user-chosen passwords (hunter2, Passw0rd, pw123) even though machine tokens are long. The leak surface is real, not theoretical.
  • The env pattern already had this floor, so DB_PASSWORD=hunter2x leaked before too (round-1 B4). The delta's contribution is extending the same blind spot to the flag spelling.
Measured candidate fix — lower the flag floor to 5, keep the {0,64} bounds

A floor of 5 satisfies both goals: it spares every counter the commit was protecting (8192, gpt2 — both 4 chars) while re-closing all nine shapes above.

-const SECRET_FLAG_VALUE = String.raw`(?:"[^"]{10,}"|'[^']{10,}'|[^\s"'\`\\][^\s]{9,})`;
+const SECRET_FLAG_VALUE = String.raw`(?:"[^"]{5,}"|'[^']{5,}'|[^\s"'\`\\][^\s]{4,})`;

Built as a fourth arm (arms/fix5.js) and driven through the identical real-dist harness — 52/52 scripted assertions pass. Witness: 03-candidate-fix-floor5-recloses-9-of-9.png, raw fixcheck.log.

required check result
hostile fixtures go clean R group 3/12 → 12/12 redacted; all 9 regressions re-closed
headline metric real-credential leaks 23/41 → 14/41 (the residual 14 = 10 round-1 B shapes + 4 {0,64} boundary shapes the fix deliberately keeps)
benign fixtures byte-identical (zero collateral) 12/12 identical at both head and fix5 — no new collateral
the delta's own intent survives --max-tokens 8192 and --tokenizer gpt2 still preserved; over-match stays 4/6, does not revert to 6/6
claimed vectors unaffected A group 8/8 still redacted
round-1 residual unaffected B group identical (2/12 redacted at both) — no shape moved either way
bounds retained N group identical to head on all 9 shapes, so the ReDoS hardening is preserved

⚠️ The suite is green with and without this patch — that is F7, not reassurance. Mutant M2 is this exact one-line edit and it scored GREEN 69/69, identical to head (mutate.json). The fixture that would pin it is in F7.

F6 — The {0,64} bound starts missing at 65-character name segments (low)

Bisected exactly, in both patterns and on both sides of the keyword:

id shape prev head
N1 -- + 64×a + token <secret> redacted redacted
N2 -- + 65×a + token <secret> redacted LEAK
N3 --token + 64×b + <secret> redacted redacted
N4 --token + 65×b + <secret> redacted LEAK
N5/N7 env name, 64-char prefix / suffix redacted redacted
N6/N8 env name, 65-char prefix / suffix redacted LEAK
N9 --amazon-bedrock-agent-runtime-session-secret-access-key (37-char prefix, realistic) redacted redacted

Severity is low and I want to bound it rather than inflate it: the bound only bites above 64 characters in a single name segment, and N9 shows a genuinely long real-world flag stays covered. Total matched flag name at the bound is 64 + keyword + 64 ≈ 133 characters. This is reported because it is a new under-match the delta introduced (4 shapes moved redacted → leak), not because 65-character flag names are common. fix5 keeps the bounds, so it does not change this row; widening the bound would trade against the ladder in §(c).

F7 — The suite cannot distinguish a floor of 10 from a floor of 5, so F5 is invisible to the PR's own tests

The mutation matrix — 10 rows: an unmutated control, 8 single-point mutants, and one combination row; each edit applied in the scratch worktree and restored from a pristine copy afterwards (source restored byte-identical: true). Witness: 02-mutation-matrix-floor-and-bounds-unpinned.png, raw mutate.json.

mutant edit suite classification
M0 none (control) GREEN 69/69 control green ⇒ the kills below are meaningful
M1 drop error_message from ERROR_TEXT_PROPERTY_KEYS RED 1 failed / 68 passed positive control, same file ⇒ the runner does collect tests exercising this module
M2 flag floor 10 → 5 (the candidate fix) GREEN 69/69 — SURVIVED coverage gap — nothing pins the floor from above
M3 flag floor removed entirely RED 1 failed / 68 passed killed by the delta's own new test
M4 flag {0,64}* GREEN — SURVIVED coverage gap (see F8)
M5 env {0,64}* GREEN — SURVIVED coverage gap (see F8)
M6/M7 {0,64}{0,63} GREEN — SURVIVED boundary not pinned (expected; no test at 64)
M8 delete error_excerpt from the allowlist GREEN — SURVIVED dead code (round-1 F2, re-confirmed by census)
M9 both bounds reverted together (combination row) GREEN — SURVIVED same as M4+M5; no layered-guard interaction hidden here

M1 is the control that makes M2's survival interpretable, and it lands in the same file as the mutants, failing the intended assertion with a real expected-vs-actual mismatch:

FAIL > QwenLogger > error text redaction > redacts error_message on the enqueue boundary
AssertionError: expected 'Command: git clone https://token:ghs_…' not to contain 'ghs_testsecret123'
Expected: "ghs_testsecret123"
Received: "Command: git clone https://token:ghs_testsecret123@github.com/org/repo.git"

M3 also fails the intended assertion — the delta commit's own new test — which is what proves the floor is pinned only from below:

FAIL > QwenLogger > error text redaction > treats short counters the same in flag and env spellings
AssertionError: expected 'run --max-tokens ***REDACTED*** max_t…' to be 'run --max-tokens 8192 max_tokens=8192'
Expected: "run --max-tokens 8192 max_tokens=8192"
Received: "run --max-tokens ***REDACTED*** max_tokens=8192"

So the suite asserts "a 4-character value must survive" and never asserts "a short secret must be scrubbed". Any floor ≥5 satisfies it. That is precisely the gap F5 fell through.

The fixture that would pin it — measured, not proposed in the abstract. Adding one case and running the suite on both arms (fixture-and-f3.log):

it('redacts a short secret behind a secret flag', () => {
  expect(
    TEST_ONLY.redactTelemetryError('mysql --password hunter2 -h db.internal failed'),
  ).not.toContain('hunter2');
});
arm + fixture suite
head (floor 10) RED — 1 failed / 69 passed (70), AssertionError: expected 'mysql --password hunter2 -h db.intern…' not to contain 'hunter2'
floor 5 GREEN — 70 passed (70)

The fixture discriminates exactly along the axis the regression lives on. F5's fix should ship with it; without it, a future floor change is equally invisible.

F8 — The {0,64} bounds are behaviourally load-bearing but unpinned by any test

M4, M5 and the M9 combination row all survive at 69/69 green, yet the ladder in §(c) proves the bounds change real behaviour by ~170× at the production input ceiling. Classification: coverage gap, not dead code and not redundant defence — the guard decides an outcome (backtracking cost), nothing asserts it. A perf guard is awkward to pin in a unit test, so this is reported as completeness, not as a merge condition; the cheapest useful pin is a wall-clock ceiling on one hostile shape (e.g. H2 at 20 k must complete under ~20 ms), which M4/M5 would fail.

F9 — error_excerpt remains a dead key (Suggestion, carried forward)

Full-repo census at the new head: exactly 1 occurrence of error_excerpt outside node_modules/.git/dist/tmp — the declaration itself at qwen-logger.ts:115. No producer anywhere. M8 (delete it) survives green. Classification unchanged: dead code. AGENTS.md Simplicity First is explicit about this shape ("No 'flexibility' or 'configurability' that wasn't requested"). Harmless as-is.

F3 (carried) — Redaction still consumes quotes; scope is wider than round 1 reported

Identical on prev and head, so it predates the delta (it comes from 31827e9d, which round 1 never verified):

in  (2q): curl -H "Authorization: Bearer ghs_testsecret123" https://api.github.com/repos
out (1q): curl -H "Authorization: ***REDACTED*** https://api.github.com/repos          UNBALANCED 2->1
in  (2q): npm publish --_authToken "npm_testsecret123" --registry x
out (0q): npm publish --_authToken ***REDACTED*** --registry x                          UNBALANCED 2->0
in  (2q): run --password="hunter2secret" --host db
out (0q): run --password=***REDACTED*** --host db                                       UNBALANCED 2->0

4/4 shapes unbalanced. Round 1 measured only the first. The PR's own test asserts the unbalanced string as the expected value, so it is deliberate. Diagnostic impact only — the secret is genuinely gone — but a reader diffing redacted text against a real command line will see quotes vanish, and the flag shapes lose both.

F1 (carried) — 10 residual shapes, unchanged

Re-measured, same set: JSON-shaped text (B1 {"authorization":"Bearer …"}, B2 {"GITHUB_TOKEN":"…"}), colon-separated header/config forms (B7 X-Api-Key: …, B8 password: …), the env \S{10,} floor (B4), non-secret-named credential flags (B5 curl -u, B6 curl --user, B10 mysql -pPass), and prose/bare tokens (B3, B12). Round 1's measured candidate fix for the JSON shape (optional quote around the separator) was not adopted; B1 still leaks. This remains incompleteness, not a regression — base leaks all of them.

Scarier consequences I checked that still do not hold

  • stack bypass — still disproved. No stack: assignment in packages/core/src/telemetry/ (non-test). A populated stack would have bypassed the message redaction, since a Node stack's first line is the message.
  • Retry-path bypass — still disproved. this.events writers: push at line 283 (post-redaction) and unshift at line 1267 (re-queues already-redacted events). No unredacted path to the wire.
  • In-place mutation corrupting a caller — still disproved. 42 internal call sites; the only external callers are integration.test.circular.ts and the unit test. No production observer.
  • ReDoS hang — disproved, and now also hardened. Zero of 32 ladder rungs hit the 30 s cap on either arm; unbounded worst case at the 25 k production ceiling was 121.9 ms.
  • Backtracking blow-up introduced by the new bounds — checked, does not hold. Head is flat (≤1.0 ms) on all 8 shapes at all rungs; bounding cannot make it worse than the unbounded arm, and the ladder confirms it is ~170× better at the top rung.

Not covered

  • Trial merge into current main. main is not reachable locally even after deepening (git fetch --depth=12 origin refs/pull/11649/head fetched the PR branch, not main). I could not confirm the merge is conflict-free or re-measure on a merged tree. HEAD^1 (d0e39357) is by construction the base tip when GitHub built the merge ref, so the control is the right one for this checkout; how far that tip now sits behind main, and whether main has since touched qwen-logger.ts, are not measured. A maintainer should re-check before landing.
  • Base OID discrepancy (carried). The metadata's baseRefOid is 20ecdaf6b2fbbfbd276bf05294e7b672632087e4, unreachable locally. I used the merge-ref base tip HEAD^1 = d0e39357, which is the correct control for a refs/pull/11649/merge checkout. The PR's declared branch point could not be inspected.
  • Per-commit attribution is now possible but was only partly exercised. All 6 PR commits are locally reachable and match the metadata. I verified the aggregate HEAD^1..HEAD diff and isolated the delta (31827e9d41714d9f) as its own arm. I did not exercise commits 7df466e0, 868e8bd7, b5d35fd1, 566d6a74 individually, so the per-claim mapping in 868e8bd7's message (R1-1 … R1-7) is not independently attributed here — round 1 covered several in aggregate and those rows carry forward above.
  • Gates. Ran only the focused suite packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts (69 passed / 69 at head) and tsc --noEmit for packages/core (EXIT=0), the latter proven live by planting a type error and confirming error TS2322: Type 'string' is not assignable to type 'number' at the planted symbol, then restoring to a clean run. Did not run the full packages/core suite, npm run lint, repo-level npm run typecheck, or any integration test.
  • No live network flush. Nothing was POSTed to the RUM endpoint — no credentials in this container, and sending fixtures to a real third-party backend would be inappropriate. flushIfNeeded/flushToRum were hard-blocked and counted (63 blocked attempts per arm). The oracle is createRumPayload(), which flushToRum() serializes verbatim; the wire body was reconstructed, not captured off a socket.
  • Other exfiltration paths. loggers.ts sets attributes['error.message'] for the OTLP exporters, and session-tracing.ts/daemon-tracing.ts carry error text into spans. Separate, opt-in sinks this PR neither touches nor claims; not verified.
  • snapshots as an alternate carrier. Round 1 found none of the four snapshots payloads carries shell error text. Not re-enumerated this round — the delta does not touch that path, and snapshots is outside ERROR_TEXT_PROPERTY_KEYS by design.
  • Windows / CRLF. The description marks Windows ⚠️. The continuation joiner handles \\\r?\n, but I did not drive a full CRLF corpus; all fixtures were LF.

Methodology

CI merge-ref checkout at 164206dd; npm ci and npm run build pre-completed at head. Because this is a follow-up round, the control is four build arms plus a candidate-fix arm, not a single base, and every carried-forward measurement was rebuilt and re-run rather than diffed against previous-report.md.

git fetch --depth=12 origin refs/pull/11649/head made the branch's own history reachable, so the arms are real commits, not reconstructions from commit messages: base = git show d0e39357:…qwen-logger.ts, round1 = git show 566d6a74:… (the head round 1 verified), prev = git show 31827e9d:… (the delta's parent), head = the checked-out 41714d9f, and fix5 = head with one line edited by build-variant.sh. Building round1 was deliberate: without it, "both earlier revisions closed these shapes" would rest on reading a pattern literal (SECRET_VALUE used +, so ≥1 char) rather than on running the code. git diff --stat HEAD^1..HEAD confirms the PR touches only qwen-logger.ts and its test, so a single-file swap inside one worktree is an exact control. All arms were built in tmp/base-tree (a git worktree at HEAD, wired to the root node_modules and to packages/core/node_modules by symlink — the latter is what avoids the ~40 TS2307 @opentelemetry/* errors round 1 hit); each build was tsc --build, ~33 s, exit 0. The worktree was removed after the cells were captured and git status --porcelain is empty.

Workspace-link trap handled explicitly. readlink -f node_modules/@qwen-code/qwen-code-core resolves to /__w/qwen-code/qwen-code/packages/core — the head tree — so a naive control would silently load head code. Every harness therefore imports by absolute dist/ path, and probe.mjs hard-fails unless realpathSync of the loaded module stays inside tmp/base-tree; that check is recorded per arm as realpathInsideTree: true. Arm identity is pinned two ways. First, each saved arm module has a distinct sha256 prefix — base 906b79d4, round1 8e9b80b0, prev 737981e0, head 3fa8341c, fix5 438e2e79. Second, a symbol census recorded in each probe's census block:

arm redactTelemetryError SECRET_FLAG_VALUE {0,64} unbounded [A-Za-z0-9_-]*
base absent absent absent absent
round1 (566d6a74) present absent absent present
prev (31827e9d) present absent absent present
head (41714d9f) present present present (×2) absent

Integrity: the worktree's head build is byte-identical (sha256 3fa8341c…) to the pre-built main-tree dist, and after every mutation the source was restored and re-verified byte-identical, with the main-tree dist still hashing to the same value at the end.

probe.mjs drives the compiled QwenLogger through real log*Event() calls with a fake Config (the only stub), clears the singleton deque between cases so each serialized payload carries exactly one event, and searches that payload for the literal secret. corpus.mjs holds 47 secret-bearing cases in five groups (8 claimed vectors, 12 round-1 siblings, 12 short-flag regression probes, 6 acknowledged over-match, 9 quantifier-boundary bisectors) plus 12 benign byte-identity cases. compare.mjs builds the cell table and ledger; encoding puts the security property on the head arm (a leaking credential is a real fail) and the expected red on the base arm (base leaking is a passing control cell), so fail=23 counts only unexpected outcomes. Group D is excluded from the fail ledger because its secret field is a benign value — counting it would have inflated the headline; the real-credential subtotal (41) is reported separately for that reason.

mutate.mjs applies 8 single-point mutants plus a combination row and an unmutated control (10 rows), locates each edit by a unique line anchor (a [^`]* regex silently no-ops on these literals because they contain an escaped backtick — two mutants reported NO-OP EDIT before I switched), runs the focused suite per mutant, and restores from a pristine copy. vacuity-detail.mjs re-runs selected mutants to capture the raw AssertionError text quoted in F7. fixture.mjs splices the proposed discriminating fixture into the worktree test file, runs the suite on both arms, and restores both files byte-identically. redos-rung.mjs + redos-driver.sh run one ladder rung per child process under timeout, so a cap is a recorded result rather than a hung harness. fixcheck.mjs adjudicates the fix5 arm against all five corpus groups. finalize.mjs merges every ledger and re-runs the typecheck gate.

Raw logs: base-probe.json, round1-probe.json, prev-probe.json, head-probe.json, fix5-probe.json (plus the matching *-probe.err), ab-table.log, ab-ledger.json, fixcheck.log, fixcheck-ledger.json, mutate.json, vacuity-detail.log, fixture-and-f3.log, redos-ladder.jsonl, final-ledger.log, assertions-detail.json (per-assertion ledger). Compiled arm modules are kept under arms/{base,round1,prev,head,fix5}.js so any cell can be re-run without rebuilding. Harnesses are .mjs (plus build-variant.sh and redos-driver.sh) and rerunnable as-is.

How to re-run. The harnesses splice a saved arm module into a build worktree, which was removed at the end of this round, so re-running needs it recreated first (the compiled arms themselves are preserved, so no per-arm rebuild is required):

cd /__w/qwen-code/qwen-code
git fetch --depth=12 origin refs/pull/11649/head
git worktree add tmp/base-tree HEAD
ln -s "$PWD/node_modules" tmp/base-tree/node_modules
ln -s "$PWD/packages/core/node_modules" tmp/base-tree/packages/core/node_modules
(cd tmp/base-tree/packages/core && npx tsc --build)   # ~33 s; creates the dist tree spliced into
ART=tmp/pr11649-verify-20260911-224720
for a in base round1 prev head fix5; do node "$ART/probe.mjs" "$PWD" $a > "$ART/$a-probe.json"; done
node "$ART/compare.mjs" && node "$ART/fixcheck.mjs" && node "$ART/finalize.mjs"
git worktree remove --force tmp/base-tree             # leave the tree clean

Note on PR text. The description and commit messages were treated as untrusted hypotheses, not evidence. No instruction in them attempted to steer this verification.

Flakiness gate log

rounds=5 files=1 skipped=0
file packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: (cd packages/core) npx --no-install vitest run ./src/telemetry/qwen-logger/qwen-logger.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: 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/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 2 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 3 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 4 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 5 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)

Evidence images

01-three-arm-ab-base-vs-prev-vs-head

02-mutation-matrix-floor-and-bounds-unpinned

03-candidate-fix-floor5-recloses-9-of-9

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 41714d9fd0f7eaadd89dddb2646e79802315cb2d — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 41714d9fd0f7eaadd89dddb2646e79802315cb2d既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 11, 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: 323 passed · 35 failed · 358 total

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

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

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

脚本断言:323 通过 · 35 失败 · 358 总计

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

Verification report (report.md, truncated)

# PR #11649 Deep Verification (round 3) — `fix(core): redact error text in usage-statistics telemetry sink`

**Verdict: `findings`** — assertions **323 pass / 35 fail / 358 total**.
Verified head: `41714d9fd0f7eaadd89dddb2646e79802315cb2d` (`git rev-parse HEAD^2`)
A/B control (base tip): `d0e3935703776ef1529e840ef9ebda024ee64406` (`HEAD^1`)
Round-2 verified head: `41714d9fd0f7eaadd89dddb2646e79802315cb2d` — **the same commit**; round-2 base control `d0e39357` — **the same commit**
Earlier revisions built as arms this round: `566d6a74` (round-1 head) and `31827e9d` (`31827e9d` = the delta's parent)

**The head has not moved since round 2, and every round-2 finding still stands — but walking the sibling doors of the same root cause found a second, worse regression that round 2 did not probe.** Commit `31827e9d` made the value patterns *balanced-quote aware*; that silently made an **unterminated** quote and a **backtick-delimited** value unredactable. Driven end to end through the real production truncation module, a **full 25-character credential reaches the wire** at head (`25/25` chars survive), where the round-1 revision `566d6a74` left `1/25`. The F5 candidate fix round 2 measured does **not** close it (`fix5` = `25/25`); a two-line fix that does is measured below (`fixall` = `1/25`, real-credential leaks `33/64 → 17/64`, benign byte-identity `12/12`).

Round 2's three named gaps are closed this round: the **trial merge into current `main`** is conflict-free with `+25 passing / +0 failing` against a byte-identical set of pre-existing failures; the **CRLF/Windows** corpus holds on security (`8/8`) but fails the description's verbatim-preservation claim; and the **full `packages/core` suite** was run on both the merged tree and plain `main`.

<details>
<summary>中文摘要</summary>

**结论:`findings`** —— 断言 **323 通过 / 35 失败 / 共 358**。已验证 head:`41714d9f`;A/B 对照基线:`d0e39357`(`HEAD^1`)。**本轮 head 与上一轮完全相同**(commit OID 一致,base OID 也一致),因此上一轮全部发现原样成立;本轮的价值在于把同一根因的**邻接形态**逐个走过一遍,以及补齐上一轮明确列为「未覆盖」的三项。

**A/B 结论**:核心主张仍然成立且是 load-bearing 的。本轮做了**七臂对照**(base / 上一轮 head `566d6a74` / delta 父提交 `31827e9d` / 当前 head / 三个候选修复臂),见「A/B(七个构建臂)」。以真实凭据形态计(共 64 个,D 组是 PR 自己承认的过度匹配、取值本身无害,故不计入泄漏口径):**base 64/64 全泄漏 → `566d6a74` 16/64 → `31827e9d` 17/64 → 当前 head 33/64 → 候选修复 fixall 17/64**。四个入队载体在 head 上全部干净、在 base 上全部泄漏,收口点确实唯一。

**本轮最重要的新发现(F10)**:`31827e9d` 把取值模式改成「必须配对引号」,副作用是**未闭合引号**与**反引号包裹**的取值彻底无法脱敏。这不是手搓形态:用真实的 `truncateAndSaveToFile`(生产参数 `threshold=25000, previewChars=4000, keep=both, lines=Infinity`)跑真实的 `redactTelemetryError`,**25 个字符的凭据 25/25 全部原样进入上报体**;而上一轮 head `566d6a74` 只剩 1/25。141 个偏移里有 26 个会切出未闭合引号。上一轮实测的 F5 候选修复(下限 10→5)**不能关闭它**(fix5 仍 25/25)。已实测两行修复 `fixall`:**1/25**,真实凭据泄漏 33/64 → **17/64**,R 组 9/9 回归全部重新关闭、X 组 5/5 关闭、A 组 8/8 保持、12/12 无害文本逐字节不变、delta 自己的目标(`--max-tokens 8192`)依然保持。最刺眼的形态是 `curl -H "Authorization: ***REDACTED*** \`ghs_backticksec1\`" ...` —— **脱敏标记紧挨着存活的凭据**,人眼看像是已脱敏。

**其余新发现**:F11 行连接符(`\`+换行)经实测**确属安全 load-bearing**(跨续行的 URL 凭据:head 全脱敏,删掉后 11 字符片段存活),但变异体 M12 全绿存活 69/69 → 属**覆盖缺口**;F12 CRLF/CR 下无害文本**未逐字节保留**(`\r` 被 `CONTROL_CHARS_RE` 删掉,`line1\rline2` → `line1line2`),与描述「非敏感文本原样保留」矛盾,但安全侧 8/8 成立,严重度低。

**上一轮发现的复核(全部重新实测,未比对旧报告)**:F1 原样成立(同 10 个形态 B1–B8/B10/B12);F2/F9 `error_excerpt` 仍是死键(全仓库 1 处,M8 存活);F3 引号被吞成立(本轮语料 3/11 不配对);F4 过度匹配改善(D 组无害取值保留 2/6 → 4/6,C 组逐字节 11/12 → 12/12,翻转的正是 C3);F5 原样成立(9/9,`566d6a74` 与 `31827e9d` 两版均全部脱敏);F6 原样成立(N2/N4/N6/N8 四例,N9 真实长 flag 仍覆盖);F7 原样成立(M2 全绿存活,正向对照 M1 在同文件内变红);F8 原样成立且更强(M4/M5/M9 全绿;阶梯实测 25k 处 prev 82.7 ms → head 0.19 ms,约 **442×**);C1/C2 原样成立;C3 **恶化**(欠匹配由 10/20 增至 33/64)。

**上一轮三项「未覆盖」本轮已覆盖**:与当前 `main`(`fdb33117`,领先 PR 分叉点 21 个 commit)的**试合并无冲突**,合并后 diff 与 PR diff 完全一致(2 文件 / 362 行),`main` 侧 0 个 commit 触及这 4 个相关文件;合并树上聚焦套件 **69/69**、`tsc --build` 干净;**全量 `packages/core` 套件**在合并树与纯 `main` 上各跑一次做 A/A:失败文件集合与 66 条 FAIL 行**逐字节相同**,差异为 **+25 通过 / +0 失败**(正是本 PR 新增的 25 个用例)。

**已排查、结论仍为「不成立」的更严重猜测**:`stack` 旁路(telemetry 内非测试代码 0 处 `stack:` 赋值);重试路径旁路;原地修改污染调用方;ReDoS 挂死(32 个阶梯档位无一触及 30 s 上限);`properties.error` 载体受 `logPrompts` 门控 —— 实测该设置**默认 true**,故该载体默认开启,且关掉后 base 与 head 均不写入(说明不是脱敏造成的差异)。

**未覆盖范围**:真实网络上报(无任何数据出容器:`socketAttempts=0`,`tls.connect` 已打补丁,`flushToRum`/`flushIfNeeded` 硬阻断并计数);仓库级 lint;逐 commit 归因(6 个 commit 本地可达,但只把 `31827e9d`→`41714d9f` 作为独立 delta 臂实测);OTLP 侧 `error.message` 属另一条上报路径;`snapshots` 载体;集成测试。

</details>

## Previous-finding status (round 2 → this head)

Round 2 verified `41714d9f` against base `d0e39357`. **Both OIDs are unchanged this round**, so the input closure for every round-2 measurement is provably identical at the source level (same commit ⇒ same tree). I did not rely on that: every row below was **rebuilt and re-run with harnesses written from scratch this round**, and my from-scratch arm builds reproduce round 2's published `sha256` prefixes exactly (`base 906b79d4`, `round1 8e9b80b0`, `prev 737981e0`, `head 3fa8341c`, `fix5 438e2e79`) — an independent cross-check that both rounds measured the same binaries. Where my corpus differs from round 2's, I report my own counts and say so.

| # | Round-2 finding | Sev | Status at `41714d9f` | Re-measured evidence |
| --- | --- | --- | --- | --- |
| F5 | Delta commit reopens 9 short-secret flag leaks | highest | **stands — exactly 9** | `R1…R9` leak at head; `prev` and `round1` redact **12/12**; `fix5` recloses **9/9**. `ab-table.log` |
| F6 | `{0,64}` bound misses at 65-char name segments | low | **stands — exactly 4** | `N2/N4/N6/N8` leak at head, redacted at `prev`; `N9` (realistic 37-char flag) still redacted |
| F7 | Suite cannot distinguish floor 10 from floor 5 | — | **stands** | Mutant `M2` **GREEN 69/69**; positive control `M1` **RED in the same file** |
| F8 | `{0,64}` bounds load-bearing but unpinned | — | **stands, effect larger on my ladder** | `M4`/`M5`/`M9` all **GREEN 69/69**; at the 25 k ceiling `prev` **82.7 ms** → `head` **0.19 ms** (**~442×**); 0 of 16 rungs capped |
| F9/F2 | `error_excerpt` is a dead key | Suggestion | **stands** | Repo-wide census **1** hit (`qwen-logger.ts:115`, the declaration); `M8` **GREEN** |
| F1 | 10 residual credential shapes | highest | **stands — same 10** | `B1,B2,B3,B4,B5,B6,B7,B8,B10,B12` leak; `B9`/`B11` redacted. Incompleteness, not a regression (base leaks all 12) |
| F3 | Redaction consumes quotes → unbalanced output | Nit | **stands** | **3/11** quoted redacted shapes come out unbalanced at head: `A2` 2→1, `X9` 2→0, `W4` 2→1 |
| F4 | Acknowledged over-match | info | **improved** | Benign values behind secret-named markers preserved **2/6 (`prev`) → 4/6 (`head`)**; benign byte-identity `C3` flips MUTATED → IDENTICAL, **11/12 → 12/12** |
| C1 | "any error-text key … added later" ≠ hardcoded 3-key allowlist | — | **stands** | `ERROR_TEXT_PROPERTY_KEYS = ['error_message','error_excerpt','error']` (line 115) |
| C2 | Declared `stack` exclusion excludes nothing | — | **stands** | `grep -rn "stack:" packages/core/src/telemetry/ --include=*.ts` excl. tests → **0** hits |
| C3 | Risk section names only over-matching | — | **worsened** | Under-matching is now **33/64** real-credential shapes on this round's corpus (round 2: 23/41 on its own) |
| Corr. 3 | Three different value floors in one function | — | **stands** | `R11` `Authorization: Bearer xyz` (3 chars) **redacted**, `R12` `Authorization: Basic YWJj` (4 chars) **redacted**, `R1` `--password hunter2` (7 chars) **leaks** |
| — | Trial merge into current `main` | — | **now covered** | Conflict-free into `fdb33117`; `+25 passing / +0 failing`; 66 FAIL lines byte-identical to plain `main` |
| — | Windows / CRLF corpus | — | **now covered** | Security holds **8/8** (`W` group); fidelity does **not** (new F12) |
| — | Full `packages/core` suite | — | **now covered** | `72 failed / 25701 passed` merged vs `72 failed / 25676 passed` on plain `main` |
| — | In-place mutation cannot corrupt a caller | — | **stands (re-confirmed)** | 42 internal `enqueueLogEvent` call sites; `this.events.push` at one site only |
| — | Per-commit attribution | — | **partly exercised** | All 6 commits reachable; only `31827e9d → 41714d9f` isolated as its own arm |

## Central claim and A/B (seven build arms)

**Central claim.** Error text enqueued into the usage-statistics (RUM) sink is scrubbed of credentials at a single choke point.

**Secondary claims.** (a) The choke point is genuinely single. (b) Non-sensitive text survives verbatim. (c) The `{0,64}` bounds prevent quadratic backtracking. (d) Flag and env value floors are unified.

**Oracle.** `flushToRum()` serializes `createRumPayload()` with `safeJsonStringify` and POSTs it, so that payload *is* the wire body. The harness drives the real `log*Event()` methods on each arm's **compiled `dist/`** and searches the serialized payload for the literal secret. Nothing about the unit under test is stubbed except `Config`. Egress is blocked twice over and counted: `flushToRum`/`flushIfNeeded` replaced (**88–92 blocked calls per arm** — 92 on the five arms that also ran the carrier pass, 88 on `fixq`/`fixall`, probed over the corpus only) and `net.Socket.prototype.connect` / `tls.connect` patched — **`socketAttempts = 0` on every arm**, so no fixture left the container.

Witness: **`01-seven-arm-ab-real-credential-leaks.png`** (the arm table and the truncation result as they printed).

| arm | commit | real-credential leaks (of 64) | R: short flag secrets leaking (of 12) | X: delimiter siblings leaking (of 12) | benign byte-identical (of 12) |
| --- | --- | --- | --- | --- | --- |
| **base** (control) | `d0e39357` | **64 / 64** | 12 | 12 | 12/12 (no redaction at all) |
| **round1** | `566d6a74` | 16 / 64 | **0** | 3 | 9/12 |
| **prev** (delta's parent) | `31827e9d` | 17 / 64 | **0** | 6 | 11/12 |
| **head** (under test) | `41714d9f` | **33 / 64** | **9** | **8** | **12/12** |
| *fix5* (round 2's candidate) | head + floor 5 | 22 / 64 | **0** | 7 | 12/12 |
| *fixq* (new, §F10) | head + unterminated-quote/backtick | 28 / 64 | 9 | **3** | 12/12 |
| ***fixall*** (new, §F10) | head + both | ***17 / 64*** | ***0*** | ***2*** | ***12/12*** |

Per-group leak counts (`ab-table.log`; for `D` the value is benign so "preserved" is good, and `D` is excluded from the 64):

```
grp  n   base  round1  prev  head  fix5  fixq  fixall
A    8   8     0       0     0     0     0     0      <- the 8 vectors the description names: closed, and stay closed
B    12  12    10      10    10    10    10    10     <- F1 residual: unchanged, incompleteness not regression
R    12  12    0       0     9     0     9     0      <- F5: 9 short flag secrets reopened by the delta
N    9   9     0       0     4     4     4     4      <- F6: {0,64} boundary
X    12  12    3       6     8     7     3     2      <- NEW F10: delimiter siblings, regressed by 31827e9d
W    8   8     2       0     0     0     0     0      <- NEW: CRLF/tab/ANSI security holds at head
T    3   3     1       1     2     1     2     1      <- NEW: truncation residues below the value floor
D    6   -     4 red   4 red 2 red 2 red 2 red 2 red  <- benign over-match: head over-matches 2, prev 4
C    12  0     3       1     0     0     0     0      <- benign byte-identity: head is the best arm
M    6   0     6       6     6     6     6     6      <- benign text the normalisation pass mutates (F12)
```

`round1` is built as an arm so both regression claims are measurements rather than readings of a pattern literal: **`566d6a74` redacted all 12 short-flag shapes and 9 of 12 delimiter shapes; head redacts 3 and 4.**

**Choke-point carrier coverage (secondary claim (a) — holds).** Same secret driven end to end through all four real carriers:

| carrier | base | head |
| --- | --- | --- |
| `properties.error_message` via `logToolCallEvent` | **LEAK** | clean |
| top-level `message` via `logInvalidChunkEvent` (exception) | **LEAK** | clean |
| `properties.error` via `logHookCallEvent` | **LEAK** | clean |
| top-level `message` + `properties.error_message` via `logApiErrorEvent` (resource) | **LEAK** | clean |

Structurally single: `this.events.push` appears at exactly one site (inside `enqueueLogEvent`, after `redactEventErrorText`); the only other writer is `unshift` in `requeueFailedEvents`, which re-queues already-redacted events. **New detail this round:** `logHookCallEvent` writes `properties['error']` only when `getTelemetryLogPromptsEnabled()` is true. That setting **defaults to `true`** (`config.ts:2806` and `:7884`, both `?? true`), so the carrier is default-on and the `error` key in the allowlist is load-bearing — confirmed by mutant `M14` going **RED** on the test `redacts the hook error property on the enqueue boundary`. With the flag forced off, neither arm writes the carrier at all, so the clean result is not an artefact of redaction.

**Backtracking ladder (secondary claim (c) — holds).** Two hostile shapes × 4 rungs (2 k / 5 k / 20 k / 25 k), one rung per child process under `timeout 30`; 25 k is the real production ceiling (`DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000`, `config.ts:793`). **Zero of 16 rungs hit the cap on either arm.** Raw: `redos-ladder.jsonl`.

| shape | prev 2 k → 20 k → 25 k | head 2 k → 20 k → 25 k |
| --- | --- | --- |
| H1 env, many keywords, no `=` | 0.29 → 19.98 → **31.01 ms** | 0.08 → 0.15 → **0.16 ms** |
| H2 flag, many keywords, no separator | 0.64 → 52.96 → **82.69 ms** | 0.09 → 0.18 → **0.19 ms** |

Superlinear on `prev` (H2 per-kchar 0.32 → 2.65 → 3.31 ms), flat on `head`. Round 2 reported the same direction with different absolutes (121.9 → 0.7 ms); the shapes are not identical, so the numbers are not comparable, only the conclusion: **the bound is load-bearing on the curve, and the thing it fixes was never a hang** — 82.7 ms at the largest input production can produce, on an already-failing command.

## Corrections

Corrections to the **PR description / commit messages**, not requests to change code.

1. **C1 stands.** "any error-text key — including ones added later — is scrubbed" is not what the code does: `ERROR_TEXT_PROPERTY_KEYS` is a hardcoded three-element allowlist. What the PR achieves is *centralising* that list at the sink — real, but a weaker contract, and the maintenance obligation survives.
2. **C2 stands.** The Risk section says `stack` is "intentionally not redacted"; no code in `packages/core/src/telemetry/` assigns `stack` to a Rum event (0 hits). The declared exclusion excludes nothing.
3. **Correction 3 stands.** There are three value floors in one function, not one: 1 char for `authorization`, 10 for flags, 10 for env. `R11`/`R12` (3- and 4-char values behind `Authorization`) are redacted while `R1` (7 chars behind `--password`) is not.
4. **NEW — "non-sensitive text is preserved verbatim" is false for CR-containing text.** The Reviewer Test Plan asserts verbatim preservation. `CONTROL_CHARS_RE` (`textUtils.ts:11`) is `[\u0000-\u001f\u007f-\u009f]`, which includes `\r` (0x0D), and `redactTelemetryError` strips it per line. `line1\r\nline2\r\n` → `line1\nline2\n`, and `line1\rline2` → `line1line2` (two lines become one). Tabs and ANSI are documented in the function's own docblock; the `\r` removal is not. See F12 — telemetry-only, cosmetic, but the claim as written does not hold.
5. **NEW — commit `31827e9d`'s message understates its own cost.** It says quote-awareness makes "interior quotes and quoted values containing spaces redact whole instead of leaking a suffix". That gain is real and I measured it (`X9`, a quoted value containing spaces, redacts whole at `prev`/`head`). The message does not mention that requiring a *balanced* quote makes an **unterminated** quote and a **backtick-delimited** value unredactable — five shapes that `566d6a74` closed (`X1,X2,X5,X6,X7`). This is the same shape as round 2's correction 4: a stated consistency gain obtained by moving one pattern's behaviour, with the cost landing elsewhere.

## Findings

### F10 — An unterminated or backtick-delimited secret value is not redacted at all, and the production truncation path produces that shape (highest)

`31827e9d` replaced the optional-delimiter value pattern with balanced-quote alternatives:

```
round1 (566d6a74): SECRET_VALUE = ["'\`]?[^\s"'\`\\]+["'\`]?     <- OPTIONAL leading delimiter, incl. backtick
prev/head        : SECRET_VALUE = (?:"[^"]+"|'[^']+'|[^\s"'\`\\][^\s]*)
                                     ^^^^^^^^ requires a CLOSING quote   ^^^^^^^^^^ refuses a leading ` " ' \
```

Three consequences, all measured through the real compiled `dist`:

| id | error text | round1 | prev | head | fix5 | fixq/fixall |
| --- | --- | --- | --- | --- | --- | --- |
| X1 | ``curl -H "Authorization: Bearer `ghs_backticksec1`" https://api.github.com`` | redacted | **LEAK** | **LEAK** | **LEAK** | redacted |
| X2 | ``run --password `ghs_backticksec2` failed`` | redacted | **LEAK** | **LEAK** | **LEAK** | redacted |
| X5 | `run --password "ghs_untermquote1` | redacted | **LEAK** | **LEAK** | **LEAK** | redacted |
| X6 | `run --password 'ghs_untermquote2` | redacted | **LEAK** | **LEAK** | **LEAK** | redacted |
| X7 | ``authorization=`ghs_backtickauth` curl failed`` | redacted | **LEAK** | **LEAK** | **LEAK** | redacted |

The sharpest output is X1 at head — **the redaction marker sits immediately before the surviving credential**, so the text reads as redacted to a human while the secret is in the payload:

```
round1  "curl -H \"Authorization: ***REDACTED***\" https://api.github.com"
head    "curl -H \"Authorization: ***REDACTED*** `ghs_backticksec1`\" https://api.github.com"
```

(`Authorization: Bearer` → the optional scheme group is skipped, `SECRET_VALUE` matches `Bearer` alone, and the backtick-led credential is left untouched.)

**This is reachable through the default production path, not just hand-built.** `truncate-e2e.mjs` drives the **real** `truncateAndSaveToFile` with the exact limits `shell.ts:2990` passes (`threshold=25000, previewChars=min(4000,threshold), keep='both', lines=Infinity`) into the **real** `redactTelemetryError`; nothing is mocked at either end. `keep:'both'` slices an over-budget line by characters, so the head preview can end after a credential's opening quote but before its closing one. The chain that carries it to the wire is `shell.ts:2990` (truncate) → `shell.ts:3102` (`error.message = llmContent`, the *truncated* text) → `loggers.ts:318` (`logToolCallEvent`) → `properties.error_message` → `redactTelemetryError`.

| arm | credential chars reaching the wire (of 25) |
| --- | --- |
| base (no redaction) | **25 / 25** |
| round1 `566d6a74` | **1 / 25** |
| prev `31827e9d` | **25 / 25** |
| **head `41714d9f`** | **25 / 25** |
| fix5 (round 2's candidate) | **25 / 25 — does not close it** |
| fixq / fixall | **1 / 25** |

The cut is not a knife-edge: **26 of 141** swept offsets leave an unterminated quote. Witness: **`03-truncation-e2e-full-credential-survives-at-head.png`**.

Reproduce:

```bash
cd /__w/qwen-code/qwen-code
ART=tmp/pr11649-verify-20260911-235152
# recreate the build worktree (removed at the end of this round); the compiled arms are preserved
git worktree add tmp/build-tree HEAD
ln -s "$PWD/node_modules" tmp/build-tree/node_modules
ln -s "$PWD/packages/core/node_modules" tmp/build-tree/packages/core/node_modules
(cd tmp/build-tree/packages/core && npx tsc --build)          # ~37 s
for a in round1 prev head fix5 fixq fixall; do
  cp "$ART/arms/$a.js" tmp/build-tree/packages/core/dist/src/telemetry/qwen-logger/qwen-logger.js
  node "$ART/truncate-e2e.mjs" "$PWD" $a | grep -E 'maxSurviving|shape|out'
done
git worktree remove --force tmp/build-tree
```

**Bounded honestly — what does *not* hold:**
- It is **not** a regression against `main`: base leaks every shape, so the PR still strictly improves the situation (64/64 → 33/64 on this corpus). It is a regression against **this branch's own earlier revision** `566d6a74`, which is what a follow-up round exists to catch.
- No **new** exfiltration channel is opened — the sink, the endpoint and the default-on setting are all pre-existing and are what this PR set out to fix.
- The truncated full output is also written to a **local** file (`outputFile`); that file is not uploaded and is not part of this finding.
- I did **not** drive the real `ShellTool` end to end (it needs a full `Config` and a subprocess). Both ends of the chain are real compiled modules and the links between them are cited by line; per the shape/cause distinction, this **reproduces the production wire shape**, and the trigger (a character cut inside a quoted value) is produced by the real truncator rather than synthesised.

<details>
<summary>Measured candidate fix — accept an unterminated quote, and allow a leading backtick</summary>

Two lines, both strictly widening what counts as a value; nothing else changes.

```diff
-const SECRET_VALUE = String.raw`(?:"[^"]+"|'[^']+'|[^\s"'\`\\][^\s]*)`;
+const SECRET_VALUE = String.raw`(?:"[^"]+"|'[^']+'|"[^"]+|'[^']+|[^\s"'\\][^\s]*)`;

-const SECRET_FLAG_VALUE = String.raw`(?:"[^"]{10,}"|'[^']{10,}'|[^\s"'\`\\][^\s]{9,})`;
+const SECRET_FLAG_VALUE = String.raw`(?:"[^"]{10,}"|'[^']{10,}'|"[^"]{10,}|'[^']{10,}|[^\s"'\\][^\s]{9,})`;
```

The balanced alternatives stay first, so already-correct shapes are unaffected; the new alternatives only fire when no closing quote exists. Dropping the backtick from the exclusion class makes flag/authorization consistent with `ENV_SECRET_PATTERN`, whose third alternative `[^\s&;,]{10,}` **already** permits backticks — that inconsistency is why `X3` (the env spelling of the same shape) was redacted at `prev`/`head` while `X1`/`X2` were not. The `\` exclusion that the docblock actually justifies is kept.

Built as two arms (`fixq` = these two lines; `fixall` = these two lines **plus** round 2's floor 5) and driven through the identical real-`dist` harnesses — **every `fix/*` assertion in the ledger passes (7/7)**.

| required check | fixq | fixall |
| --- | --- | --- |
| hostile fixtures go clean — X regressions | **5/5 closed** | **5/5 closed** |
| hostile fixtures go clean — R regressions (F5) | 0/9 (floor untouched) | **9/9 closed** |
| truncation end-to-end | **25/25 → 1/25** | **25/25 → 1/25** |
| headline metric | 33/64 → 28/64 | **33/64 → 17/64** |
| benign fixtures byte-identical (zero collateral) | **12/12** | **12/12** |
| claimed vectors unaffected (`A` group) | **8/8** | **8/8** |
| the delta's own intent survives (`C3`) | **identical** | **identical** |
| bounds retained (ReDoS hardening) | `{0,64}` ×4 present | `{0,64}` ×4 present |

`fixall` is the one to ship: it closes both regression classes and lands at **17/64**, matching `round1`'s 16/64 on this corpus (the one difference is `T3`, an env-spelling residue below the env floor, which `fixall` deliberately does not touch).

Residual after `fixall`, stated rather than hidden: `X4` (a **double** backslash before the value — the separator skips only one) and `X8` (`"abc"`, 3 chars inside quotes, below any floor). `X4` leaked at every revision including `round1`, so it is pre-existing, not a regression.

⚠️ **The suite is green with and without this patch — that is F7/F11, not reassurance.** Mutants `M10` (*is* `fixq`) and `M11` (*is* `fixall`) both scored **GREEN 69/69**. The fixtures that would pin them are in F11.

</details>

### F11 — The line-continuation joiner is security-load-bearing but unpinned by any test (coverage gap)

Mutant `M12` deletes one line, `const joined = text.replace(/\\\r?\n[ \t]*/g, '');` → `const joined = text;`. The suite stays **GREEN 69/69**. On my main corpus the deletion changed **0 outcomes**, which would normally classify it as dead code — so I escalated to the shape the commit message actually names ("including a URL credential") and the classification flips:

| shape | head | m12 (joiner removed) | round1 |
| --- | --- | --- | --- |
| U1 `https://user:ghs_urlcont\` + LF + `secret1@github.com/…` | **redacted** | **fragment `ghs_urlcont` survives** | fragment survives |
| U2 same across CRLF | **redacted** | **fragment survives** | fragment survives |
| U3 `--password ghs_flagcont\` + LF + `secret end` | `--password ***REDACTED*** end` | `--password ***REDACTED***\n  secret end` | residue |
| U4 unsplit URL credential (control) | redacted | redacted | redacted |

```
U1 head    "git clone https://***REDACTED***@github.com/org/repo.git"
U1 m12     "git clone https://user:ghs_urlcont\\\n  secret1@github.com/org/repo.git"
```

The mechanism: `redactUrlCredentials`'s `(?:[^/\s]+@)+` cannot span a newline, so only the joiner makes a continuation-split URL credential redactable. **Classification: coverage gap, not dead code and not redundant defence** — the guard decides a security outcome and nothing asserts it. It is also the one guard in this PR whose deletion is invisible *and* whose behaviour is not duplicated by a sibling hunk: `U3` shows the separator's `(?:\\\s*)?` covers the flag spelling (which is why `M12` moved 0 cases in my corpus), but nothing else covers the URL spelling.

Cheapest pin, and it should ship with F10's fix:

```ts
it('redacts a URL credential split across a shell line continuation', () => {
  expect(
    TEST_ONLY.redactTelemetryError(
      'git clone https://user:ghs_urlcont\\\n  secret1@github.com/org/repo.git',
    ),
  ).not.toContain('ghs_urlcont');
});
```

### F7 (carried) — The suite cannot distinguish a floor of 10 from a floor of 5, nor head from head-plus-fix

15 rows: an unmutated control, 13 single-point mutants and one combination row; each applied by exact-string match (uniqueness enforced — a non-matching anchor is a hard error, never a silent no-op), then restored from a pristine copy (`source restored byte-identical: true`, `pristine sha=39348522b547`). Witness: **`02-mutation-matrix-joiner-floor-bounds-unpinned.png`**, raw `mutate.json`.

| mutant | edit | suite | classification |
| --- | --- | --- | --- |
| **M0** | none (control) | **GREEN 69/69** | control green ⇒ the kills below are meaningful |
| **M1** | drop `error_message` from the allowlist | **RED 1 failed / 68 passed** | **positive control, same file** ⇒ the runner does collect tests exercising this module |
| **M2** | flag floor **10 → 5** (round 2's candidate fix) | **GREEN — SURVIVED** | coverage gap (F5 invisible) |
| M3 | flag floor removed entirely | **RED 1 failed / 68 passed** | killed by the delta's own new test |
| M4 | flag `{0,64}` → `*` | GREEN — SURVIVED | coverage gap (F8) |
| M5 | env `{0,64}` → `*` | GREEN — SURVIVED | coverage gap (F8) |
| M6 / M7 | `{0,64}` → `{0,63}` | GREEN — SURVIVED | boundary not pinned (no test at 64) |
| M8 | delete `error_excerpt` from the allowlist | GREEN — SURVIVED | **dead code** (F9) |
| **M9** | both bounds reverted **together** (combination row) | GREEN — SURVIVED | no layered-guard interaction hidden here |
| **M10** | **REVERSE: apply `fixq`** | **GREEN — SURVIVED** | suite pins nothing on the delimiter axis (F10 invisible) |
| **M11** | **REVERSE: apply `fixall`** | **GREEN — SURVIVED** | suite cannot tell head from head-plus-fix |
| **M12** | remove the line-continuation joiner | GREEN — SURVIVED | **coverage gap on a security guard** (F11) |
| M13 | remove the per-line `stripAnsiAndControl` pass | **RED 2 failed / 67 passed** | killed — the ANSI hardening *is* pinned |
| M14 | drop `error` from the allowlist | **RED 1 failed / 68 passed** | killed — the R1-1 fix *is* pinned |

`M1` lands in the same file as the mutants and fails the **intended** assertion with a real expected-vs-actual mismatch, so `M2`/`M10`/`M11`/`M12` cannot be explained by a harness that never ran:

```
FAIL > QwenLogger > error text redaction > redacts error_message on the enqueue boundary
```

`M3` fails the delta's own new test, which is what proves the floor is pinned **only from below** — the suite asserts "a 4-character value must survive" and never asserts "a short secret must be scrubbed":

```
FAIL > QwenLogger > error text redaction > treats short counters the same in flag and env spellings
```

`M10`/`M11` are the reverse mutation and the more serious half: a suite that cannot tell head from head-plus-fix has its coverage gap exactly where the next regression will land — and F10 is that regression, already landed on this branch.

### F5 (carried) — 9 short-secret flag leaks, unchanged

Re-measured with fresh fixtures: `R1…R9` leak at head (`hunter2`, `Passw0rd`, `abc123`, `sk-12345`, `s3cr3tval`, `cr3dX`, `"pw123"`, `npm_abc12`, a 9-char value); `R10` (10 chars) is redacted, bisecting the floor exactly. `prev` and `round1` redact **12/12**; `fix5` and `fixall` reclose **9/9**. `--password` is named in the description as a covered vector. Not a regression against `main` (base leaks all 12); a regression against this branch's own earlier revisions. Round 2's candidate fix is confirmed and subsumed by `fixall`.

### F6 (carried) — The `{0,64}` bound starts missing at 65-character name segments (low)

Exactly 4 shapes moved redacted → leak: `N2` (65-char prefix, flag), `N4` (65-char suffix, flag), `N6`/`N8` (65-char prefix/suffix, env). `N1/N3/N5/N7` at 64 chars are redacted, bisecting the bound. `N9` — a realistic 57-char flag, `--amazon-bedrock-agent-runtime-session-secret-access-key` — **stays redacted**, so the bound only bites above 64 characters in a single name segment. `fix5`/`fixq`/`fixall` all keep the bounds, so none of them changes this row; widening it would trade against the ladder above.

### F12 — Benign CR-containing text is not preserved verbatim (low)

New, from the CRLF corpus round 2 listed as uncovered. The **security** side holds: all 8 `W` shapes (CRLF-terminated, tab-separated, ANSI-hidden, CR inside the marker, secret split across a CRLF continuation) are redacted at head, `8/8`, and `W3`/`W8` are redacted at `prev`/`head` but leak at `round1` — so `31827e9d`'s normalisation genuinely improved the CRLF path.

The **fidelity** side does not. `CONTROL_CHARS_RE` includes `\r`, so:

| input | output at head | assertion |
| --- | --- | --- |
| `line1\r\nline2\r\n` (benign) | `line1\nline2\n` | **not verbatim** |
| `line1\rline2` (benign) | `line1line2` | **not verbatim — two lines become one** |

Tabs (`M1`), ANSI (`M3`), C0 controls (`M4`) and continuations (`M5`) are also mutated, but the function's own docblock documents those; the `\r` removal is not documented and contradicts the Reviewer Test Plan's "non-sensitive text is preserved verbatim". Telemetry-only and cosmetic — the second row can merge two log lines in the reported text — so low severity. It is identical on `prev`, `head` and all three fix arms, so no candidate fix changes it. Fixing it means excluding `\r` from the per-line strip, which would also remove the (security-positive) `W8` marker-reassembly; I did not measure that trade and am not proposing a change.

### F1 (carried) — 10 residual shapes, unchanged

Same 10: JSON-shaped text (`B1` `{"authorization":"Bearer …"}`, `B2` `{"GITHUB_TOKEN":"…"}`), colon-separated header/config forms (`B7` `X-Api-Key: …`, `B8` `password: …`), the env floor (`B4` `DB_PASSWORD=hunter2`), non-secret-named credential flags (`B5` `curl -u`, `B6` `curl --user`, `B10` `mysql -pPass`), and prose/bare tokens (`B3`, `B12`). `B9`/`B11` are redacted. **Incompleteness, not a regression** — base leaks all 12.

### F13 — Truncation residues below the value floor leak partially (low, new)

A consequence of the same floors, measured rather than inferred: `T1` `--password abcdefgh [truncated]` (an 8-char residue) leaks at head and is redacted by `fix5`/`fixall`; `T3` `GITHUB_TOKEN=ghs_tr` (6 chars) leaks at **every** arm including `round1`, because `fixall` does not touch the env floor. `T2` (12 chars, above the floor) is redacted at head. So a truncation that cuts a credential short can still emit the surviving prefix. Partial-credential exposure only; noted for completeness, not proposed as a merge condition.

### F9 (carried) — `error_excerpt` remains a dead key (Suggestion)

Repo-wide census at this head: **exactly 1** occurrence outside `node_modules`/`.git`/`dist`/`tmp` — the declaration at `qwen-logger.ts:115`. No producer anywhere. `M8` (delete it) survives green. Classification: **dead code**. `AGENTS.md` Simplicity First is explicit about this shape. Harmless as-is.

### Scarier consequences I checked that still do **not** hold

- **`stack` bypass — disproved.** 0 `stack:` assignments in `packages/core/src/telemetry/` (non-test). A populated `stack` would have bypassed `message` redaction, since a Node stack's first line is the message.
- **Retry-path bypass — disproved.** `this.events` writers: `push` inside `enqueueLogEvent` (post-redaction) and `unshift` in `requeueFailedEvents` (re-queues already-redacted events).
- **In-place mutation corrupting a caller — disproved.** 42 internal call sites; the only external callers are `integration.test.circular.ts` and the unit test.
- **ReDoS hang — disproved, and hardened.** 0 of 16 ladder rungs hit the 30 s cap on either arm; unbounded worst case at the 25 k ceiling is 82.7 ms.
- **Blow-up *introduced* by the new bounds — disproved.** Head is flat (≤0.19 ms) on both shapes at all rungs.
- **`properties.error` clean only because the carrier is off — disproved.** `logPrompts` defaults to `true`; with it forced off, neither arm writes the carrier, so the head result is not an artefact.
- **The full credential leaking via truncation because truncation is line-based — disproved, and the truth is worse.** `keep:'both'` slices an over-budget *line* by characters; that is exactly what produces the unterminated quote in F10.

## Not covered

- **No live network flush.** Nothing was POSTed to the RUM endpoint — no credentials in this container, and sending fixtures to a real third-party backend would be inappropriate. Egress was blocked twice (socket layer + `flushToRum`) and counted: **`socketAttempts = 0` on all seven arms**, 88–92 blocked flush calls each. The oracle is `createRumPayload()`, which `flushToRum()` serializes verbatim; the wire body was reconstructed, not captured off a socket.
- **Real `ShellTool` not driven end to end.** F10's chain is two real compiled modules (`truncateAndSaveToFile` → `redactTelemetryError`) with the intermediate links cited by line (`shell.ts:2990`, `shell.ts:3102`, `loggers.ts:318`). Driving the actual tool would need a full `Config` and a subprocess. This **reproduces the production wire shape**; it does not reproduce a real failing shell command.
- **Per-commit attribution only partly exercised.** All 6 PR commits are locally reachable (`git fetch --depth=12 origin refs/pull/11649/head`) and match the metadata's `commits` array. I isolated `31827e9d → 41714d9f` as its own arm and used `566d6a74` as a second control, but did not exercise `7df466e0`, `868e8bd7` or `b5d35fd1` individually, so `868e8bd7`'s R1-1…R1-7 claim mapping is not independently attributed here.
- **Repo-level gates.** Ran the focused suite (69/69 at head and on the merged tree), the full `packages/core` suite on the merged tree and on plain `main`, and `tsc --build` for `packages/core` on the merged tree (clean). Did **not** run `npm run lint`, repo-level `npm run typecheck`, or any integration test.
- **The 72 pre-existing `packages/core` failures were attributed, not diagnosed.** All 8 failing files (`installationManager`, `rulesDiscovery`, `logger`, `ide-client`, `file-token-storage`, `memoryDiscovery`, `skill-manager`, `subagent-manager`) fail identically on plain `main` in this container and are filesystem/HOME-sensitive; I did not establish whether they also fail in the project's own CI.
- **Base OID discrepancy (carried).** The metadata's `baseRefOid` is `20ecdaf6…`, unreachable locally. I used the merge-ref base tip `HEAD^1` = `d0e39357`, the correct control for a `refs/pull/11649/merge` checkout.
- **Other exfiltration paths.** `loggers.ts` sets `attributes['error.message']` for the OTLP exporters; `session-tracing.ts`/`daemon-tracing.ts` carry error text into spans. Separate, opt-in sinks this PR neither touches nor claims.
- **`snapshots` as an alternate carrier.** Not re-enumerated; it is outside `ERROR_TEXT_PROPERTY_KEYS` by design and no revision touches it.
- **Non-UTF8 / astral-plane credentials, and CRLF *file* fixtures.** The `W`/`M` groups cover CR, CRLF, tabs and ANSI inside strings; I did not drive a whole CRLF-encoded file or multi-byte credentials.

## Methodology

CI merge-ref checkout at `164206dd`; `npm ci` and `npm run build` pre-completed at head. Follow-up round, so the control is **seven build arms** rather than a single base, and every carried-forward measurement was rebuilt and re-run rather than diffed against `previous-report.md`.

`git fetch --depth=60 origin main` and `--depth=12 origin refs/pull/11649/head` made both current `main` (`fdb33117`) and all 6 PR commits reachable, so the arms are **real commits**: `base` = `d0e39357`, `round1` = `566d6a74`, `prev` = `31827e9d`, `head` = the checked-out `41714d9f`, and `fix5`/`fixq`/`fixall` = head with 1–2 lines edited by `apply-edit.mjs` (which refuses an anchor that does not match exactly once, so a silent no-op edit is impossible). `git diff --stat HEAD^1..HEAD` confirms the PR touches only `qwen-logger.ts` and its test, so swapping those two files inside one worktree is an exact control; both were swapped together because head's test file references a symbol base does not export, which would otherwise break `tsc --build`. Builds ran in `tmp/build-tree` (`git worktree` at HEAD, wired to the root `node_modules` and to `packages/core/node_modules` by symlink), each `tsc --build` ~10–37 s, exit 0. Both scratch worktrees were removed after the cells were captured; `git status --porcelain` is empty and `git worktree list` shows only the main tree.

**Workspace-link trap handled explicitly.** `node_modules/@qwen-code/qwen-code-core` resolves to `/__w/qwen-code/qwen-code/packages/core` — the **head** tree — even from inside the worktree (asserted: `readlink -f` from `tmp/build-tree/packages/core` finds it only at `../../node_modules/…`). Every harness therefore imports by **absolute `dist/` path** and hard-fails unless `realpathSync` of the loaded module stays inside the worktree (`realpathInsideWorktree: true`, recorded per arm). `qwen-logger.ts`'s cross-module imports are all relative (`../../extension/redaction.js`, `../../utils/textUtils.js`, …) and its only bare specifiers are third-party (`node:*`, `https-proxy-agent`, `mnemonist`), so no arm can reach head code across a workspace boundary.

**Arm identity pinned three ways.** (1) `sha256` of each compiled module — `base 906b79d4`, `round1 8e9b80b0`, `prev 737981e0`, `head 3fa8341c`, `fix5 438e2e79`, `fixq b0b7b428`, `fixall afad45b8`, `m12 5ab51541` — with the first five **reproducing round 2's published prefixes exactly from a from-scratch build**, and `fix5` re-derived from the edit machinery to the same hash. (2) A symbol census per arm (`redactTelemetryError`, `SECRET_FLAG_VALUE`, `{0,64}` count, unbounded name class, floor markers). (3) The worktree's head build is byte-identical to the pre-built main-tree `dist` (`3fa8341c…`), re-verified after the round.

`probe.mjs` drives the compiled `QwenLogger` through real `log*Event()` calls with a fake `Config` (the only stub), clears the singleton deque per case so each payload carries exactly one event, and searches the `safeJsonStringify`'d payload for the literal secret. `corpus.mjs` holds 88 cases in ten groups: 8 claimed vectors, 12 round-1 residuals, 12 short-flag probes, 9 quantifier-boundary bisectors, **12 delimiter siblings (new)**, **8 CRLF/tab/ANSI (new)**, **3 truncation residues (new)**, 6 acknowledged over-match, 12 benign byte-identity, 6 normalisation-mutation. All seven arms were driven over an identical 88-case set (asserted by comparing the per-arm case-id sequence, `idsMatchFirstArm=true` for all seven). `truncate-e2e.mjs` chains the real truncator into the real redactor and sweeps 141 offsets for the cut. `joiner-probe.mjs` tests the continuation joiner on split URL credentials. `mutate.mjs` runs the 15-row matrix and restores from a pristine copy after each row. `redos-driver.sh` runs one ladder rung per child process under `timeout`, so a cap is a recorded result rather than a hung harness. `compare.mjs` builds the cell table and ledger; `finalize.mjs` merges every ledger, which is why the gate counts are read from captured logs (ANSI-stripped — vitest colourises even when redirected, and unstripped they silently read `NaN`) rather than restated in prose.

**Ledger encoding.** The security property is asserted on the **head** arm, so a credential reaching the wire is a real `fail`. Control arms carry their expectation in the assertion (`control/base-leaks/*` passes when base leaks), so an expected red is a pass, and surviving mutants are recorded as `mutation/<id>-survives` — a coverage measurement, not a PR defect. All **35** fails are therefore unexpected outcomes: **33** head-arm credential leaks plus **2** CRLF fidelity. No harness failure is counted as a PR finding; where a harness was wrong I fixed it and re-ran (two corpus entries initially carried a `secret` string absent from their own `text`, which made `X8`/`T2` read as redacted on every arm including base — caught by cross-checking `identical` against `leaked`, corrected, and all arms re-run).

Raw logs: `{base,round1,prev,head,fix5,fixq,fixall,m12}-probe.json` (+ `.err`), `trunc-*.json`, `trunc-summary.txt`, `trunc-detail.txt`, `ab-table.log`, `ab-summary.txt`, `corpus-table.txt`, `x-detail.txt`, `joiner-probe.{json,log}`, `mutate.{json,log}`, `redos-ladder.{jsonl,log}`, `ledger.json`, `assertions-detail.json`, `final-ledger.log`, `compare.log`, `finalize.log`, `merge-evidence.log`, `main-side-touch.log`, `gate-{head,merged}-focused.log`, `gate-{main,merged}-full-core.log`, `gate-merged-build.log`, `{main,merged}-fails.txt`, `fails-diff.txt`, `{main,merged}-failing-files.txt`. Compiled arms are kept under `arms/*.js` with their `*-build.log`, so any cell can be re-run without rebuilding. Harnesses are `.mjs`/`.sh` and rerunnable as-is; `summary.mjs` regenerates capture 1's table.

**How to re-run.** See the reproduce block in F10 — it recreates the build worktree, then `node "$ART/compare.mjs" && node "$ART/final

...truncated -- full content in the run artifacts.
Flakiness gate log

rounds=5 files=1 skipped=0
file packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: (cd packages/core) npx --no-install vitest run ./src/telemetry/qwen-logger/qwen-logger.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: 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/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 2 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 3 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 4 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 5 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)

Evidence images

01-seven-arm-ab-real-credential-leaks

02-mutation-matrix-joiner-floor-bounds-unpinned

03-truncation-e2e-full-credential-survives-at-head

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-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.

⏸️ Deferring — not approving, not requesting changes. Recorded against 41714d9f so the deferral is pinned to the commit I actually reviewed.

All five round-2 Criticals are closed at this head; I re-derived each from the diff and the base tree. The code is sound and the leak surface is strictly smaller than main. What stops this being an approval is ownership, not quality: telemetry redaction policy is a maintainer decision, and this gate escalates telemetry rather than deciding it. #11198 carries status/ready-for-human and names the policy as the actual work.

Full reasoning, the two corrections to my previous pass, and the three questions for the maintainer are in the Stage 3 comment above. Escalated to @zjunothing (already assigned, sole core-telemetry owner).

One mechanical note: the standing CHANGES_REQUESTED on this PR is pinned to 566d6a74, two commits behind head, and every Critical it named is now closed. It will hold mergeStateStatus at BLOCKED until dismissed or /review re-runs at 41714d9f.

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

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 12, 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: 192 passed · 22 failed · 214 total

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

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

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

脚本断言:192 通过 · 22 失败 · 214 总计

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

Verification report

PR #11649 Deep Verification (round 4) — fix(core): redact error text in usage-statistics telemetry sink

Verdict: findings — assertions 192 pass / 22 fail / 214 total.
Verified head: 41714d9fd0f7eaadd89dddb2646e79802315cb2d (git rev-parse HEAD^2)
A/B control (base tip): d0e3935703776ef1529e840ef9ebda024ee64406 (HEAD^1)
Round-3 verified head: 41714d9fthe same commit; round-3 base control d0e39357the same commit.

The head and the base have not moved since round 3, so every round-3 finding was re-measured on an input closure that is provably identical — and two things came back different. Walking the fidelity side of the same balanced-quote change that round 3 walked only for security found a second regression from the same commit (31827e9d): its quoted-value alternatives span newlines, so one stray quote in benign multi-line error text collapses 97.64% of it, 21 lines → 1 (F14). And round 3's recommended fix, which it measured as "benign byte-identical 12/12" on a short single-line corpus, destroys up to 99.88% of a 25 k payload and 69.9% of the real truncation preview when it closes the leak it was recommended for (F15). Separately, F10 is reproduced independently at the real 30 k shell threshold — but its reachability is narrower than round 3 presented: realistic multi-line shell output does not leak at all (0/40); only an over-budget single line sliced mid-value does (1/60 offsets, full 18/18-char credential).

All 8 of round 3's published arm sha256 prefixes reproduce byte-for-byte from my from-scratch builds, which also pins a detail round 3 left implicit: its "floor 5" was flag-only. Building the flag+env variant yields the best arm on the corpus (fixall 15/68 vs round 3's fixallf 17/68).

中文摘要

结论:findings —— 断言 192 通过 / 22 失败 / 共 214。已验证 head:41714d9f;A/B 对照基线:d0e39357HEAD^1)。head 与 base 自上一轮以来都没有变动,因此本轮在一个「可证明完全相同」的输入闭包上重新实测了上一轮的每一项结论 —— 结果有两处不同。

A/B 结论:核心主张仍然成立且 load-bearing。本轮做了十臂对照(base / 上一轮 head 566d6a74(round1) / delta 父提交 31827e9d(prev) / 当前 head / joiner 变异体 / 五个候选修复臂),见「A/B(十个构建臂)」。以真实凭据形态计(共 68 个):base 68/68 全泄漏 → round1 14/68 → prev 17/68 → 当前 head 32/68 → fixq 27/68 → fixallf 17/68 → fixall 15/68。四个入队载体在 head 上全部干净、在 base 上全部泄漏(8/8),收口点确实唯一。上一轮发布的全部 8 个 arm sha256 前缀,本轮从零重建后逐字节复现base 906b79d4round1 8e9b80b0prev 737981e0head 3fa8341cfix5 438e2e79fixq b0b7b428fixall afad45b8m12 5ab51541),并据此确定了上一轮「floor 5」的确切定义是「仅 flag」;我另外构建了 flag+env 版本,是本轮语料上最好的臂。

本轮新发现 F14(高)31827e9d 引入的「配对引号」取值模式里,[^"] 可以匹配换行,而三次 .replace() 作用在重新拼接后的整段文本上,不是逐行。于是 benign 多行错误文本里一个游离的引号就能吞掉几乎全部内容:Q4(API_KEY="rotated + 19 行 + end of key")在 head 上销毁 932 字符中的 910(97.64%),21 行塌成 1 行;Q5(authorization 拼法)915/957(95.61%);Q2(25 k 上限)24938/24972(99.86%)。同一组 fixture 在 round1(566d6a74)上只销毁 20 与 19 字符 —— 即这是 31827e9d 引入的回归,与 F10 是同一处改动的两个相反面:要求配对引号,既让未闭合引号无法脱敏(F10,安全侧),又让游离引号吞到它的配对为止(F14,保真侧)。对照组成立:Q6(有引号但无密钥名标记)所有臂销毁 0;Q7(单行带引号取值)为预期脱敏。

本轮新发现 F15(高,针对上一轮的建议):上一轮把 fixq/fixall 记为「benign 12/12 逐字节不变」并建议采用 fixall —— 那批语料都是短的单行文本。在 Q 组与真实截断器上重测:Q3(未闭合引号,25 k)head/prev 销毁 0,fixq/fixall/fixallf 销毁 24937 字符(99.88%)Q8(短未闭合)head 0,fixq/fixall 28/57(49.12%)。经真实 truncateAndSaveToFile(生产参数 threshold=30000、previewChars=4000、keep='both'、lines=Infinity):head 在 offset 752 泄漏完整 18 字符凭据且 destroyed=0,而 fixq/fixall 关掉了泄漏,却销毁 3933 字符预览中的 2749(69.9%);同一 offset 上 round1 只销毁 52 字符。取舍本身可能是对的(凭据泄漏比丢诊断信息更糟),但上一轮没有把这个代价量出来,而它大到足以在采用前先量一个更窄的修法。

F10 复核(本轮独立复现,且范围更窄):在真实的 shell 阈值 30000DEFAULT_SHELL_OUTPUT_THRESHOLDshell.ts:105;上一轮用的是 config 通用默认 25000)下驱动真实截断器:19/60 个 offset 会把凭据送进预览,其中 1/60 在 head 上泄漏完整 18/18 字符(offset 752,truncLen=3933 outLen=3933);round1 与 fixq/fixall 均 0/60;base 泄漏 19/60(对照成立)。但在贴近真实的多行 shell 输出(S1/S2)上,head 0/40 不泄漏 —— 凭据所在行要么整行存活并被脱敏,要么整行被丢弃。因此 F10 的可达性需要「单行超出预览预算并被按字符切在取值中间」,比上一轮呈现的更窄。

上一轮发现的复核:F5 原样成立(R1–R9 在 head 泄漏、R10 脱敏;round1/prev 12/12 全脱敏);F6 原样成立(恰好 N2/N4/N6/N8 四例,N1/N3/N5/N7/N9 均脱敏,含 57 字符真实 flag);F1 原样成立(B 组 head 10/12 泄漏,base 12/12);F3 成立(引号不配对断言通过);F4 与上一轮一致(D 组 head 保留 4/6,C 组 12/12 逐字节不变,head 仍是最好的臂);F12 原样成立(M 组 head 0/6 逐字节保留,\r 被删);C1 原样成立(十臂符号普查均为 ['error_message','error_excerpt','error'])。F7/F8/F9/F11 本轮未重跑变异矩阵,其状态依据「输入闭包可证明相同」+ 我把 joiner 变异体重新构建到与上一轮逐字节相同的 5ab51541 而沿用,见「未覆盖」。

未覆盖范围(要点)变异矩阵未重跑ReDoS 阶梯未重跑 —— 包括没有在 fixq/fixall 上跑,因此「上一轮建议的修复是否重新引入超线性回溯」这一问题本轮没有答案(这一点很重要:fixq/fixall 新增了贪婪且上界不限的分支);未跑全量 packages/core 套件、未做与当前 main 的试合并、未跑仓库级 lint;无任何真实网络上报(egress 在 socket 层阻断并计数);snapshots 载体、逐 commit 归因(7df466e0/868e8bd7/b5d35fd1)。

Previous-finding status (round 3 → this head)

Round 3 verified 41714d9f against base d0e39357. Both OIDs are unchanged this round (HEAD^2 = 41714d9f, HEAD^1 = d0e39357; trees cf048690… / 6227c57f…), and git diff --name-status HEAD^1..HEAD touches only qwen-logger.ts and qwen-logger.test.ts, with 0 changes to any package.json/package-lock.json — so the input closure is identical. I did not rely on that: I rebuilt all ten arms from scratch and every one of round 3's eight published sha256 prefixes reproduced byte-for-byte, and all corpus/carrier/truncation cells below were re-run with harnesses written this round.

# Round-3 finding Sev Status at 41714d9f Re-measured evidence
F10 Unterminated/backtick value unredactable; "25/25 chars reach the wire" highest stands as a mechanism; reachability narrower than round 3 stated Corpus X1,X2,X5,X6,X7 leak at head (5/5), redacted at round1 (5/5), closed by fixq (5/5). Real truncator at the 30 k shell threshold: 1/60 offsets leak the full 18/18 chars; but realistic multi-line output S1/S20/40 leaks at head
F5 Delta reopens 9 short-secret flag leaks highest stands — exactly 9 R1…R9 leak at head, R10 (10 chars) redacted; round1/prev redact 12/12; fix5f/fixall reclose 9/9 (ab-table.log, R row)
F14 (not probed in round 3) high — NEW new this round Quoted value spans \n: Q4 910/932 chars (97.64%), 21→1 line; Q5 915/957 (95.61%); Q2 24938/24972 (99.86%). round1 on the same fixtures: 20 and 19 chars
F15 (round 3 recommended fixall as "benign byte-identical 12/12") high — NEW new this round Q3 fixq/fixall destroy 24937 chars (99.88%); Q8 28/57 (49.12%); real truncator offset 752 → 2749 of 3933 chars (69.9%) destroyed, vs round1 52
F6 {0,64} bound misses at 65-char name segments low stands — exactly 4 N2/N4/N6/N8 leak at head; N1/N3/N5/N7 (64 chars) and N9 (realistic 57-char flag) redacted; round1/prev 0/9
F1 10 residual credential shapes highest stands — same 10 B group: head 10/12 leak, base 12/12; B9/B11 redacted. Incompleteness, not a regression
F12 Benign CR-containing text not verbatim low stands M group head 0/6 byte-identical; M2 line1\r\nline2\r\n 14→12 chars, M4 line1\rline2 11→10
F3 Redaction consumes quotes → unbalanced output Nit stands Assertion redaction-can-emit-unbalanced-quotes passes on A2/X9/W4
F4 Acknowledged over-match info consistent with round 3 D group preserved 2/6 (prev) → 4/6 (head); C group byte-identity 12/12 at head (best arm); D3/D4 still over-matched
C1 "any error-text key … added later" ≠ hardcoded 3-key allowlist stands Per-arm symbol census on all ten compiled arms: ERROR_TEXT_PROPERTY_KEYS = ['error_message','error_excerpt','error']
C2 Declared stack exclusion excludes nothing stands (carried on identical closure) Re-grepped this round: 0 non-test stack: assignments in packages/core/src/telemetry/
Corr. 3 Three different value floors in one function stands R11 Bearer xyz (3 chars) redacted, R12 Basic YWJj (4 chars) redacted, R1 --password hunter2 (7 chars) leaks
F7/F8/F9/F11 Mutation matrix: floor, bounds, dead key, joiner all unpinned not re-measured this round The nojoin mutant arm was rebuilt to round 3's exact hash 5ab51541; the vitest matrix itself was not re-run (see Not covered)
Trial merge into current main, full packages/core suite not re-run this round Round 3 covered both at this same head/base; see Not covered

Central claim and A/B (ten build arms)

Central claim. Error text enqueued into the usage-statistics (RUM) sink is scrubbed of credentials at a single choke point.

Secondary claims. (a) the choke point is genuinely single; (b) non-sensitive text survives verbatim; (c) the {0,64} bounds prevent quadratic backtracking; (d) flag and env value floors are unified.

Oracle. flushToRum() serializes createRumPayload() with safeJsonStringify and POSTs it, so that payload is the wire body. The corpus job drives the real logToolCallEvent()enqueueLogEvent()redactEventErrorText() path on each arm's compiled dist/, serializes with the real safeJsonStringify, and searches the payload for the literal secret. The only stub is Config. Every case is also run through TEST_ONLY.redactTelemetryError directly and the two verdicts are asserted to agree — allAgree=true on all ten arms, so the pattern-level and wire-level oracles never diverge.

Egress. Blocked at net.Socket.prototype.connect (ESM namespaces are frozen, so tls.connect/https.request cannot be reassigned; both funnel through the socket prototype) with a positive control that runs before any measurement: a probe connect to the real RUM host must be intercepted, else the harness exits 4. egressControlFired=1 and blocked.socket === egressControlFired on all ten arms — no fixture left the container. flushToRum/flushIfNeeded are also replaced and counted.

Witness: 01-ten-arm-ab-base-68-of-68-leak-head-32.png.

arm commit / edit real-credential leaks (of 68) R: short flag secrets (of 12) X: delimiter siblings (of 12) benign byte-identical (C, of 12)
base (control) d0e39357 68 / 68 12 12 12/12 (no redaction at all)
round1 566d6a74 14 / 68 0 1 10/12
prev (delta's parent) 31827e9d 17 / 68 0 6 11/12
head (under test) 41714d9f 32 / 68 9 7 12/12
nojoin (F11 mutant) head − joiner line 33 / 68 9 7 12/12
fixq (round 3) head + unterminated quote/backtick 27 / 68 9 2 12/12
fix5f (round 3's fix5) head + flag floor 5 22 / 68 0 7 12/12
fixallf (round 3's fixall) fixq + flag floor 5 17 / 68 0 2 12/12
fix5 (new) head + flag and env floor 5 20 / 68 0 7 12/12
fixall (new) fixq + flag and env floor 5 15 / 68 0 2 12/12

Per-group leak counts (ab-table.log; D/C/M are benign groups where "preserved" is the good outcome, so they are listed separately and excluded from the 68):

grp  n   base  round1  prev  head  nojoin  fixq  fix5f  fixallf  fix5  fixall
A    8   8     0       0     0     0       0     0      0        0     0     <- the 8 vectors the description names: closed on every arm
B    12  12    10      10    10    10      10    10     10       9     9     <- F1 residual: incompleteness, not regression
R    12  12    0       0     9     9       9     0      0        0     0     <- F5: 9 short flag secrets reopened by the delta
N    9   9     0       0     4     4       4     4      4        4     4     <- F6: {0,64} boundary
X    12  12    1       6     7     7       2     7      2        7     2     <- F10: delimiter siblings, regressed by 31827e9d
W    8   8     2       0     0     1       0     0      0        0     0     <- CRLF/tab/ANSI security holds at head
T    3   3     1       1     2     2       2     1      1        0     0     <- truncation residues below the value floor
V    4   4     0       0     0     0       0     0      0        0     0     <- NEW: astral/CJK/NBSP all redacted at head
D    6   -     2 red   2 red 4 red 4 red   4 red 3 red  3 red    3 red 3 red <- benign over-match (head over-matches 2)
C    12  0     3       1     0     0       0     0      0        0     0     <- benign byte-identity: head is the best arm
M    6   0     6       6     6     5       6     6      6        6     6     <- benign text the normalisation pass mutates (F12)

Choke-point carrier coverage (secondary claim (a) — holds). One secret driven end to end through all four real carriers, on the real compiled modules:

carrier base head
properties.error_message via logToolCallEvent LEAK (marker absent) clean, marker present
top-level message via logInvalidChunkEvent (error_message → exception) LEAK clean, marker present
properties.error via logHookCallEvent (logPrompts on) LEAK clean, marker present
top-level message + properties.error_message via logApiErrorEvent LEAK clean, marker present

8/8 assertions. Structurally single: this.events.push appears at exactly one site (inside enqueueLogEvent, after redactEventErrorText); the only other writer is unshift in requeueFailedEvents, which re-queues already-redacted events. Note the exception carrier needs the event field error_message, not error (logInvalidChunkEvent maps event.error_messagemessage); my first harness passed the wrong field and reported a false clean with no redaction marker — caught by asserting markerPresent alongside wireLeaked, then fixed and both arms re-run. A clean result with no marker means the text never reached the payload, not that it was redacted.

Corrections

Corrections to the PR description / commit messages and to round 3's report, not requests to change code.

  1. C1 stands. "any error-text key — including ones added later — is scrubbed" is not what the code does: ERROR_TEXT_PROPERTY_KEYS is a hardcoded three-element allowlist (verified on all ten compiled arms). Centralising the list at the sink is real but a weaker contract.
  2. C2 stands. The Risk section says stack is "intentionally not redacted"; 0 non-test stack: assignments exist in packages/core/src/telemetry/. The declared exclusion excludes nothing.
  3. Correction 3 stands. Three value floors in one function, not one: 1 char for authorization, 10 for flags, 10 for env. R11/R12 (3- and 4-char values) redact while R1 (7 chars) leaks.
  4. NEW — the Risk section's stated bound on over-matching is wrong by ~4 orders of magnitude. It says "the patterns run to the next whitespace or quote, so the false-positive surface is a non-empty run of characters following an authorization, secret-flag, or secret-key marker." Measured, a quoted value runs to its matching closing quote, which [^"] allows to be any number of newlines away: Q4 lost 910 characters across 21 lines, Q2 lost 24,938 characters. The real bound is the whole 25 k payload, not one token. See F14.
  5. NEW — round 3's "floor 5" was flag-only, and that is now pinned by hash, not by reading. My flag-only rebuilds reproduce round 3's published fix5 438e2e79 and fixall afad45b8 exactly; my flag+env variants do not (a0d31c3a, adc08e7b). This matters because it explains round 3's residual T3 (GITHUB_TOKEN=ghs_tr, 6 chars) leaking under fixall: the env floor was never lowered. Lowering both reaches T 0/3 and 15/68 overall — the best arm measured this round.
  6. NEW — round 3's "benign byte-identical 12/12" for fixq/fixall is true but not a fidelity bound. That corpus is short and single-line. On multi-line and 25 k fixtures the same arms destroy up to 99.88% of the text (F15). The claim was accurate about what it measured and silent about what it did not.

Findings

F14 — A quoted value spans newlines, so one stray quote can destroy nearly all of a benign multi-line error text (high, new)

31827e9d replaced the optional-delimiter value pattern with balanced-quote alternatives:

round1 (566d6a74): SECRET_VALUE = ["'\`]?[^\s"'\`\\]+["'\`]?   <- [^\s...] cannot cross a newline
prev/head        : SECRET_VALUE = (?:"[^"]+"|'[^']+'|[^\s"'\`\\][^\s]*)
                                     ^^^^^^ [^"] MATCHES \n

redactTelemetryError splits on \n only to strip ANSI/C0 per line, then rejoins and runs the three .replace() calls over the whole string. So "[^"]{10,}", '[^']{10,}' and "[^"]+" all match across newlines. Every fixture below is entirely benign — no credential anywhere; the number is diagnostic text destroyed.

Witness: 02-crossline-quote-swallow-benign-chars-destroyed.png.

id fixture (all benign) base round1 prev head fixq fixall
Q4 API_KEY="rotated + 19 diagnostic lines + end of key" 0 20 910 910 / 932 (97.64%), 21→1 line 910 910
Q5 authorization: Bearer "stale + 19 lines + token here" 0 19 915 915 / 957 (95.61%), 21→1 line 915 915
Q2 --password " + 24,950 chars + " tail 0 24938 24938 24938 / 24972 (99.86%) 24938 24938
Q6 control: benign quotes, no secret-named marker 0 2 0 0 0 0
Q7 control: single-line quoted value (intended) 0 −6 −2 −2 −2 −2

Head's Q4 output is exactly API_KEY=***REDACTED*** — 22 characters from 932.

Why this is a finding and not just the accepted tradeoff. The Risk section accepts over-matching but bounds it (correction 4). The mechanism also has a direction: round1 destroyed 20 chars where head destroys 910 on the identical fixture, so this is a regression introduced by 31827e9d — the same commit, and the same balanced-quote requirement, that produced F10. One change, two opposite failure modes: an unterminated quote becomes unredactable (security), a stray quote becomes a text shredder (fidelity). What it destroys is precisely the diagnostic value the Risk section cites for rejecting fingerprinting.

Bounded honestly — what does not hold:

  • It is not a security regression: nothing leaks that base did not leak, and C group byte-identity is 12/12 at head (its best result on any axis).
  • The trigger needs a secret-named marker immediately followed by a quote that does not close on the same line, plus a second quote later in the same error text. I did not find a common real command that does this, so the practical hit rate is unknown — the finding is about the unbounded magnitude when it fires and the incorrect bound stated in the description, not about frequency.
  • Both controls hold (Q6 0 on every arm, Q7 the intended small redaction), so this is not "the redactor eats everything".

Reproduce:

cd /__w/qwen-code/qwen-code
ART=tmp/pr11649-verify-20260912-010107
for a in round1 prev head; do node "$ART/arm-runner.mjs" $a swallow; done   # rows Q1..Q8

F15 — Round 3's recommended fix closes the leak but destroys up to 99.88% of the error text, a cost it never measured (high, new)

fixq/fixall add "[^"]{10,} and '[^']{10,} — an unterminated alternative with no upper bound, so it matches greedily to end of string. Round 3 reported these arms as "benign byte-identical 12/12" and recommended fixall to ship. That corpus is short and single-line, so it could not see this.

fixture head fixq / fixall / fixallf round1
Q3 --password " + 24,950 chars, no closing quote 0 destroyed (but leaks) 24937 destroyed (99.88%) 24937
Q8 --password 'abc + 2 short diagnostic lines 0 (but leaks) 28 / 57 (49.12%), 3→1 line −8

And through the real truncateAndSaveToFile with production limits (threshold=30000 = DEFAULT_SHELL_OUTPUT_THRESHOLD, previewChars=min(4000,threshold), keep='both', lines=Infinity), at the one offset where head leaks:

arm leaked longest surviving fragment chars of the 3,933-char preview destroyed by redaction
base yes (19/60 offsets) 18/18 0
round1 no (0/60) 3/18 52
head yes (1/60, offset 752) 18/18 — the full credential 0
fixq no (0/60) 3/18 2749 (69.9%)
fixall no (0/60) 3/18 2749 (69.9%)

Witness: 03-real-truncator-head-leaks-18-of-18-fixq-costs-2749-chars.png.

This does not make the fix wrong. A credential reaching a third-party metrics backend is worse than lost diagnostics, and fixall is still the best arm on the corpus (15/68). It makes round 3's recommendation incompletely priced: a maintainer reading "benign byte-identical 12/12" would not know that shipping it converts a ≤18-char leak into losing 70–99.9% of the error text on the shapes the fix exists for. The price is large enough to be worth measuring a narrower alternative first — bounding the quoted alternatives to a single line ("[^"\n]{10,}" / '[^'\n]{10,}') would close F14 and F10's backtick half while refusing to span the payload. I did not build or measure that variant, so it is a direction, not a recommendation.

Reproduce:

ART=tmp/pr11649-verify-20260912-010107
for a in head fixq fixall round1; do
  POSITIONS=60 OFF_BASE=600 OFF_SPAN=500 node "$ART/arm-runner.mjs" $a truncate S3 30000
done

F10 (carried, re-measured) — Reachability is narrower than round 3 presented

The mechanism stands exactly as round 3 described: X1,X2,X5,X6,X7 leak at head, all five are redacted at round1, and fixq closes all five. Head's X1 output still puts the marker immediately before the surviving credential — curl -H "Authorization: ***REDACTED*** \ghs_backticksec1`" …` — which reads as redacted to a human while the secret is in the payload.

What changed is the production-reachability half. Round 3 drove the truncator at threshold=25000 (the generic DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD) and reported 25/25 credential chars reaching the wire. The shell tool — the PR's own stated dominant leak vector — uses DEFAULT_SHELL_OUTPUT_THRESHOLD = 30_000 (shell.ts:105) unless explicitly configured. At that threshold, with the real truncator:

  • Realistic multi-line shell output (S1 URL credential, S2 quoted flag; 40 insertion positions each): head leaks at 0/40 on both. The credential line either survives whole and is redacted, or is dropped entirely. maxSurvivingFragment 5 (S1) and 3 (S2) — fragments of the surrounding literal, not the secret.
  • A single line larger than the preview budget, credential at a swept character offset (S3, 60 offsets): the secret reaches the truncated preview at 19/60 offsets, and head leaks the full 18/18 chars at 1/60 (offset 752). round1, fixq, fixall: 0/60. base: 19/60.

So F10 is real and load-bearing, but it needs keep:'both' to slice an over-budget single line mid-value — minified JSON, a very long echoed command — not ordinary multi-line shell output. A maintainer weighing F15 against F10 should know the leak is 1 window in 60 on the shape I could construct, while the fidelity cost is 70–99.9% on the same shape.

F5 (carried) — 9 short-secret flag leaks, unchanged

R1…R9 leak at head (hunter2, Passw0rd, abc123, sk-12345, s3cr3tval, cr3dX, "pw123", npm_abc12, a 9-char value); R10 (10 chars) is redacted, bisecting the floor exactly. round1 and prev redact 12/12; fix5f, fixallf, fix5, fixall all reclose 9/9. --password is named in the description as a covered vector. Not a regression against main (base leaks all 12); a regression against this branch's own earlier revisions.

F6 (carried) — The {0,64} bound starts missing at 65-character name segments (low)

Exactly 4 shapes move redacted → leak: N2/N4 (65-char prefix/suffix, flag) and N6/N8 (65-char prefix/suffix, env). N1/N3/N5/N7 at 64 chars are redacted, bisecting the bound. N9 — a realistic 57-char flag, --amazon-bedrock-agent-runtime-session-secret-access-key — stays redacted. No fix arm changes this row; widening it would trade against the (unmeasured this round) backtracking curve.

F1 (carried) — 10 residual shapes, unchanged

B group: head 10/12 leak, base 12/12. Same classes as round 3: JSON-shaped text (B1, B2), colon-separated header/config forms (B7 X-Api-Key:, B8 password:), the env floor (B4 DB_PASSWORD=hunter2), non-secret-named credential flags (B5 curl -u, B6 curl --user, B10 mysql -pPass), and prose/bare tokens (B3, B12). B9/B11 redacted. Incompleteness, not a regression.

F12 (carried) — Benign CR-containing text is not preserved verbatim (low)

M group: head preserves 0/6 byte-identically. M2 line1\r\nline2\r\n 14→12 chars; M4 line1\rline2 11→10 chars (two lines become one); M1 tabs, M3 ANSI, M5 continuations, M6 bell also mutated. CONTROL_CHARS_RE (textUtils.ts:11) includes \r. Tabs/ANSI/C0/continuations are documented in the function's own docblock; the \r removal is not, and it contradicts the Reviewer Test Plan's "non-sensitive text is preserved verbatim". Identical on every arm, so no candidate fix changes it. Note C group (no control characters) is 12/12 byte-identical at head, so this is confined to text already containing C0 controls.

F9 (carried) — error_excerpt remains a dead key (Suggestion)

Repo-wide census at this head: 1 occurrence outside node_modules/.git/dist/tmp — the declaration. No producer. Classification: dead code; AGENTS.md Simplicity First is explicit about this shape. Harmless as-is.

NEW, clean — astral-plane and multibyte credentials are handled (V group)

Round 3 listed non-UTF8/astral as not covered. All four shapes are redacted at head (V1 trailing emoji, V2 env spelling with emoji, V3 CJK-prefixed value, V4 NBSP separator), 4/4, and all four leak at base — so this is a genuine improvement, not a vacuous pass. No [^\s]-class width bug here.

Scarier consequences I checked that still do not hold

  • stack bypass — disproved (re-grepped). 0 non-test stack: assignments in packages/core/src/telemetry/.
  • Astral/multibyte class-width bug — disproved. V group 4/4 redacted at head, 4/4 leak at base.
  • Wire-level and pattern-level oracles diverging — disproved. allAgree=true on all ten arms across 68 cases each.
  • A fixture escaping the container — disproved, with a positive control. The egress block is proven to fire before any measurement; blocked.socket === egressControlFired on all ten arms.
  • The cross-line swallow eating text with no secret marker — disproved. Q6 (benign quotes, no marker) destroys 0 chars on every arm.
  • F10 reachable via ordinary multi-line shell output — disproved. S1/S2 leak at 0/40 at head; it needs an over-budget single line.
  • A false "clean" from a harness that never delivered the text — caught and fixed. The exception carrier initially reported clean with no redaction marker because I passed error where logInvalidChunkEvent reads error_message; markerPresent is now asserted beside wireLeaked.

Not covered

  • The mutation matrix was NOT re-run this round. Round 3's 15 rows (M0 control, M1 positive control, M2 floor, M4/M5/M9 bounds, M8 dead key, M10/M11 reverse, M12 joiner, M13 ANSI, M14 error key) are the basis for F7/F8/F9/F11, and I am carrying those four statuses on the proven-identical input closure plus one fresh measurement: I rebuilt the nojoin mutant arm and it hashes to round 3's exact 5ab51541. That is evidence the mutant is the same binary; it is not evidence about what the suite does with it. Treat F7/F8/F11 as unverified this round.
  • The backtracking ladder was NOT re-run — including on fixq/fixall. This was a planned new probe and it did not fit the budget, so "does round 3's recommended fix reintroduce superlinear backtracking?" is unanswered. It matters: fixq/fixall add greedy alternatives with no upper bound ("[^"]{10,}), and the {0,64} bounds the delta commit exists to add are still present (verified ×4 on every arm by symbol census), but the value side was never laddered. Secondary claim (c) is therefore carried from round 3, not re-measured.
  • No live network flush. Nothing was POSTed to the RUM endpoint. The oracle is createRumPayload() serialized by the real safeJsonStringify — the wire body was reconstructed, not captured off a socket.
  • Real ShellTool not driven end to end. The truncation cells chain the real truncateAndSaveToFile into the real redactTelemetryError with production limits read from shell.ts:2976; the intermediate links (shell.ts:2998 llmContent = truncatedResult.contenterror.messagelogToolCallEventproperties.error_message) are cited by line, not executed. This reproduces the production wire shape; it does not run a real failing shell command.
  • Full packages/core suite, trial merge into current main, repo-level lint/typecheck — not run this round. Round 3 covered the first two at this same head and base (conflict-free into fdb33117, +25 passing / +0 failing, 66 FAIL lines byte-identical to plain main). main has almost certainly moved since; whether the merge is still conflict-free is not established here.
  • Per-commit attribution only partly exercised. All 6 PR commits are locally reachable (git fetch --depth=12 origin refs/pull/11649/head) and match the metadata's commits array exactly. I built 566d6a74 (round1) and 31827e9d (prev) as arms and isolated 31827e9d → 41714d9f as the delta, but did not exercise 7df466e0, 868e8bd7 or b5d35fd1 individually, so 868e8bd7's R1-1…R1-7 claim mapping is not independently attributed.
  • snapshots as an alternate carrier — not enumerated; outside ERROR_TEXT_PROPERTY_KEYS by design.
  • Other exfiltration pathsloggers.ts attributes['error.message'] for OTLP, session-tracing.ts/daemon-tracing.ts spans. Separate opt-in sinks this PR neither touches nor claims.
  • Base OID discrepancy (carried). The metadata's baseRefOid is 20ecdaf6…, unreachable locally. I used the merge-ref base tip HEAD^1 = d0e39357, the correct control for a refs/pull/11649/merge checkout.
  • Whole-CRLF-file fixtures and non-UTF8 byte sequences. The W/M/V groups cover CR, CRLF, tabs, ANSI, C0, emoji, CJK and NBSP inside JS strings; I did not drive a CRLF-encoded file or invalid UTF-8 bytes.

Methodology

CI merge-ref checkout at 164206dd; npm ci and npm run build pre-completed at head — the pre-built dist already hashed to 3fa8341c…, matching round 3's published head before I built anything. Follow-up round, so the control is ten build arms rather than a single base.

git fetch --depth=12 origin refs/pull/11649/head made all 6 PR commits reachable, so round1 = 566d6a74 and prev = 31827e9d are real commits, not reconstructions; base = HEAD^1 = d0e39357; head = the checked-out 41714d9f. git diff --name-status HEAD^1..HEAD confirms the PR touches only qwen-logger.ts and its test, so swapping those two files inside one worktree is an exact control (both are swapped together because head's test references a symbol base does not export). Candidate arms come from apply-edit.mjs, which refuses an anchor that does not match exactly once — all three anchors were dry-run against the pristine source and reported unique before any build. Builds ran in tmp/build-tree (git worktree at HEAD, wired to the root node_modules and packages/core/node_modules by symlink), each npx tsc --build exit 0; build-arms.sh restores the pristine sources afterwards and asserts the restore (source restored byte-identical to pristine head: true).

Workspace-link trap handled explicitly. readlink -f tmp/build-tree/node_modules/@qwen-code/qwen-code-core resolves to /__w/qwen-code/qwen-code/packages/core — the head tree — even from inside the worktree, so a naive base harness would silently load head code. Every harness therefore imports by absolute dist/ path inside the worktree and hard-fails unless realpathSync of the loaded module stays inside it: realpathInsideWorktree=true on all ten arms. Each arm is swapped into that dist path and its sha256 re-verified after the copy before import.

Arm identity pinned by hash, cross-checked against round 3. All eight of round 3's published prefixes reproduce: base 906b79d4, round1 8e9b80b0, prev 737981e0, head 3fa8341c, fix5f 438e2e79, fixq b0b7b428, fixallf afad45b8, nojoin 5ab51541. Two arms are new this round (fix5 a0d31c3a, fixall adc08e7b) and differ from round 3's by lowering the env floor as well as the flag floor. A per-arm symbol census (redactTelemetryError presence, {0,64} count, joiner line, the literal SECRET_VALUE/SECRET_FLAG_VALUE strings, the allowlist) is printed in logs/build-arms.log, so each arm's content is verified independently of its hash.

arm-runner.mjs drives the compiled QwenLogger through real log*Event() calls with a fake Config (the only stub), clears the singleton deque per case so each payload carries exactly one event, and searches the real safeJsonStringify'd payload for the literal secret; it also asserts the pattern-level and wire-level verdicts agree per case. corpus.mjs holds 68 credential cases in 8 groups plus 24 benign cases in 3, and the 8 SWALLOW fidelity fixtures. truncate drives the real truncateAndSaveToFile across three scenarios (realistic multi-line URL credential, realistic multi-line quoted flag, over-budget single line) sweeping insertion offsets. compare.mjs builds the cell tables and the ledger; counts in this report are read from its captured logs, not restated.

Ledger encoding. The security property is asserted on the head arm, so a credential reaching the wire is a real fail. Control arms carry their expectation in the assertion (control/base-leaks/* passes when base leaks — 68/68), so an expected red is a pass. All 22 fails are unexpected outcomes: 12 cross-line fidelity (Q2/Q4/Q5 × head, fixq, fixall, fixallf), 2 truncation-preview fidelity (fixq/fixall at 2,749 chars), 6 normalisation fidelity (F12, M1…M6), and 2 acknowledged over-match (D3/D4). No harness failure is counted as a PR finding; where a harness was wrong I fixed it and re-ran — two cases: W6/W7 originally carried a secret string that was not a literal substring of their own text (a control character sat inside it), which made base read as non-leaking, and the exception carrier passed error where logInvalidChunkEvent reads error_message, which produced a false clean. Both were corrected and all arms re-run.

Raw logs: {arm}-corpus.json, {arm}-swallow.json, {arm}-trunc-{S1,S2,S3}.json (+.err) for all ten arms, ab-table.log, swallow-table.log, compare.log, ledger.json, assertions.json, build-arms.log, {arm}-build.log, gate-head-focused.log. Compiled arms are kept under arms/*.js, so any cell can be re-run without rebuilding; harnesses are .mjs/.sh and rerunnable as-is. Focused suite at head: 69 passed / 69 (gate-head-focused.log), matching round 3.

Flakiness gate log

rounds=5 files=1 skipped=0
file packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: (cd packages/core) npx --no-install vitest run ./src/telemetry/qwen-logger/qwen-logger.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: 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/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 2 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 3 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 4 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)
round 5 · packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts: P (exit 0)

Evidence images

01-ten-arm-ab-base-68-of-68-leak-head-32

02-crossline-quote-swallow-benign-chars-destroyed

03-real-truncator-head-leaks-18-of-18-fixq-costs-2749-chars

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-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.

⏸️ Still deferring — not approving, not requesting changes. Pinned to 41714d9f, the commit I reviewed. This supersedes the framing of my 23:56 deferral on this same commit, which cannot be edited.

Correction to that earlier deferral. It said the blocker was "ownership, not quality". Ownership still blocks — telemetry redaction policy is a maintainer decision, and #11198 carries status/ready-for-human. But there is now a quality finding too, and it has a measurement behind it rather than an opinion.

The round-2 /verify report landed after that deferral was written. The final commit swapped the flag path from the unbounded SECRET_VALUE to SECRET_FLAG_VALUE, which carries a 10-character value floor. I hand-derived the consequence from the diff:

SECRET_FLAG_VALUE = (?:"[^"]{10,}"|'[^']{10,}'|[^\s"'\`\\][^\s]{9,})

mysql --password hunter2 (7), --token abc123 (6), --api-key sk-12345 (8) and --password="pw123" (5 inside quotes) match no alternative — the quoted ones need ≥10 interior chars, the unquoted one needs ≥10 total, and neither ENV_SECRET_PATTERN (requires key=value) nor AUTHORIZATION_PATTERN (requires the literal authorization) can pick them up. They reach the third-party RUM backend verbatim. --password and --token are named as covered vectors in the PR description.

Two baselines, because they answer differently: against main this is not a regression — base has zero redaction (grepped: 0 hits), head closes all 8 named vectors for realistic long credentials, measured leaks 41/41 → 23/41. Against the branch's own earlier revisions it is: /verify built 566d6a74 and 31827e9d as separate arms and both redacted 12/12 short-flag shapes at 10/41; head redacts 3/12 at 23/41.

The fix is one line, and it is measured rather than conjectural. A fifth arm with the floor at 5 ({5,} / {4,}, {0,64} bounds kept) passed 52/52: 9/9 reopened shapes re-closed, 8/8 description vectors still redacted, benign text byte-identical 12/12, and --max-tokens 8192 still passing through — because 8192 is 4 characters. Floor 5 dominates floor 10 on every axis measured, so this is not a coverage-vs-over-match trade-off.

Why it survived to head: the suite cannot tell the floors apart. Every positive redaction assertion uses a value ≥10 chars (the one short-looking fixture, --password P@ss'w0rd123, is 12), and mutant M2 (floor 10 → 5) survived green 69/69. On a P1 credential fix that is the load-bearing gap — a boundary CI cannot distinguish in either direction is how a coverage regression lands in the final commit with nothing turning red.

I am not submitting a third CHANGES_REQUESTED. This account already has two standing (7df466e0, 566d6a74), reviewDecision is CHANGES_REQUESTED, and mergeStateStatus is BLOCKED, so the PR cannot merge and another review from me adds no gate. One consequence for whoever picks this up, flagged because it cuts the other way: both standing reviews are pinned to commits whose named Criticals are now closed, so dismissing them as stale is legitimate — but doing so makes this mergeable with the floor regression still in it. Settle the floor and the boundary fixture before dismissing anything.

Full reasoning, the hand-derived table, the corroborated mutation result, and the three questions for the maintainer are in the Stage 2 and Stage 3 comments above (both re-derived this pass, not carried forward). Escalated to @zjunothing — already assigned, sole core-telemetry owner.

@yiliang114 — the architecture is right and I am not asking you to change it. Two items before merge: lower the flag floor to 5, and add the fixture pair that pins both sides of the boundary (--password hunter2 must redact, --max-tokens 8192 must not) so this cannot silently regress again.

中文说明

⏸️ 仍然 defer —— 不批准,也不 request changes。 钉在我实际审查的 commit 41714d9f 上。它取代我 23:56 在同一 commit 上那次 defer 的表述 —— 那一条无法编辑。

对上一次 defer 的更正。 它说阻碍是"归属权,不是质量"。归属权依然是阻碍 —— 遥测脱敏策略是维护者的决定,而 #11198status/ready-for-human。但现在也有一条质量发现,而且它背后是实测数据而不是观点。

第二轮 /verify 报告在那次 defer 写完之后才落地。最后一个 commit 把 flag 路径从无下限的 SECRET_VALUE 换成了带 10 字符取值下限的 SECRET_FLAG_VALUE。我从 diff 手工推导了后果:mysql --password hunter2(7)、--token abc123(6)、--api-key sk-12345(8)、--password="pw123"(引号内 5)任何分支都不匹配 —— 引号分支要求内部 ≥10 字符,非引号分支要求总计 ≥10,而 ENV_SECRET_PATTERN(要求 key=value)和 AUTHORIZATION_PATTERN(要求字面的 authorization)都接不住。它们会原样送达第三方 RUM 后端。而 --password--token 是 PR 描述里点名认领的覆盖向量。

两个基线要分开说,因为答案不同: 相对 main不是回退 —— base 完全没有脱敏(grep 零命中),head 对真实长凭据关闭了全部 8 个点名向量,实测泄漏 41/41 → 23/41。相对本分支自己更早的版本回退 —— /verify566d6a7431827e9d 各自构建成独立对照臂,两版都脱敏 12/12 个短取值 flag 形态、泄漏 10/41;head 只脱敏 3/12、泄漏 23/41。

修复是一行,而且是实测过的,不是猜想。 第五个臂把下限设为 5({5,} / {4,},保留 {0,64} 边界),52/52 断言通过:9/9 被重新打开的形态关闭、8/8 描述向量保持脱敏、无害文本逐字节一致 12/12,并且 --max-tokens 8192 仍原样通过 —— 因为 8192 只有 4 个字符。下限 5 在每一个实测维度上都优于下限 10,所以这不是"覆盖面 vs 过度匹配"的取舍。

它为什么能活到 head: 套件分辨不出两种下限。每一条正向脱敏断言用的取值都 ≥10 字符(唯一看着短的 fixture --password P@ss'w0rd123 是 12 字符),变异体 M2(下限 10 → 5)全绿存活 69/69。在一个 P1 凭据修复上这就是承重的缺口 —— 一个 CI 在任一方向都分辨不出的边界,正是一处覆盖面回退能落在最后一个 commit 上而没有任何东西变红的原因。

我不提交第三个 CHANGES_REQUESTED 本账号已有两个生效(7df466e0566d6a74),reviewDecisionCHANGES_REQUESTEDmergeStateStatusBLOCKED,所以 PR 合不了,我再加一个评审不会增加门禁。有一个后果要提醒接手的人,因为它指向另一个方向:两个生效评审都钉在其点名 Critical 已全部关闭的 commit 上,所以把它们当陈旧评审 dismiss 是合理的 —— 但那样做会让这个 PR 在下限回退仍然存在的情况下变成可合并。 在 dismiss 任何东西之前,先把下限和边界 fixture 定下来。

完整推理、手工推导的表格、复核过的变异结果,以及给维护者的三个问题,都在上方 Stage 2 与 Stage 3 评论中(两条都是本轮重新推导的,不是照搬)。已转交 @zjunothing —— 已被指派,core-telemetry 唯一 owner。

@yiliang114 —— 架构是对的,我不要求你改它。合并前两条:把 flag 下限降到 5;加上钉住边界两侧的 fixture 对(--password hunter2 必须脱敏,--max-tokens 8192 必须不脱敏),让它不能再静默回退。

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

@qwen-code-ci-bot qwen-code-ci-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.

3 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • issue-scope mismatch: Fixes #11198 auto-closes an issue whose stated scope is wider than this diff (qwen-logger.ts:191-194) - already reported (comment 5633025309, triage stage 2 finding 8); no author reply
  • fourth credential-redaction table / reuse of the existing shape tables (qwen-logger.ts:191-194) - already reported (comment 3989187979, R1-10); consolidation direction declined twice by the author, and the structural direction is carried by…
  • dead 'error_excerpt' allowlist entry (qwen-logger.ts:115) - already reported (comment 5633025309, triage stage 2 finding 4; comment 5633024923, stage 1)

Not reviewed: test-efficacy probe — harnessValidated: null (its baseline run tripped the vitest globalSetup prerequisite guard), so no revert/mutant/hunk evidence was measured for the 252 added test lines.

Not explored to full depth (tool budget reached): "agent 6a": none — I did not run npx vitest run src/telemetry/qwen-logger/qwen-logger.test.ts at HEAD (I relied on the PR's green CI at the anchor plus direct tsx execu….

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

Convergence: round 3 posted 14 inline comment(s), 14 of them reported for the first time; the previous round posted 9 (8 new). Findings keep coming back to the same files: packages/core/src/telemetry/qwen-logger/qwen-logger.ts (findings in rounds 1, 2; 13 more now); packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts (findings in round 2; 1 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 3 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):test-efficacy probe — harnessValidated: null (its baseline run tripped the vitest globalSetup prerequisite guard), so no revert/mutant/hunk evidence was measured for the 252 added test lines.

未探索到全部深度(达到工具调用预算):"agent 6a"none — I did not run npx vitest run src/telemetry/qwen-logger/qwen-logger.test.ts at HEAD (I relied on the PR's green CI at the anchor plus direct tsx execu…

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

收敛情况:第 3 轮发布了 14 条行内评论,其中 14 条是首次提出;上一轮发布了 9 条(其中 8 条首次提出)。发现反复回到同一批文件:packages/core/src/telemetry/qwen-logger/qwen-logger.ts(第 1、2 轮已出过发现,本轮又有 13 条);packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts(第 2 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated

@qwen-code-ci-bot qwen-code-ci-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.

2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • hand-maintained three-key allowlist does not deliver class closure (qwen-logger.ts:106) - already reported (comment 3994853920, R3-8)
  • top-level arm visits only message, so snapshots is never visited (qwen-logger.ts:218) - already reported (comment 3994853924, R3-10)

Not reviewed: test-efficacy probe — harnessValidated: null, its baseline run tripped the vitest globalSetup prerequisite guard, so no revert/mutant/hunk evidence was measured for the added test lines (this is a harness limitation, not a silent agent).

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

Convergence: round 4 posted 4 inline comment(s), 4 of them reported for the first time; the previous round posted 14 (14 new). Findings keep coming back to the same files: packages/core/src/telemetry/qwen-logger/qwen-logger.ts (findings in rounds 2, 3; 3 more now); packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts (findings in round 3; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

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

本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):test-efficacy probe — harnessValidated: null, its baseline run tripped the vitest globalSetup prerequisite guard, so no revert/mutant/hunk evidence was measured for the added test lines (this is a harness limitation, not a silent agent).

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

收敛情况:第 4 轮发布了 4 条行内评论,其中 4 条是首次提出;上一轮发布了 14 条(其中 14 条首次提出)。发现反复回到同一批文件:packages/core/src/telemetry/qwen-logger/qwen-logger.ts(第 2、3 轮已出过发现,本轮又有 3 条);packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts(第 3 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts Outdated
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts
const MAX_RETRY_EVENTS = 100;

const ERROR_TEXT_PROPERTY_KEYS = ['error_message', 'error_excerpt', 'error'];
const REDACTED_ERROR_TEXT = '***REDACTED***';

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] R4-3: This head ships the opposite redaction policy from the one recorded on the linked issue, and Fixes #11198 will auto-close that issue on merge — so a decision the triage gate expressly reserved to a maintainer ends up documented against a false record. #11198 carries status/ready-for-human because, per its own triage stage-2 comment, "choosing between them is a product/privacy call, not an engineering one: 1. Shape-based masking … Keeps errors debuggable; can never be provably complete. 2. Fingerprint / argument-drop … Cannot leak by construction; loses diagnostic value." The only author statement in that thread resolves it the other way from this code (2026-09-11T10:07:40Z): "Policy: shape-based masking, not fingerprinting. The pass strips URL credentials, Authorization: Bearer <token> headers, secret-bearing flags (--token, --_authToken, --password), KEY=value env-style secrets … and ANSI/control chars. Masking over fingerprinting is deliberate: errors stay debuggable." Commit c62948304f (2026-09-12, roughly 19 hours later) deleted all five of those named pattern families and adopted strategy 2. The concrete cost: @zjunothing — sole core-telemetry owner, escalated twice in reviews 5184325298 and 5184495508 with "telemetry redaction policy is a maintainer decision" — rules on the policy by reading an issue thread whose last technical word says diagnostics are preserved and five shapes are masked, while the code being merged drops 100% of free-form error text on a default-on channel. The Fixes keyword then closes the issue with that contradiction as its final record, so nothing prompts a re-read. The PR description does state the tradeoff under Risk & Scope, which is why this is a Suggestion rather than a blocker — but the description does not reach the issue thread.

Before merge, post a correction on #11198 stating that the shipped policy is now strategy 2 (whole-field replacement with a fixed ***REDACTED*** marker at the enqueueLogEvent choke point, no shape matching), that the earlier "shape-based masking, not fingerprinting" comment is superseded, and that the diagnostic-loss tradeoff is the one recorded in this PR's Risk & Scope. Alternatively drop the Fixes keyword and let the maintainer close #11198 after ruling on the policy it was marked ready-for-human for.

Witness:

ISSUE #11198, author comment 2026-09-11T10:07:40Z:
  "Policy: shape-based masking, not fingerprinting. The pass strips URL credentials,
   Authorization: Bearer <token> headers, secret-bearing flags (--token, --_authToken,
   --password), KEY=value env-style secrets ..., and ANSI/control chars."

HEAD c62948304f (2026-09-12), pattern-family census over qwen-logger.ts:
  git show HEAD:.../qwen-logger.ts | grep -cE \
    "Bearer|Authorization|--token|_authToken|password|stripAnsi|redactUrlCredentials|API_KEY|DSN"
  -> 0            (same census at the round-3 anchor 566d6a74ad -> 18)

the only redaction left in the file:
  :213   properties[key] = REDACTED_ERROR_TEXT;
  :220   (event as RumExceptionEvent).message = REDACTED_ERROR_TEXT;

No behavioural probe applies to a documentary claim; both arms above are quoted from the issue evidence and from the tree at the reviewed commit.

中文说明

当前 head 落地的脱敏策略,与作者在关联 issue 上记录下来的策略正好相反,而 Fixes #11198 会在合并时自动关闭该 issue —— 于是一个被 triage gate 明确保留给维护者的决定,最终以一份错误的记录被关闭。

#11198status/ready-for-human,正是因为其 triage stage-2 评论写明:"在两者之间做选择是产品/隐私决定,不是工程决定:1. 基于形状的掩码 …… 保留错误可调试性,但永远无法被证明是完备的。2. 指纹化 / 丢弃参数 …… 结构上不可能泄漏,但失去诊断价值。" 而该 thread 中唯一的作者表态与当前代码方向相反(2026-09-11T10:07:40Z):"策略:基于形状的掩码,不是指纹化。该处理会清除 URL 凭据、Authorization: Bearer <token> 头、含密钥的命令参数(--token--_authToken--password)、KEY=value 形式的环境变量密钥……以及 ANSI/控制字符。选择掩码而非指纹化是有意为之:错误保持可调试。"

commit c62948304f(2026-09-12,约 19 小时后)删掉了上述全部五类被点名的模式,改用了方案 2。

具体代价:@zjunothing —— core-telemetry 唯一 owner,在评审 5184325298 与 5184495508 中两次被转交,并被告知"遥测脱敏策略是维护者的决定" —— 在判断该策略时读到的是一个 issue thread,而它最后的技术表述仍说诊断信息被保留、五类形状被掩码;但即将合并的代码在一条默认开启的通道上丢弃了 100% 的自由文本错误信息。随后 Fixes 关键字会以这份矛盾作为最终记录关闭该 issue,因此不会再有任何提示促使人们重读。

本 PR 描述确实在"风险与范围"中说明了这一取舍,所以这是一条建议而非阻断项 —— 但描述触达不到 issue thread。

修法:合并前在 #11198 上发一条更正,说明落地的策略现在是方案 2(在 enqueueLogEvent 收口点用固定的 ***REDACTED*** 标记整字段替换,不做任何形状匹配)、此前"基于形状的掩码,不是指纹化"的表态已被取代、以及诊断信息损失这一取舍即本 PR"风险与范围"所记录的内容。或者去掉 Fixes 关键字,让维护者在就该 issue 被标记 ready-for-human 的策略做出判断之后再关闭它。

上方 Witness:不存在适用于文档性主张的行为探针;两侧证据分别引自 issue 材料与被审 commit 上的代码树。

— qwen3.8-max via Qwen Code /review (v0.23.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.

[Suggestion] R4-3: still stands — this head ships the opposite redaction policy from the one recorded on the linked issue, and Fixes #11198 will auto-close that issue on merge, so a decision the triage gate expressly reserved to a maintainer ends up documented against a false record. #11198 carries status/ready-for-human because its own triage records the choice as "a product/privacy call, not an engineering one: 1. Shape-based masking … Keeps errors debuggable; can never be provably complete. 2. Fingerprint / argument-drop … Cannot leak by construction; loses diagnostic value." The only author statement in that thread resolves it the other way from this code: "Policy: shape-based masking, not fingerprintingMasking over fingerprinting is deliberate: errors stay debuggable." The code then deleted all five of the pattern families that comment named and adopted strategy 2. The sole core-telemetry owner — escalated twice with "telemetry redaction policy is a maintainer decision" — rules on the policy by reading an issue thread whose last technical word says diagnostics are preserved, while the code being merged drops 100% of free-form error text on a default-on channel. The Fixes keyword then closes the issue with that contradiction as its final record, so nothing prompts a re-read. No correction has been posted on the issue as of this head, and the description still carries Fixes #11198. The PR description does state the tradeoff under Risk & Scope, which is why this is a Suggestion rather than a blocker — but the description does not reach the issue thread.

Before merge, post a correction on #11198 stating that the shipped policy is now strategy 2 (whole-field replacement with a fixed ***REDACTED*** marker at the enqueueLogEvent choke point, no shape matching), that the earlier "shape-based masking, not fingerprinting" comment is superseded, and that the diagnostic-loss tradeoff is the one recorded in this PR's Risk & Scope. Alternatively drop the Fixes keyword and let the maintainer close #11198 after ruling on the policy it was marked ready-for-human for.

Witness:

ISSUE #11198, author comment 2026-09-11T10:07:40Z:
  "Policy: shape-based masking, not fingerprinting. The pass strips URL credentials,
   Authorization: Bearer <token> headers, secret-bearing flags (--token, --_authToken,
   --password), KEY=value env-style secrets ..., and ANSI/control chars."

Pattern-family census over qwen-logger.ts, re-run this round at head ea640b45:
  git show HEAD:.../qwen-logger.ts | grep -cE \
    "Bearer|Authorization|--token|_authToken|password|stripAnsi|redactUrlCredentials|API_KEY|DSN"
  -> 0            (same census at the round-3 anchor 566d6a74ad -> 18)

the only redaction left in the file:
  :213   properties[key] = REDACTED_ERROR_TEXT;
  :220   (event as RumExceptionEvent).message = REDACTED_ERROR_TEXT;

No behavioural probe applies to a documentary claim; both arms are quoted from the issue evidence and from the tree at the reviewed commit.

中文说明

本条依然成立:当前 head 落地的脱敏策略与关联 issue 上记录下来的策略正好相反,而 Fixes #11198 会在合并时自动关闭该 issue —— 于是一个被 triage gate 明确保留给维护者的决定,最终以一份错误的记录被关闭。

#11198status/ready-for-human,正是因为其 triage 把这个选择记录为「产品/隐私决定,不是工程决定:1. 基于形状的掩码 …… 保留错误可调试性,但永远无法被证明是完备的。2. 指纹化 / 丢弃参数 …… 结构上不可能泄漏,但失去诊断价值。」而该 thread 中唯一的作者表态与当前代码方向相反:「策略:基于形状的掩码,不是指纹化 …… 选择掩码而非指纹化是有意为之:错误保持可调试。」随后代码删掉了那条评论点名的全部五类模式,改用了方案 2。

core-telemetry 唯一 owner —— 已被两次转交并告知「遥测脱敏策略是维护者的决定」—— 在判断该策略时读到的是一个 issue thread,而它最后的技术表述仍说诊断信息被保留;但即将合并的代码在一条默认开启的通道上丢弃了 100% 的自由文本错误信息。随后 Fixes 关键字会以这份矛盾作为最终记录关闭该 issue,因此不会再有任何提示促使人们重读。截至当前 head,issue 上没有更正说明,描述里仍然写着 Fixes #11198。本 PR 描述确实在「风险与范围」中说明了这一取舍,所以这是一条建议而非阻断项 —— 但描述触达不到 issue thread。

修法:合并前在 #11198 上发一条更正,说明落地的策略现在是方案 2(在 enqueueLogEvent 收口点用固定的 ***REDACTED*** 标记整字段替换,不做任何形状匹配)、此前「基于形状的掩码,不是指纹化」的表态已被取代、以及诊断信息损失这一取舍即本 PR「风险与范围」所记录的内容。或者去掉 Fixes 关键字,让维护者在就该 issue 被标记 ready-for-human 的策略做出判断之后再关闭它。

上方 Witness:不存在适用于文档性主张的行为探针;两侧证据分别引自 issue 材料与被审 commit 上的代码树(模式族普查在本轮于 head ea640b45 重新执行,结果为 0,而第 3 轮锚点 566d6a74ad 上为 18)。

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

Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts
@yiliang114

yiliang114 commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closeout at ea640b45c2fd: no source changes or new verification run in this pass. The current two-file diff is smaller than the initial shape parser (implementation: 64 → 25 changed lines; tests: 86 → 73). The ledger continues from five substantive rounds at c6294830 to six after the hook producer was removed; this evidence-only pass does not add a round.

R5-1 is stale: both language versions of the PR description now say error_message / error_excerpt / top-level message are marker-replaced, while hook error is no longer collected. That matches the exact current source, so that thread can be resolved.

Four threads remain for explicit disposition:

  • R4-3: whole-field replacement versus retaining diagnostic text is still a maintainer policy decision. The old auto-close claim is no longer true: the PR uses Refs #11198, GitHub returns no closing-issue references, and the issue's implementation update now records whole-field replacement without approving the policy.
  • R4-2: in-place enqueue mutation and what the tests observe need an explicit contract. No affected in-tree caller was demonstrated; no API rewrite is being made here.
  • R4-1: non-command hook failures lose raw diagnostic causes without an equivalent structured discriminator. An enum through the hook/telemetry layers is a separate design decision; restoring raw text is not the fix.
  • R5-2: the hook test's missing raw-value assertion is a valid, bounded regression-test improvement, not proof that the current producer leaks. It remains paused at the substantive-round limit; accepting it does not require a generalized telemetry redesign.

Recommendation: explicitly accept or reject whole-field masking first. I favor keeping raw error text out of this default-on channel, with structured diagnostics designed separately. No broader privacy guarantee is claimed for unknown keys, snapshots, stack, or other telemetry sinks. No additional review run is requested for this comment-only pass.

`redactEventErrorText` already replaces every string under `error_message` /
`error_excerpt` / top-level `message` with a fixed marker at the enqueue
boundary, so `logHookCallEvent`'s `getTelemetryLogPromptsEnabled()` gate on
`properties['error']` could only ever emit the constant `'***REDACTED***'` —
a consent flag that no longer gates any content. Hook failure is already
signalled by `success` / `exit_code`, so the raw error text is dropped
fail-closed instead, and the now-dead `'error'` entry is removed from
`ERROR_TEXT_PROPERTY_KEYS`.

The failed-hook test now drives a config with telemetry log prompts enabled
and asserts `properties` carries no `error` key, which goes red when the
gated assignment is restored.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtycy85205
@github-actions github-actions Bot removed the review/self-reported The linked issue was opened by the PR author (self-reported) label Sep 12, 2026
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Removed the automatic-closing keyword from the PR and made the current whole-field replacement policy and pending maintainer decision explicit in its description. This corrects the stale completion claim; it does not decide the privacy-versus-diagnostics tradeoff. Leaving the policy thread open for that decision.

中文说明

已移除 PR 的自动关闭关键字,并明确当前实现是整字段替换、仍需维护者决定隐私与诊断信息的取舍。此次仅纠正完成声明,不代替策略决策,因此保留本线程。

@qwen-code-ci-bot qwen-code-ci-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.

3 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • dead 'error_excerpt' allowlist entry (qwen-logger.ts:106) - already reported (issue comment 5633025309; review summaries 5182358058, 5184937341)
  • blanket top-level message redaction / invalid_chunk flattening (qwen-logger.ts:218) - already reported (comment 3995556248, R4-4)
  • two-name allowlist fails open for unlisted fields (qwen-logger.ts:106) - already reported (comment 3994853920, R3-8; comment 3994853924, R3-10)

Not reviewed: test-efficacy probe — harnessValidated: null; its baseline run tripped the vitest globalSetup prerequisite guard, so the systematic revert/mutant/hunk sweep measured nothing for the four changed hunks (targeted mutations were executed instead by this round's verifier, in an isolated mirror).

Convergence: round 5 posted 6 inline comment(s), 3 of them reported for the first time; the previous round posted 4 (4 new). Findings keep coming back to the same files: packages/core/src/telemetry/qwen-logger/qwen-logger.ts (findings in round 4; 2 more now); packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts (findings in round 4; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

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

本轮确认的 3 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查(原文为英文):test-efficacy probe — harnessValidated: null; its baseline run tripped the vitest globalSetup prerequisite guard, so the systematic revert/mutant/hunk sweep measured nothing for the four changed hunks (targeted mutations were executed instead by this round's verifier, in an isolated mirror).

收敛情况:第 5 轮发布了 6 条行内评论,其中 3 条是首次提出;上一轮发布了 4 条(其中 4 条首次提出)。发现反复回到同一批文件:packages/core/src/telemetry/qwen-logger/qwen-logger.ts(第 4 轮已出过发现,本轮又有 2 条);packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts(第 4 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

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


logger.enqueueLogEvent(event);

expect(event.message).toBe('***REDACTED***');

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] R4-2: still stands — redactEventErrorText rewrites the caller's event object in place, and every test that observes an event does so through that same mutated reference, so nothing in the suite pins the producer-to-property mapping or the object that is actually queued and serialized. enqueueLogEvent mutates its argument at :187/:207-221 and returns nothing, on a class re-exported as public package API (packages/core/src/index.ts:587), and createRumEvent shallow-spreads ...(properties || {}), so rumEvent.properties is the caller's inner object. This diff already shows the cost: the tool-call privacy expectation had to be rewritten from error_message: 'failed' to '***REDACTED***' purely because vi.spyOn records arguments by reference and the call-through spy mutated the recorded object afterwards. So remapping error_message: event.error (:574) to event.error_type ships the wrong field to the RUM endpoint with the entire telemetry suite green, while a behaviour-preserving copy-then-redact fix turns this new assertion red — which invites deleting the assertion instead of the mutation. No in-tree caller is harmed today (all ~45 call sites build fresh literals), so the cost is test observability plus a public-API footgun rather than a live bug.

Redact a shallow copy and queue the copy, leaving the caller's object untouched, then re-point the assertions at what actually ships — the queued deque ((logger['events'].toArray() as RumEvent[])[0], precedent at test:280) or createRumPayload() (precedent at test:163-176). If in-place mutation is deliberately the contract, stub the real implementation in the two producer tests instead (vi.spyOn(logger, 'enqueueLogEvent').mockImplementation(() => {})) and restore their raw-value expectations, so the mapping stays pinned.

Witness:

BASE (merge base e40bf35eb9 source+test) + mapping mutant  error_message: event.error -> event.error_type:
  AssertionError: - "error_message": "failed" / + "error_message": "unknown"    Tests 1 failed | 43 passed (44)
PR (head ea640b45) + the same mutant:
  src/telemetry/qwen-logger/qwen-logger.test.ts : Tests 44 passed (44)
  src/telemetry (whole dir, 30 files)           : Tests 1001 passed (1001)
  => MUTANT SURVIVES
Comparator self-test at head (symmetric remap of the NON-redacted sibling :573 error_type -> event.error):
  AssertionError - "error_type": "unknown" / + "error_type": "failed"    1 failed | 43 passed  => comparator live
Shallow copy-then-redact variant at :187: Tests 2 failed | 42 passed (44)
  error text redaction > replaces error text at the enqueue boundary -> expected 'raw top-level error' to be '***REDACTED***'

The base/head pair is the deciding evidence: at the merge base the suite caught this remap, at head it does not, across 1001 telemetry tests. That is a measured loss of discriminating power caused by this diff. (Round 4 recorded witness: not run for this mutant; it has now been executed.)

One correction to the fix constraint this thread carried last round: the clone must stay shallow, but not because a test would go red. integration.test.circular.ts:76 does enqueue an event carrying a circular httpAgent, yet a JSON deep clone's throw lands inside enqueueLogEvent's own try and is swallowed by the catch at qwen-logger.ts:203-205 — measured: threw:false deque 0 -> 0 enqueued:false swallowed err:1, with that file still reporting Tests 2 passed (2). The real constraint is that silent drop, and it is invisible because neither *.test.circular.ts file is executed by any job (vitest's default include **/*.{test,spec}.?(c|m)[jt]s?(x) does not match them, and no npm script, workflow or config references them).

Acceptance criterion: the re-pointed error text redaction case must still go red when this.redactEventErrorText(event) is removed from enqueueLogEvent (qwen-logger.ts:187) while staying green under a copy-on-enqueue implementation — please run that mutation and confirm it reds.

中文说明

本条依然成立:redactEventErrorText 就地改写调用方传入的事件对象,而所有观察该事件的测试读到的都是同一个被改写过的引用,因此整个套件里没有任何东西钉住「生产者字段 → RUM 属性」的映射,也没有任何东西观察真正入队并被序列化的那个对象。enqueueLogEvent:187/:207-221 改写自己的入参且不返回任何值,而这个类是包的公开 API(packages/core/src/index.ts:587);createRumEvent...(properties || {}) 做浅展开,所以 rumEvent.properties 就是调用方的那个内层对象。

本 diff 已经暴露了这个代价:tool-call 隐私用例的期望值不得不从 error_message: 'failed' 改写成 '***REDACTED***',纯粹是因为 vi.spyOn 按引用保存参数、而调用真实实现的 spy 在保存之后又改写了那个对象。于是把 error_message: event.error:574)改成 event.error_type 会让错误字段上报到 RUM,而整个遥测套件全绿;反过来,一个保持行为不变的「复制后再脱敏」修法会让这条新断言变红 —— 这等于在诱导删掉断言而不是删掉变异。

今天没有仓库内调用方受损(约 45 处调用都现场构造字面量),所以代价是测试可观测性加上一个公开 API 陷阱,而不是线上缺陷。

修法:对副本脱敏并入队副本,让调用方对象保持原样;再把断言改为指向真正上报的内容 —— 队列((logger['events'].toArray() as RumEvent[])[0],先例见 test:280)或 createRumPayload()(先例见 test:163-176)。如果就地改写确实是有意为之的契约,那就在那两个生产者测试里替换掉真实实现(vi.spyOn(logger, 'enqueueLogEvent').mockImplementation(() => {}))并恢复它们对原始值的断言。

上方 Witness:base 与 head 两臂的对照是决定性证据 —— 在 merge base 上套件能抓到这个字段重映射,在 head 上抓不到(跨越 1001 个遥测测试)。这是本 diff 造成的、可测量的判别力损失。(第 4 轮记录的是 witness: not run,现已实际执行。)

对上一轮该 thread 所记约束的一处更正:克隆必须保持拷贝,但原因不是某个测试会变红。integration.test.circular.ts:76 确实入队了一个带循环引用 httpAgent 的事件,然而 JSON 深克隆抛出的异常落在 enqueueLogEvent 自己的 try 里,被 qwen-logger.ts:203-205catch 吞掉 —— 实测:threw:false deque 0 -> 0 enqueued:false swallowed err:1,而该文件仍报告 Tests 2 passed (2)。真正的约束是这次静默丢弃,而它之所以不可见,是因为两个 *.test.circular.ts 文件根本不会被任何 job 执行(vitest 默认 include **/*.{test,spec}.?(c|m)[jt]s?(x) 匹配不到它们,也没有任何 npm script、workflow 或配置引用它们)。

验收标准:改为指向队列/payload 后的 error text redaction 用例,在移除 enqueueLogEvent 里的 this.redactEventErrorText(event)qwen-logger.ts:187)时必须变红,而在「复制后脱敏」的实现下必须保持绿 —— 请实际执行该变异并确认它变红。

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

Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts
Comment thread packages/core/src/telemetry/qwen-logger/qwen-logger.ts
Comment on lines 1119 to 1120
exit_code: event.exit_code,
};

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] R4-1: (fix-induced) the reported input is closed — this round took the first of the two options that thread prescribed, deleting the gated properties['error'] = event.error assignment and dropping 'error' from ERROR_TEXT_PROPERTY_KEYS, so the switch that could only ever emit a constant is gone. But the change that closed it opened a new defect at the same site: the rationale recorded beside the fix ("the failure is already signalled by success / exit_code") holds only for hook_type: 'command', and exit_code is undefined for the other three hook types, so their failures now reach the sink with no cause discriminator at all. HookExecutionResult.exitCode is optional (hooks/types.ts:1340) and only the command runner sets it — a repo-wide grep for exitCode in packages/core/src/hooks (non-test) hits only the declaration, the read at hookEventHandler.ts:1085, and hookRunner.ts; httpHookRunner's four success: false results (:147, :189, :206, :331) carry only error, and the prompt/function runners never set it either. safeJsonStringify (:385) then drops the undefined key, so an HTTP hook that fails URL validation, one that is aborted, one that times out and one that hits a transport error all serialize to the byte-identical {hook_event_name, hook_type: 'http', hook_name, duration_ms, success: 0} — and logHookCallEvent writes exactly six properties at head, so nothing else discriminates cause. Hook-failure triage from usage telemetry is therefore impossible for 3 of 4 hook types. The command-hook half is narrower than it first looks: hookRunner.ts:1431-1439 sets exitCode ?? -1 for a signal kill, so signal vs. non-zero exit is distinguishable; what collapses is timeout vs. abort vs. spawn error, since hookRunner.ts:1234-1246 and :1454-1462 call finish() with no exitCode.

Keep the fail-closed drop of raw text, but re-establish a non-raw discriminator in the same properties record: forward the existing coarse HookExecutionOutcome union ('success' | 'blocking' | 'non_blocking_error' | 'cancelled', hooks/types.ts:124-128) through HookCallEvent, or derive an error_type/failure_kind enum from the failure class the way logToolCallEvent (:573) and logRipgrepRuntimeRecoveryEvent (:1005) already do. An enum carries no user or command text, so it does not weaken the privacy fix — and it also gives HookCallEvent.error a reader again.

Witness:

PROBE hook wire {"name":"hook_call#PostToolUse","properties":{"hook_event_name":"PostToolUse","hook_type":"http","hook_name":"notify","duration_ms":12,"success":0}} <- error: 'URL validation failed: blocked scheme'
PROBE hook wire {"name":"hook_call#PostToolUse","properties":{"hook_event_name":"PostToolUse","hook_type":"http","hook_name":"notify","duration_ms":12,"success":0}} <- error: 'HTTP hook execution cancelled (aborted): id'
PROBE hook wire {"name":"hook_call#PostToolUse","properties":{"hook_event_name":"PostToolUse","hook_type":"prompt","hook_name":"ctx","duration_ms":12,"success":0}} <- error: 'Prompt hook timed out after 5000ms'
PROBE hook wire {"name":"hook_call#PostToolUse","properties":{"hook_event_name":"PostToolUse","hook_type":"command","hook_name":"cleanup.sh","duration_ms":12,"success":0}} <- error: 'Hook timed out after 5000ms'
Two different HTTP failures serialize to byte-identical property sets; exit_code is absent
(not null) because flushToRum serializes with safeJsonStringify (qwen-logger.ts:385).

The new key must not be named error_message or error_excerptconst ERROR_TEXT_PROPERTY_KEYS = ['error_message', 'error_excerpt']; (qwen-logger.ts:106) replaces any string under those keys with ***REDACTED*** at the enqueue boundary, so a discriminator so named would itself be erased. It must not be named error either, which expect(callArgs.properties).not.toHaveProperty('error'); (qwen-logger.test.ts:825) pins absent. error_type satisfies both: it is already the house classification key and is not in the allowlist.

Acceptance criterion: a new case in describe('logHookCallEvent') building a failed non-command hook — new HookCallEvent('PostToolUse', 'http', 'notify', { tool_name: 'shell' }, 200, false, undefined, undefined, undefined, undefined, 'HTTP hook execution cancelled (aborted): h1') — and asserting the discriminator (e.g. expect(rumEvent.properties.error_type).toBe('cancelled')). Removing the mapping must turn it red; the neighbouring test at :782 uses objectContaining, so adding a key does not disturb existing assertions.

中文说明

(本条由上一轮的修复引入)原报告的问题已经关闭 —— 本轮采用了该 thread 给出的两个选项中的第一个:删掉了带条件的 properties['error'] = event.error 赋值,并把 'error'ERROR_TEXT_PROPERTY_KEYS 中去掉,于是那个只能输出常量的开关不复存在。但关闭它的这次改动在同一个位置打开了一个新缺陷:写在修复旁边的理由(「失败信息已由 success / exit_code 承载」)只对 hook_type: 'command' 成立,而另外三种 hook 类型的 exit_codeundefined,因此它们的失败现在到达 sink 时完全没有任何原因判别字段。

HookExecutionResult.exitCode 是可选的(hooks/types.ts:1340),且只有 command runner 会设置它 —— 在 packages/core/src/hooks(非测试)中对 exitCode 做全仓 grep,只命中声明本身、hookEventHandler.ts:1085 的读取,以及 hookRunner.tshttpHookRunner 的四个 success: false 结果(:147:189:206:331)只带 error,prompt/function runner 也从不设置它。随后 safeJsonStringify:385)会丢弃 undefined 键,于是一个 URL 校验失败的 HTTP hook、一个被中止的、一个超时的、一个传输错误的,全部序列化成逐字节相同的 {hook_event_name, hook_type: 'http', hook_name, duration_ms, success: 0} —— 而 logHookCallEvent 在 head 上只写六个属性,没有任何其他字段能区分原因。因此 4 种 hook 类型中有 3 种无法再从使用统计里做失败分诊。

command hook 那一半比看上去要窄:hookRunner.ts:1431-1439 对被信号杀死的场景设置了 exitCode ?? -1,所以「信号杀死」与「非零退出」是可以区分的;真正塌缩的是超时 vs 中止 vs spawn 错误,因为 hookRunner.ts:1234-1246:1454-1462 调用 finish() 时都没有 exitCode

修法:保留对原始文本的 fail-closed 丢弃,但在同一个 properties 记录里重新建立一个非原始文本的判别字段 —— 把已有的粗粒度 HookExecutionOutcome 联合类型('success' | 'blocking' | 'non_blocking_error' | 'cancelled'hooks/types.ts:124-128)通过 HookCallEvent 传过来,或者像 logToolCallEvent:573)与 logRipgrepRuntimeRecoveryEvent:1005)那样,从失败类别推导出一个 error_type/failure_kind 枚举。枚举不携带任何用户或命令文本,因此不会削弱这次隐私修复 —— 而且它顺带让 HookCallEvent.error 重新有了读取方。

上方 Witness 是实测的 hook wire payload:两种不同的 HTTP 失败序列化出逐字节相同的属性集合;exit_code缺失(不是 null),因为 flushToRumsafeJsonStringifyqwen-logger.ts:385)序列化。

约束:新字段不能命名为 error_messageerror_excerpt —— const ERROR_TEXT_PROPERTY_KEYS = ['error_message', 'error_excerpt'];qwen-logger.ts:106)会在入队边界把这些键下的任何字符串替换为 ***REDACTED***,如此命名的判别字段自己就会被抹掉;也不能命名为 error,因为 expect(callArgs.properties).not.toHaveProperty('error');qwen-logger.test.ts:825)钉住了该键必须不存在。error_type 同时满足这两条:它已经是本仓库的分类字段惯例,且不在 allowlist 中。

验收标准:在 describe('logHookCallEvent') 中新增一个用例,构造一个失败的非 command hook —— new HookCallEvent('PostToolUse', 'http', 'notify', { tool_name: 'shell' }, 200, false, undefined, undefined, undefined, undefined, 'HTTP hook execution cancelled (aborted): h1') —— 并断言该判别字段(例如 expect(rumEvent.properties.error_type).toBe('cancelled'))。移除该映射后它必须变红;相邻的 :782 用例使用 objectContaining,因此新增一个键不会干扰既有断言。

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

Comment on lines 824 to 825
const callArgs = enqueueSpy.mock.calls[0][0];
expect(callArgs.properties).not.toHaveProperty('error');

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] R5-2: this test was renamed to an effect-shaped claim ("without forwarding raw error text") and given the comment below stating that guarantee, but its only value-level guard is the absence of one key name — the raw strings its own fixture still carries are never checked against the serialized event. The fixture is still constructed with stderr: 'error output' and error: 'Command failed' (:789-801; the 11th positional is error, per telemetry/types.ts:1150-1176), so re-forwarding either string under any other property key — hook_error, failure_reason — ships raw hook error text to the RUM endpoint with this suite green: expect.objectContaining at :805-819 tolerates added keys, and not.toHaveProperty('error') names only the key this round deleted. This round made the test weaker rather than stronger — at c62948304f it asserted error: '***REDACTED***', a value assertion, and the merge kept only the key-absence one. The sibling prompt-privacy test 40 lines above (:762-779) already models the effect-shaped check for this exact event type, serializing the event and asserting not.toContain on the raw strings.

Witness:

INTACT (unmodified PR):            Test Files 1 passed (1) / Tests 44 passed (44)
ARM A (restore the deleted line, key `error`):
  FAIL > logHookCallEvent > should log a failed hook call event without forwarding raw error text -> 1 failed
ARM B (identical raw text, key `hook_error`):
  Test Files 1 passed (1)  <- suite stays GREEN
ARM B payload: {"type":"hook","name":"hook_call#PostToolUse","properties":{"hook_event_name":"PostToolUse",
  "hook_type":"command","hook_name":"cleanup.sh","duration_ms":200,"success":0,"exit_code":1,
  "hook_error":"Command failed"}}

The probe flips: re-forwarding under the deleted key goes red, re-forwarding the identical raw string one key over ships to the sink with 44/44 green.

Suggested change
const callArgs = enqueueSpy.mock.calls[0][0];
expect(callArgs.properties).not.toHaveProperty('error');
const callArgs = enqueueSpy.mock.calls[0][0];
expect(callArgs.properties).not.toHaveProperty('error');
const serializedEvent = JSON.stringify(callArgs);
for (const raw of ['Command failed', 'error output']) {
expect(serializedEvent).not.toContain(raw);
}

Scope note: this covers the key-renaming hole only. The "raw text routed into snapshots" arm of the same scenario is already on this PR as R3-10 (author: "Still live at head c629483"), so the fix should not be written as though it closes that one too.

Acceptance criterion: the assertion above must go red if the lines this round deleted from logHookCallEvent are restored (properties['error'] = event.error;), or if the same text is routed through another key — that is the ARM B mutation, green today. Please run it and confirm.

中文说明

这个用例被改成了以效果命名的断言(「不转发原始错误文本」),并加上了下方这段说明该保证的注释,但它唯一的取值级守卫只是某一个键名不存在 —— 它自己的 fixture 里仍然带着的那些原始字符串,从来没有被拿去和序列化后的事件比对过。

fixture 仍然以 stderr: 'error output'error: 'Command failed' 构造(:789-801;第 11 个位置参数是 error,见 telemetry/types.ts:1150-1176)。因此把这两个字符串中的任意一个改用别的属性键转发出去 —— hook_errorfailure_reason —— 就会把原始 hook 错误文本送到 RUM 端点,而本套件依然是绿的::805-819expect.objectContaining 容忍新增键,而 not.toHaveProperty('error') 只点名了本轮删掉的那个键。

本轮实际上让这个用例变弱了而不是变强了 —— 在 c62948304f 上它断言的是 error: '***REDACTED***',那是一个取值断言;合并之后只保留了「键不存在」这一个。上方 40 行处的同类 prompt 隐私用例(:762-779)已经为完全相同的事件类型示范了以效果为形状的写法:序列化事件,然后对原始字符串断言 not.toContain

上方 Witness:探针发生了翻转 —— 用被删掉的那个键重新转发会变红;把完全相同的原始字符串换一个键转发,则会送到 sink,而 44/44 全绿。

范围说明:本条只覆盖「换键名」这个漏洞。同一场景里「原始文本被放进 snapshots」的那一半,已经作为 R3-10 存在于本 PR(作者回复:「在 head c629483 上依然存在」),所以修复不应写成好像连那一条也一起关闭了。

验收标准:如果本轮从 logHookCallEvent 删掉的那几行被恢复(properties['error'] = event.error;),或者同样的文本被换到另一个键下,上面的断言必须变红 —— 后者就是实测中的 ARM B 变异,今天是绿的。请实际执行并确认。

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

@chiga0 chiga0 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.

Review Summary

Files reviewed

  • packages/core/src/telemetry/qwen-logger/qwen-logger.ts
  • packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts

Findings

No blockers found.

Clean areas

  • Single choke point: redactEventErrorText is called at the top of enqueueLogEvent before the event enters the deque. All callers route through enqueueLogEvent, so no error text can bypass redaction.
  • Fail-closed hook approach: Instead of redacting properties['error'], the PR removes the hook error forwarding code entirely. Hook failures still signal via success: 0 / exit_code. Most conservative option — eliminates the leakage class rather than sanitizing it.
  • Redaction coverage: ERROR_TEXT_PROPERTY_KEYS = ['error_message', 'error_excerpt'] plus top-level message on exception/resource events covers all error-text carriers.
  • Type safety: (event as RumExceptionEvent).message cast is TypeScript-only narrowing; typeof message === 'string' guard prevents mutation on events without the field.
  • Test correctness: Uses toEqual for exact property-shape matching, correctly verifies redaction while preserving error_type. Hook test correctly asserts not.toHaveProperty('error').

Needs human review

  • Diagnostic trade-off: Hook error text is now completely absent from telemetry. Confirm that success: 0 + exit_code provides sufficient diagnostic signal.
  • Stale CHANGES_REQUESTED: The standing review references regex patterns that no longer exist at HEAD. Should be dismissed if the current approach is accepted, otherwise mergeStateStatus remains BLOCKED.

Reviewed with AI assistance.

@qqqys qqqys 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.

APPROVE

核对基线:head ea640b45c2fde6767ebbff99712dac8b48461583

历史阻塞问题:已解除

本 PR 历史上有三次 CHANGES_REQUESTED,最后一次是 2026-09-12T02:54:50Z 针对 41714d9f;当前 head 之后最新的一次 review(2026-09-12T14:57:47Z,针对 ea640b45)已降级为 COMMENTED,不再要求变更。43 条线程里 4 条未解决(R4-1、R4-2、R4-3、R5-2),逐条读过,全部是 [Suggestion] 级,没有 Critical、安全、数据损坏或回归级别的历史阻塞问题。按本渠道策略 Suggestion 不作为合入门禁。

本轮独立扫描:未发现 Critical

这是一个隐私收口改动,我按「是否存在绕过 redaction 的出口」和「是否存在未被覆盖的错误文本载体」两条主线核对:

  1. redaction 确实是唯一收口点。 出站队列 this.events 的写入只有两处:enqueueLogEvent 内的 this.events.push(event):195),以及重试回填的 this.events.unshift(eventsToRequeue[i]):1175)。前者的第一行就是 this.redactEventErrorText(event):187);后者回填的是此前已经入过队、因而已经被改写过的事件,不会把原文重新带回来。发送侧 this.events.toArray():304:377)只从队列读取,没有旁路构造 payload 的路径。因此所有出站事件都经过 redaction。
  2. 错误文本的载体被覆盖完整。 该 sink 里携带错误文本的字段只有三个:properties.error_message:574:703:880-881:908)、properties.error_excerpt,以及 exception 事件顶层的 message:696:716)。ERROR_TEXT_PROPERTY_KEYS 覆盖了前两个,redactEventErrorText 的后半段单独处理了 message:218-221),且都做了 typeof === 'string' 判定,不会把非字符串值改坏。我另外按 error|stack|stack_trace|details|excerpt|reason|message 扫过整个文件的字段位,没有发现第四个承载原文的键。
  3. hook 路径的处理是收紧而非放松。 删掉的 if (event.error && this.config?.getTelemetryLogPromptsEnabled()) properties['error'] = event.error; 原本会在开启 logPrompts 时把 hook 的原始错误文本放进 properties.error——而这个键不在 ERROR_TEXT_PROPERTY_KEYS 里。直接删除该赋值后,这条路径不再产生任何错误原文,方向是更严格,不构成泄漏。
  4. 过度改写只发生在一处且是安全方向。 :729message: \Content retry failed after ${event.total_attempts} attempts`是只含数字的构造串,也会被统一改写成REDACTED`。这是损失一点诊断信号,不是正确性问题,且失败方向是「少发数据」,可以接受。

CI:当前 head 上 Lint & StaticTest (ubuntu-latest, Node 22.x)Integration Tests (no-AK, No Sandbox)review-pr 全部 pass,没有由本 PR 引入的失败。

其余意见

未解决的 4 条 Suggestion 里有两条值得后续处理,但都不阻塞本次合入:R4-2 指出 redactEventErrorText 是就地改写传入的事件对象(生产路径上事件由各 create*Event 帮助函数在 enqueue 前逐次新建,因此不影响调用方状态,但测试若在 enqueue 后再观察同一对象就会看到被改写后的值,容易写出恒真断言);R4-3 指出本 head 采用的 redaction 策略与关联 issue 上记录的相反,而 Fixes #11198 会自动关闭该 issue,建议在 issue 或 PR 里补一句说明以免留下矛盾记录。

结论:历史阻塞问题已解除,本轮未发现可证明的 Critical,提交 APPROVE。

@yiliang114
yiliang114 added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit 85509c7 Sep 12, 2026
166 of 167 checks passed
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Posted as a PR comment rather than a review: this head already carries a pending review by the author (11 drafted replies), and GitHub rejects a second pending review. Nothing was deleted.

Reviewed at ea640b45c2 (2 files, +51/-47) against e40bf35eb9.

Verdict: no blocking defect at this head. The approach is right and strictly narrower than main: the enqueue boundary is the one place error text can leave the process, and a whole-field replacement cannot leak a substring the way a partial-text policy can. The regex-masking implementation the earlier rounds reviewed is not present in this revision at all.

Two notes that are real but non-blocking. Both are already carried by the standing bot threads (R4-4 / R3-8 / R5-1), so I am not re-filing them as new inline comments:

  1. The redaction is an allowlist, so it fails open for unlisted keys (qwen-logger.ts:106, :207-221). error_message / error_excerpt plus the top-level message are covered, and the hook error property is dropped at its producer instead of being added to the list. No producer writes properties.error today, so the gap is latent rather than live -- but the "single enqueue boundary" claim is true only for the enumerated keys, and any later producer that adds another error-bearing key bypasses it silently. Note also that error_excerpt has no producer anywhere in the tree (only the constant references it), so the description's field inventory does not match this head.
  2. Over-redaction of a benign message (qwen-logger.ts:218-221, producer at :729). logContentRetryFailureEvent sets the top-level message to the fixed, non-sensitive template Content retry failed after N attempts; the blanket top-level redaction replaces it with ***REDACTED***, so every error/content_retry_failure event loses its only human-readable description. Redacting by provenance -- have the two producers that actually carry error text pass the marker -- keeps the fixed exception templates intact.

Two things I checked and found clean, so they are not findings:

  • The as RumExceptionEvent cast at :218 is not masking a missing field: RumResourceEvent does declare message, so the logApiErrorEvent resource path is covered at runtime.
  • The in-place mutation of the caller's object is not reachable: every real call site builds the event through createRumEvent and drops the reference afterwards.

On the standing CHANGES_REQUESTED reviews. 7df466e0, 566d6a74 and 41714d9f are all pinned behind this head, and the code they reviewed -- the AUTHORIZATION_PATTERN / ENV_SECRET_PATTERN / SECRET_FLAG_VALUE family, including the round-3 ledger Critical about the 10-character flag floor -- no longer exists in this revision. The last review taken at this head (round 5) carries Suggestion-level findings only. Dismissing those stale reviews is legitimate; neither note above needs to block merge.

@qwen-code-dev-bot qwen-code-dev-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.

REQUEST_CHANGES

已核对 head ea640b45c2fde6767ebbff99712dac8b48461583(vs merge-base 20ecdaf6b2)。required CI 全绿。方向我是赞成的:把遮罩从生产者侧移到 sink 入口(enqueueLogEventredactEventErrorTextqwen-logger.ts:184-221)比逐个生产者各自记得脱敏可靠,error_message / error_excerpt / exception message 三类字符串字段被整体替换为固定标记,按构造就不会漏。拦的是下面两条,都落在本改动自己身上。

1. 这个改动自己的隐私保证没有用例钉住(线程 R5-2,成立)

qwen-logger.test.ts:782-824 这条改名成 “without forwarding raw error text” 的用例,唯一的值级断言是 expect(callArgs.properties).not.toHaveProperty('error'),其余全是 expect.objectContaining(容忍新增键),而它自己的 fixture 仍然带着 stderr: 'error output'error: 'Command failed'(第 10、11 位实参)。所以同样的原文换到 hook_error / failure_reason 任何别的键下,这条用例照绿、原文照发到 RUM。上面的注释还写着「no raw error property is forwarded to the sink」,即用例在声明一个它并不检查的保证;而 sink 的防护本身是按键名匹配的黑名单,对新键名不设防,这一点恰是需要值级断言的理由。

同文件 :762-779 已经示范了该写的形状:把事件序列化后对原文取 not.toContain。请把这条补上(对 fixture 里的两个原文串断言 not.toContain),否则本 PR 的核心契约只有键名缺席在守。

2. 上一轮的修法顺手让 4 类 hook 里 3 类的失败再无归因(线程 R4-1,成立)

本轮删掉了 properties['error'] = event.error(连同 ERROR_TEXT_PROPERTY_KEYS 里的 'error'),留下的注释理由是「失败已由 success / exit_code 表明」。这个前提只对 hook_type: 'command' 成立:HookExecutionResult.exitCode 是可选的(hooks/types.ts:1340),全仓非测试代码里只有 hookRunner.ts:1439 会写它(http / prompt / function 三类都不写),logHookCallEvent 只有 6 个属性,序列化时 undefined 键被丢弃,于是 HTTP hook 的 URL 校验失败、被中止、超时、传输错误四类在用量遥测里落成逐字节相同的记录,hook_type 之外没有任何区分因。

请给一个不含自由文本的归因位(例如固定的错误类别枚举,或对非命令类失败也落一个 sentinel),并保持整字段遮罩不回退;如果决定本轮就不补,请把这一点写进 PR 正文的取舍段并在 #11198 上记一笔,别让它随合并静默消失。

两条已核为过期或不阻塞的说明

  • 线程 R4-3 说本 head 的隐私策略与 #11198 上记录的方案相反、且 Fixes #11198 会在合并时自动关闭该 issue。策略相反这点确实成立(issue 上作者 2026-09-11T10:07 那条还在说 shape-based masking「让错误保持可调试」,现已改为整字段替换),但 自动关闭的前提在本 head 已不成立:PR 正文用的是 Refs #11198:49:102),并明确写了「this PR must not automatically close that issue before the decision is recorded」。不过该 issue 仍带 status/ready-for-humancategory/securityscope/data-privacy 且核心遥测 owner 已两次把「遮罩策略由维护者定」升级上来 —— 请在合并前先由维护者在 #11198 记下裁决(整字段替换 vs 形状遮罩),别让这个 P1 隐私决策随合并默认生效。
  • 线程 R4-2(redactEventErrorText 就地改写调用方对象、createRumEvent 浅拷贝 propertiesQwenLogger 又是指向公开的包 API)我复核为事实,但正如提出者所说,当前 45 个左右调用点都传新构造的字面量,所以今天不伤生产,只伤测试可观察性与易用性。建议顺手改成先浅拷贝再脱敏、入队那份拷贝,断言指向真正被序列化的对象(同文件 :280 有现成写法)。

qwen-code-dev-bot added a commit to bluefateludi/qwen-code that referenced this pull request Sep 12, 2026
Merge origin/main into codex/issue-11198-redact-rum-errors.

Main landed QwenLM#11649, which replaces the RUM enqueue boundary's `message`,
`error_message`, and `error_excerpt` with a fixed marker outright. This
branch masks only credential-bearing shapes in those same fields plus
`stack` and `properties.error`. Both sides edited the one choke point, so
the calls collided.

Keep both passes rather than picking a side: the blanket one runs first and
owns the three fields it replaces, then the targeted one covers what it
leaves raw. Neither side's coverage is lost, and the stricter base-branch
policy is not weakened. `redactErrorText` is a no-op on the marker, so the
order is not output-load-bearing and only avoids re-scanning text that is
about to be discarded.
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.

6 participants