Skip to content

fix(core): redact credential-bearing error text at the RUM sink - #11424

Open
bluefateludi wants to merge 13 commits into
QwenLM:mainfrom
bluefateludi:codex/issue-11198-redact-rum-errors
Open

bluefateludi wants to merge 13 commits into
QwenLM:mainfrom
bluefateludi:codex/issue-11198-redact-rum-errors

Conversation

@bluefateludi

@bluefateludi bluefateludi commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Every event the usage-statistics channel queues for the RUM endpoint now passes a redaction step at the single choke point all log methods share, before it is queued for upload. The pass masks credential-bearing shapes in error text: URL userinfo (reusing the existing URL redactor), Authorization headers including their bearer prefixes, bare bearer tokens, and secret-looking flag or environment assignments such as --token=…, --api-key=…, or AWS_SECRET_ACCESS_KEY=…. After masking, control characters are stripped (newlines preserved, so the multi-line error block survives) so a control character cannot split a secret key from its separator and defeat a mask; the text is then truncated by the same shared helper the OpenTelemetry span path uses, bound and surrogate-pair guard alike, so both telemetry sinks apply one definition of the truncation bound. Non-error fields (model names, durations, counts, error type codes) are untouched. Two free-text surfaces the choke point does not reach (snapshots and the payload-level base_url) are recorded as known limits in the design doc; neither carries a shell command line today.

The redaction policy is pattern masking rather than fingerprinting, and that choice is recorded in a design doc alongside the code: masked text stays debuggable in the RUM feed, and any shape the mask misses is bounded by the truncation cap. A fingerprinted feed (tool name + exit code + hash) cannot leak by construction but loses the diagnostic value that makes the feed worth having. If maintainers prefer the fingerprint direction, the sink-side pass makes that swap a one-function change.

Why it's needed

Closes #11198. The usage-statistics channel is on by default and uploads tool error text verbatim. A failed shell command's error message starts with the full command line, so a failed git clone https://x-access-token:…@github.com/…, a curl with an Authorization header, or a connection string with embedded database credentials is uploaded with the secret inline — no opt-in step, no redaction anywhere on the path. The same sink ships raw error text through the API-error, invalid-chunk, auth, and ripgrep-fallback events as well. The OpenTelemetry span path already redacts its copy of the same error text; this PR brings the RUM path to parity.

Fixing this per call site is how the gap kept reopening — a recent PR added a second unredacted error field on this sink and review caught it. A pass at the choke point closes the class: future call sites inherit the redaction because every event funnels through it.

One decision from the issue's triage needs maintainer confirmation: the redaction policy. This PR implements masking; the design doc records why and what the alternative would trade away.

Reviewer Test Plan

How to verify

From the repository root, run the telemetry unit tests:

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

Expected: all pass, including the new cases. The sanitize tests pin each masked shape — the exact leaked example from the issue (git clone https://x-access-token:ghs_abc@github.com/o/r), a database DSN, an Authorization header inside a curl -H argument, secret flags with both = and space separators, and secret env assignments — plus two negative cases proving ordinary text (error: connection refused for host db:5432, --verbose=2) passes through unchanged. The logger tests enqueue an event whose error text carries credentials and assert the queued copy is masked, and that a non-error event's properties are untouched.

A broader regression check:

cd packages/core && npx vitest run src/telemetry

Expected: 30 files, 1011 tests, all passing.

To see the behaviour without the tests: build core, import the redaction helper from the built output, and pass it any failing shell command's error block — the command line comes back with credentials replaced by *** and the rest of the diagnostic text intact.

Evidence (Before & After)

Non-UI change; no screenshots. Before: the chain verified in the issue's triage shows raw command lines reaching the RUM payload fields (message, error_message) with no redaction anywhere on the path. After: the same fields, enqueued through the choke point, contain https://***REDACTED***@github.com/o/r and "Authorization: ***" while preserving the surrounding diagnostic text.

Tested on

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

Environment (optional)

Windows 11, Node.js 22, unit tests only. Full repo npm run build and npm run typecheck clean.

Risk & Scope

  • Main risk or tradeoff: pattern masking is not provably complete — a secret in a shape outside the mask set can still pass, bounded by the 1024-character truncation. The alternative (fingerprinting) cannot leak by construction but destroys the feed's diagnostic value; the design doc records this tradeoff for maintainers to confirm.
  • Not validated / out of scope: no live RUM payload was captured; verification is by unit test against the exact enqueue path the payload is built from. The truncation bound was not re-sized. What the model or the user sees in the transcript is untouched — only the RUM payload changes.
  • Breaking changes / migration notes: none. One new exported helper in the telemetry sanitize module; no public API change, no config change, no wire-format change beyond the masked values.

Linked Issues

Closes #11198

中文说明

本 PR 做了什么

usage-statistics 通道发往 RUM 端点的每个事件,现在都会在所有日志方法共用的唯一汇聚点入队前经过一步脱敏。该步骤掩码错误文本中携带凭据的形态:URL userinfo(复用现有 URL 脱敏器)、Authorization 头(含 bearer 前缀)、裸 bearer token,以及形如 --token=…、--api-key=…、AWS_SECRET_ACCESS_KEY=… 的 secret 形态 flag / 环境变量赋值。掩码后按 OpenTelemetry span 路径已在用的长度上限截断,两个遥测出口应用同一份"安全错误文本"定义。非错误字段(模型名、时长、计数、错误类型码)不受影响。

脱敏策略选择的是模式掩码而非指纹化,这一取舍连同理由记录在代码旁的设计文档中:掩码后的文本在 RUM 流里仍可调试,掩码漏掉的形态被截断上限约束。指纹化(工具名 + 退出码 + 哈希)在构造上不可能泄露,但会失去这条数据流存在的诊断价值。若维护者更倾向指纹化方向,sink 侧的这层结构使替换只是改一个函数。

为什么需要

Closes #11198。usage-statistics 通道默认开启,且原样上传工具错误文本。shell 命令失败时的错误消息以完整命令行开头,因此失败的 git clone https://x-access-token:…@github.com/…、带 Authorization 头的 curl、或内嵌数据库凭据的连接串,都会把 secret 原样上传——无需任何 opt-in,路径上没有任何脱敏。同一出口还通过 API 错误、invalid-chunk、auth、ripgrep-fallback 事件上传原始错误文本。OpenTelemetry span 路径对同一份错误文本早已脱敏;本 PR 让 RUM 路径与之对齐。

逐调用点修复正是这个缺口反复重开的原因——最近一个 PR 在同一出口新增了第二个未脱敏的错误字段,被 review 抓住。在汇聚点做一次统一处理闭掉的是整类问题:未来的调用点自动继承脱敏,因为所有事件都经过这里。

issue triage 中有一个决定需要维护者确认:脱敏策略。本 PR 实现的是掩码;设计文档记录了理由以及备选方案的代价。

Reviewer Test Plan

如何验证

在仓库根目录运行遥测单测:

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

预期:全部通过,包括新增用例。sanitize 测试逐形态钉死:issue 中的原始泄露样例(git clone https://x-access-token:ghs_abc@github.com/o/r)、数据库 DSN、curl -H 参数里的 Authorization 头、= 与空格两种分隔的 secret flag、secret 环境变量赋值;另有两个反向用例证明普通文本(error: connection refused for host db:5432、--verbose=2)原样通过。logger 测试入队一个错误文本携带凭据的事件,断言排队副本已掩码,且非错误事件的属性不受影响。

更大范围的回归检查:

cd packages/core && npx vitest run src/telemetry

预期:30 个文件、1011 个测试全部通过。

不看测试直接观察行为:构建 core 后从产物导入脱敏函数,传入任一失败 shell 命令的错误块——命令行返回时凭据已替换为 ***,其余诊断文本保留。

证据(前后对比)

非 UI 改动,无截图。改前:issue triage 核实的链路显示原始命令行直达 RUM 载荷字段(message、error_message),路径上无任何脱敏。改后:同样字段经汇聚点入队后内容为 https://***REDACTED***@github.com/o/r 与 "Authorization: ***",周围诊断文本保留。

测试环境

OS 状态
🍏 macOS ⚠️
🪟 Windows ✅
🐧 Linux ⚠️

环境(可选)

Windows 11,Node.js 22,仅单元测试。全仓 npm run build 与 npm run typecheck 干净。

风险与范围

  • 主要风险或取舍:模式掩码无法证明完备——掩码集合之外的 secret 形态仍可能通过,受 1024 字符截断约束。备选的指纹化在构造上不可能泄露但会毁掉这条数据流的诊断价值;设计文档记录了这一取舍供维护者确认。
  • 未验证 / 范围外:未抓取真实 RUM 报文;验证方式是针对构建载荷的同一入队路径做单测。截断上限未重新设定。模型或用户在会话中看到的内容不变——只有 RUM 载荷变化。
  • 破坏性变更 / 迁移说明:无。遥测 sanitize 模块新增一个导出的辅助函数;无公共 API 变化、无配置变化、除掩码后的值外无线上格式变化。

关联 Issue

Closes #11198

@bluefateludi

Copy link
Copy Markdown
Contributor Author

Review follow-up, pushed in 2b79608c (on top of the maintainer's merge of main):

  • Shared truncation, not a copy. The 1024-code-unit bound and the surrogate-pair guard now live in one exported helper (truncateErrorText in session-tracing.ts) used by both the OTel span path and the RUM sink pass, so the bound can no longer drift and the CJK cut-at-1024 case cannot emit a lone surrogate into the RUM JSON.
  • Control characters stripped before masking, newlines preserved. A C0 char between a secret key and its separator can no longer defeat a mask. I did not reuse stripAnsiAndControl verbatim because it deletes LF/TAB and would flatten the multi-line error block the logger test asserts on — the review itself noted this needed a deliberate decision; the chosen tradeoff (strip C0/C1 except LF/CR) is recorded in the design doc.
  • Doc claims corrected. The design doc and description no longer say truncateSpanError is reused; they state precisely what is shared (the bound + guard) and what deliberately differs (normalisation), and record snapshots and base_url as the two free-text surfaces outside the choke point, plus that stack is defensive coverage for a field nothing populates today.

New tests pin the two behaviours: a control character wedged between --token and = still masks, and CJK-heavy truncation output is byte-identical to the shared helper's. npx vitest run src/telemetry/sanitize.test.ts src/telemetry/qwen-logger/qwen-logger.test.ts src/telemetry/session-tracing.test.ts → 252 passed; eslint and prettier clean. The remaining repo-wide typecheck errors reproduce on clean upstream main at the same base (channel locale, tasksSnapshot, planExecutionMode) and are untouched by this diff.

The masking-vs-fingerprinting policy question remains open for a maintainer, as the triage intended.

bluefateludi added a commit to bluefateludi/qwen-code that referenced this pull request Sep 10, 2026
…y value

Review round 2 on QwenLM#11424, addressing the six Critical findings plus the
two new ones from the latest pass:

- R1-1: bound both key runs at {0,64} — the cubic-backtracking DoS on
  dash-dense input drops from 632.9s to ~26ms at the scheduler gate.
- R1-2: strip ANSI/VT sequences first (newlines preserved) and drop the
  \b anchors so an escape sequence or word char before a key can no
  longer silently disable a mask.
- R1-3: cover properties.error — hook error text carrying the full hook
  command line was uploaded neither masked nor capped.
- R1-4: the Authorization/bearer value position now skips the scheme or
  label word instead of masking it, so token/Basic/ApiKey/Digest values
  and the 'bearer token: <jwt>' prose form are masked.
- R1-18/R1-19: the value alternative is a whitespace-bounded optionally-
  unterminated quoted run or a bare run that never starts with a dash,
  so an empty value cannot eat the next flag's name and a broken quote
  cannot void the mask.
- R2-1: neutralise control chars with a space instead of deleting them —
  deletion fused key and value into one token or glued a word onto the
  key, regressing masks that worked before.
- R2-2: skip a shell line-continuation marker before the value so masks
  bind to the credential on the continuation line.
- R1-5 (minimal): register the secrets the process holds (content
  generator API key, MCP server header values) and mask them by exact
  value — closed by construction; the design doc drops the class-closure
  claim and records the pattern-enumeration residual gap in Non-goals.
@bluefateludi

Copy link
Copy Markdown
Contributor Author

Thanks for the two review rounds — all seven open Criticals plus the two new ones from the latest pass are fixed in 3c9d095, along with the surrogate-test Suggestion (R2-3). Plan and rationale per finding:

R1-1 (backtracking DoS) — Both key runs in every pattern are now bounded at {0,64}. Measured on the new pin: '--token'.repeat(1000) completes in ~25ms (was ~11.5s; 28k chars was 632.9s). The trade-off (a flag whose key run exceeds 64 chars before the secret word is no longer masked) is accepted and documented in the pattern comment.

R1-2 (ANSI + \b anchors) — Both halves: stripVTControlCharacters from node:util runs first (it removes ANSI/VT sequences but preserves newlines, so the multi-line error block survives — unlike stripAnsiAndControl), and all \b anchors are gone. The key classes themselves delimit the matches, so prefix_mAWS_SECRET_ACCESS_KEY=x and the SGR-wrapped forms now mask.

R1-3 (properties.error) — Added to the key list exactly as suggested (['error', 'error_message', 'error_excerpt']). The gate in logHookCallEvent is untouched — the key is only masked when present, never written. Acceptance case added: a hook_call event whose properties.error carries the reviewer's exact curl -H "Authorization: Bearer abc123" https://user:tok@hooks.internal/run string, asserting the queued copy is masked; the existing error: 'Command failed' / hook_name: 'cleanup.bat' pins stay byte-identical.

R1-4 (scheme-word swallowing) — The Authorization value position now skips an optional scheme word (bearer|basic|digest|token|negotiate|ntlm|apikey|oauth|sso) and then masks the credential; the bearer pattern keeps an optional token: label in the replacement ($1$2***), so invalid bearer token: eyJhbGciSECRET999 → invalid bearer token: *** and Authorization: Bearer\nX-Api-Key: sk-live-… masks both. All four committed Authorization/bearer pins are byte-identical.

R1-18 (leading-dash value) / R1-19 (unterminated quote) — Solved together by one shared value alternative: (?:[\\^][ \t]\r?\n[ \t])?("[^\s"]"?|'[^\s']'?|(?!-)[^\s"']+). The bare run can never start with a dash (so --password= --token ghp_REALTOKEN→--password= --token ***, --password-stdin --username admin` untouched); the quoted run tolerates a missing terminator but is whitespace-bounded, so an unclosed quote masks the value without swallowing the rest of the line's diagnostics (the reviewer's measured 149-char swallow doesn't happen). All six acceptance shapes plus the env-pattern case are pinned.

R2-1 (delete → neutralise) — Control chars are now replaced with a space, not deleted, per the measured fix: --token<TAB>value masks instead of fusing, and word-char-glued keys mask. The old deletion pin was updated in the same commit as the fix constraint required ('git push --token =***…' — note the space-neutralised separator is consumed by the mask's \s* so the visible output is --token ***).

R2-2 (line continuation) — The value-group prefix (?:[\\^][ \t]\r?\n[ \t])? from the fix-arm measurement: all four continuation shapes (--token=`, API_KEY=\, Authorization: Bearer \, bearer \) mask the credential on the continuation line; the \ + newline text-level normaliser was deliberately not taken, for the false-positive reasons measured in the finding.

R1-5 (class-level) — minimal value-masking, as discussed — Process-held secrets (the content-generator API key and MCP server header values) are now masked by exact value before the pattern passes: enqueueLogEvent re-registers them from Config each call (so mid-session refreshes are covered), values below 8 chars are skipped so placeholders can't censor text, and matching is split/join so it's closed by construction — a held credential reaches the payload in no shape at all. The design doc: (1) drops every "closing the class" claim (the choke point closes the entrance problem, not the shape problem), (2) adds the residual-gap Non-goal naming the enumerable families (quoted/lowercase env keys, URL query params, JSON/YAML bodies, tool-specific syntax, output-borne secrets) and pointing at the triage's fingerprinting option and first-line-only middle ground as the follow-up decision for maintainers, and (3) adds properties.error to the covered-field list. I have not grown SECRET_KEY_PATTERN's vocabulary or added more spellings — the policy question (masking vs fingerprinting vs first-line-only) is left open for maintainers as the finding requests.

R2-3 (surrogate test) — Rewritten to the astral form ('a'.repeat(1023) + '🚀'.repeat(500)) with the lone-high-surrogate regex property; deleting the back-off in truncateErrorText now turns it red.

Also fixed while porting: the space-separated flag mask is now restricted to --long-flag keys (prose like "the auth-token is expired" keeps its next word; --auth=abc inline still masks), and the env pattern stays uppercase-only by design with the mixed-case gap recorded in the doc.

Verification: 261/261 across sanitize.test.ts, qwen-logger.test.ts, session-tracing.test.ts; eslint and tsc clean on the touched files; all previously-committed pins byte-identical.

@bluefateludi
bluefateludi force-pushed the codex/issue-11198-redact-rum-errors branch from 3c9d095 to a63390a Compare September 10, 2026 06:20
bluefateludi added a commit to bluefateludi/qwen-code that referenced this pull request Sep 10, 2026
…y value

Review round 2 on QwenLM#11424, addressing the six Critical findings plus the
two new ones from the latest pass:

- R1-1: bound both key runs at {0,64} — the cubic-backtracking DoS on
  dash-dense input drops from 632.9s to ~26ms at the scheduler gate.
- R1-2: strip ANSI/VT sequences first (newlines preserved) and drop the
  \b anchors so an escape sequence or word char before a key can no
  longer silently disable a mask.
- R1-3: cover properties.error — hook error text carrying the full hook
  command line was uploaded neither masked nor capped.
- R1-4: the Authorization/bearer value position now skips the scheme or
  label word instead of masking it, so token/Basic/ApiKey/Digest values
  and the 'bearer token: <jwt>' prose form are masked.
- R1-18/R1-19: the value alternative is a whitespace-bounded optionally-
  unterminated quoted run or a bare run that never starts with a dash,
  so an empty value cannot eat the next flag's name and a broken quote
  cannot void the mask.
- R2-1: neutralise control chars with a space instead of deleting them —
  deletion fused key and value into one token or glued a word onto the
  key, regressing masks that worked before.
- R2-2: skip a shell line-continuation marker before the value so masks
  bind to the credential on the continuation line.
- R1-5 (minimal): register the secrets the process holds (content
  generator API key, MCP server header values) and mask them by exact
  value — closed by construction; the design doc drops the class-closure
  claim and records the pattern-enumeration residual gap in Non-goals.
@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)为单个提交。

@bluefateludi

Copy link
Copy Markdown
Contributor Author

The Test (ubuntu-latest, Node 22.x) failure on a63390a is not from this diff — heads-up so nobody spends time on it:

A re-run of the failed job, or any future rebase, should go green against current main. Per the no-rebase note above I'm leaving the branch as-is; happy to rebase if a maintainer prefers.

中文说明

a63390a 上 Test (ubuntu-latest, Node 22.x) 的失败与本 diff 无关——说明一下,免得大家花时间排查:

重跑失败的 job,或将来任何 rebase,对当前 main 都应当变绿。遵照上方"请勿 rebase"的提醒,分支保持不动;如 maintainer 希望rebase,我可以随时执行。

Tool error messages carry raw shell command lines, which reached the
usage-statistics endpoint unredacted through error_message and message
fields. Apply a sink-side pass in enqueueLogEvent, the choke point all
log methods share, using a new redactErrorText helper that masks URL
userinfo, Authorization headers, bearer tokens, and secret-looking
flag/env assignments, then truncates to the same bound the OTel span
path uses.

Closes QwenLM#11198
…error redaction

Address review findings on the first round: the truncation cap and its
surrogate-pair guard are now the exported truncateErrorText from
session-tracing, used by both the OTel span path and the RUM sink pass
instead of a copied constant; and control characters are stripped
before masking (newlines preserved, so the multi-line error block
survives) so a C0 char cannot split a secret key from its separator
and defeat a mask. The design doc now states the parity claim
accurately and records the two free-text surfaces the choke point does
not reach.
…y value

Review round 2 on QwenLM#11424, addressing the six Critical findings plus the
two new ones from the latest pass:

- R1-1: bound both key runs at {0,64} — the cubic-backtracking DoS on
  dash-dense input drops from 632.9s to ~26ms at the scheduler gate.
- R1-2: strip ANSI/VT sequences first (newlines preserved) and drop the
  \b anchors so an escape sequence or word char before a key can no
  longer silently disable a mask.
- R1-3: cover properties.error — hook error text carrying the full hook
  command line was uploaded neither masked nor capped.
- R1-4: the Authorization/bearer value position now skips the scheme or
  label word instead of masking it, so token/Basic/ApiKey/Digest values
  and the 'bearer token: <jwt>' prose form are masked.
- R1-18/R1-19: the value alternative is a whitespace-bounded optionally-
  unterminated quoted run or a bare run that never starts with a dash,
  so an empty value cannot eat the next flag's name and a broken quote
  cannot void the mask.
- R2-1: neutralise control chars with a space instead of deleting them —
  deletion fused key and value into one token or glued a word onto the
  key, regressing masks that worked before.
- R2-2: skip a shell line-continuation marker before the value so masks
  bind to the credential on the continuation line.
- R1-5 (minimal): register the secrets the process holds (content
  generator API key, MCP server header values) and mask them by exact
  value — closed by construction; the design doc drops the class-closure
  claim and records the pattern-enumeration residual gap in Non-goals.
@bluefateludi
bluefateludi force-pushed the codex/issue-11198-redact-rum-errors branch from a63390a to 2a2ec70 Compare September 10, 2026 10:04

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

Reviewed at 2a2ec70. The regex hardening from the earlier rounds is real and I confirmed the bounded key runs, the value guard, the control-character stripping and the continuation prefix. One new Critical, and one policy question that still needs settling before this can land.

[Critical] registerProcessSecrets dereferences a getter that is undefined before auth initialization, and the resulting TypeError silently drops the event. packages/core/src/telemetry/qwen-logger/qwen-logger.ts:145-148:

function registerProcessSecrets(config: Config | undefined): void {
  const secrets: Array<string | undefined> = [
    config?.getContentGeneratorConfig().apiKey,
  ];

The optional chain guards config, not the getter's result. getContentGeneratorConfig is (packages/core/src/config/config.ts:4942-4947):

getContentGeneratorConfig(): ContentGeneratorConfig {
  return (
    getRuntimeContentGenerator()?.contentGeneratorConfig ??
    this.contentGeneratorConfig
  );
}

The backing field is declared private contentGeneratorConfig!: ContentGeneratorConfig (config.ts:2225) and assigned only in refreshAuth, while getRuntimeContentGenerator() reads an AsyncLocalStorage store that is empty at startup. The declared return type is non-nullable but the codebase does not believe it — every other call site guards with ?. (config.ts:4198, 4963, 8623). The call is inside enqueueLogEvent's try block (qwen-logger.ts:231) ahead of this.events.push(event) at :241, and the catch at :248-250 only debug-logs, so the throw costs the whole event. This is reachable on the default startup path, where session-start telemetry is emitted before auth is validated. It is not a leak — it is silent loss of session_start. CI does not catch it because the test mock always returns an object.

[Suggestion] Registering every MCP header value as a secret over-redacts. A server configured with Accept: application/json, text/event-stream makes the exact-value pass replace application/json throughout all error text, since the length floor is only 8 characters. That degrades diagnosability for no security gain; restrict this to auth-ish header names.

[Suggestion] Simplicity. knownSecretValues is module-global mutable state repopulated on every event, cleared only by a test-only export that ships in production. The leak-proof half of this feature is achievable by passing the secrets into redactErrorText as a parameter with no module state at all. As it stands the PR adds a second mechanism beyond what was asked.

[Nit] The idempotence claim does not hold for truncation, since the truncating helper appends a marker and a second pass re-truncates. And RumExceptionEvent.caused_by is a free-text surface that is neither in the key list nor recorded as a known limitation, while stack was covered defensively.

Previously raised, still standing: the CPU half of the round-1 performance concern — all passes run before truncation, and ToolCallEvent.error is unbounded at its producer. And the spelling-enumeration gap: ?key= / ?access_token= in URLs and JSON-shaped "api_key":"…" remain unmasked because every flag pattern requires a leading dash. Several other suggestions from those rounds are also unaddressed.

Decision needed from us, not the author. This PR defers the masking-versus-fingerprinting policy question, and it should not land undecided: either we accept that spelling-based patterns are best-effort and say so in the code, or we commit to the exact-value approach and drop the pattern layer. I would rather we choose than merge both.

Verification gaps: no tests run, so every finding here is read-verified only. The regex behaviour is static reasoning — I did not empirically confirm the ?key= and JSON misses, nor measure backtracking cost. I could not enumerate every producer of RumExceptionEvent.message and stack outside this file, which is the main reason I am not treating the redaction surface as closed. I verified the pre-auth ordering on the non-interactive path only.

@bluefateludi

Copy link
Copy Markdown
Contributor Author

Thanks — the Critical is real and is fixed in 16c4f99 (pushed).

[Critical] pre-auth getContentGeneratorConfig() dereference. Fixed exactly as diagnosed: the chain now guards the getter's result (config?.getContentGeneratorConfig()?.apiKey), matching the other pre-auth call sites (config.ts:4198, 4963, 8623), with a comment explaining why the non-nullable declared return type cannot be trusted before refreshAuth runs. Two new tests pin the behavior:

  • a pre-auth config whose getter returns undefined — unlike the default mock you called out — the event still enqueues and no error is logged; before the fix the TypeError silently dropped it,
  • an end-to-end exact-value masking test through enqueueLogEvent: a sk-… api key embedded in message text comes out as ***, which previously had no direct coverage.

qwen-logger.test.ts is 49/49 locally; build passes.

[Nit] truncation idempotence. You're right that a second pass would re-truncate (…[truncated] is appended unconditionally in truncateErrorText). In practice each event is redacted exactly once at enqueue time, and the marker can only be re-emitted if the already-truncated text is fed through again, which no current caller does. We'll leave it unless you want an early-return for strings already ending in the marker — it's a two-line guard.

[Nit] caused_by. Noted as a known limitation; we can add it to the key list in redactErrorTextFields if you'd like — it's one line. Saying nothing for now since it's free-text structured JSON and we'd rather hear whether you want it masked as a whole or key-by-key.

[Suggestion] MCP header over-redaction. Kept register-all-headers this round by design: header values are secrets-by-convention (they ride on every request to that server), and we'd rather over-mask than under-mask at a telemetry sink. But the Accept: application/json, text/event-stream example is fair — if you prefer, we'll restrict registration to auth-ish names (authorization, x-api-key, *token*, *secret*, cookie) as a follow-up commit here; say the word.

[Suggestion] module-global knownSecretValues. We weighed passing the secrets as a parameter into redactErrorText, but the redaction runs inside enqueueLogEvent for every event with config in hand; a parameter would either thread through every sanitize call site or capture the set in a closure that goes stale on credential refresh. The set is process-scoped by construction (the secrets are process-held), re-registered per event so refreshes are covered, and the test-only clear export mirrors the file's existing TEST_ONLY pattern. Happy to do the parameter form if you still prefer it — it's mechanical.

Still standing (CPU cost; ?key= / JSON "api_key" gaps). Acknowledged and deliberately unchanged this round: both interact with the masking-vs-fingerprinting policy you've asked to settle before merge. Extending the pattern enumeration now and then possibly dropping the pattern layer wholesale would churn the regexes twice. Once the policy lands we'll do a single follow-up pass covering whichever survives.

Masking vs fingerprinting. Agreed it should be decided before this lands. Our recommendation: keep both layers and state in the code that the spelling-based patterns are best-effort — they cover disjoint failure modes (exact values catch our own credentials in any spelling; patterns catch credential-shaped text from external error output we hold no copy of). The doc comment on redactErrorText already frames the pattern layer as best-effort with the residual gap recorded. We'll adjust to whichever side you pick, in this PR.

@wenshao

wenshao commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor

Qwen Code review request accepted. Review is running in workflow run. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes.

@bluefateludi

Copy link
Copy Markdown
Contributor Author

@qwen-code /review

wenshao and others added 2 commits September 11, 2026 09:09
Pre-bound the mask passes at a working multiple of the shared cap so the
output limit bounds the CPU too (dash-dense hostile error text was
quadratic in the URL pass), neutralise VT runs instead of deleting them
so a colour sequence between a flag and its value cannot fuse the token,
unescape two-character JSON escapes before the masks so stringified
producer output is masked in one text shape, prefer fully closed quoted
runs so a quoted secret containing spaces is masked whole, let the
Authorization scheme word and the bearer label fold across one newline,
and mask registered secrets on the raw input so an incomplete escape
sequence cannot consume the secret's first character.

Register the credential half of scheme-prefixed MCP header values and
fold second-session configs into the add-only secret registry, and
narrow the design doc's exact-value claims to the registered set with
the remaining carries recorded as an open residual gap.
@bluefateludi

Copy link
Copy Markdown
Contributor Author

Round 3 addressed — 4f4ac86 merged main, ec72635 closes the standing Criticals. Verified each mechanism against HEAD first (the round-3 review anchored at 2a2ec70, one commit before 16c4f99, so R3-10's pre-auth getter finding was already fixed there and is not re-done).

Fixed in ec72635

  • R1-1 (CPU bound): the mask passes now run on input pre-bounded at SPAN_TEXT_MAX_CHARS * 64 (the shared constant, now exported; no second constant introduced). Measured before/after on '--token'.repeat(200_000): 4.0s → the whole 1.4MB input processes in ~1s; the new test also asserts the output equals the capped variant and reddens (353s without the pre-bound in the mutation run). The truncation-aware cut keeps straddling credentials masked because the cut happens before the passes.
  • R1-2 (VT deletion fusion): VT runs that are the only separator between two non-whitespace chars become a space; all other runs are deleted, so 'git push --token␛[32mghp_REALTOKEN' now masks ('git push --token ***') while ANSI-wrapped diagnostics keep their spacing (the round-2 pin needed a one-space widening: 'npm ERR! https://***REDACTED***@…').
  • R3-19 (JSON-escaped values): two-character JSON escapes (\", \n, \t, \r, \) are unescaped before the masks, so the safeJsonStringify producer shape masks in one text shape. '{"error":"curl --token=\"ghs_SECRET\" failed"}' no longer ships the token next to a ***.
  • R3-20 (quoted value with spaces): VALUE_ALT prefers a fully closed quoted run; '--password "correct horse battery"' → '--password ***'. Unclosed quotes keep the whitespace-bounded behaviour (R1-19 pins still green).
  • R3-23 / R3-24 (folded newline): both the Authorization scheme word and the bare bearer label tolerate one folded newline; the Authorization fold keeps the negative Name:-header lookahead (colon-only, per the review's caveat) so 'Authorization: Bearer\nX-Api-Key: …' still masks both lines separately.
  • R3-22 (exact-value mask vs normalisation): registered secrets are masked on the raw input before normalisation, so an incomplete CSI sequence can no longer consume the secret's first character and defeat both halves. The split-inside-secret entrance (control char inside the registered value) remains — noted below under deferred.
  • R3-27 (scheme-prefixed header envelope): the credential half of "Authorization": "Bearer <token>" is registered alongside the envelope (short remainders still refused by the floor).
  • R3-26 (singleton-bound registry): getInstance now folds the caller's Config into the add-only registry on every subsequent call, so a second session's credentials are registered. Composed with the envelope fix as the review recommended.

Every fix has a red/green-verified test: each mutation (pre-bound removed, VT replace removed, JSON unescape removed, closed-quote branch removed, either fold removed, raw-input mask removed, envelope split removed, else-arm registration removed) was applied and confirmed to redden its test before being restored. sanitize.test.ts 36/36, qwen-logger.test.ts 51/51, redaction.test.ts 13/13, lint/typecheck/build green.

Doc corrections (the consolidated pass R3-22 asked for): the "closed by construction / no shape at all" sentences are narrowed to the registered set; a named residual-gap list now records what the registry does NOT read (Qwen OAuth dynamic tokens, customHeaders, MCP env, oauth.clientSecret, transport-time Bearer), the normalisation section documents the new VT/JSON handling, the Cf/combining-mark class is recorded as a residual gap with the repo's shipped technique named as the follow-up shape, the pre-bound is documented, and the upload:v1: table row is annotated as inherited from redactUrlCredentials (with the Testing claim corrected).

Not adopted this round — needs a maintainer decision, recorded in the doc

  • R1-5 (class-level: positive enumeration cannot close): option (c) is what landed — claims narrowed, enumerable families listed as residual gaps, policy decision left open. Options (a) fingerprinting and (b) first-line-only masking change what the RUM feed is; that is the product decision the review itself says belongs to the maintainers, not the author. The Authorization scheme-word allowlist keeps its bounded set (the unbounded-scheme-skip variant was measured by the reviewer to mask 7/10 proprietary shapes but changes ordinary-header behaviour; happy to switch on a ruling).
  • R3-22 entrance 2 (control char inside a registered value splitting it): separator-tolerant matching inside credential spans is the workspace-remember-errors.ts technique — real work, deferred with the Cf class it belongs to.

Deferred as Suggestion-level (per the ~5-round convergence posture; each is a behaviour change with a measured trade-off, none certifies falsely): R3-2 (raw-shell vs JSON modelling of VALUE_ALT), R3-3/R3-16 (header-name filter for registry admission), R3-5 (Qwen OAuth cached-token registration), R3-4 (MCP env/oauth.clientSecret registration with key-name predicate), R3-14 (customHeaders registration), R3-15 (placeholder handling), R3-8 (truncation sentinel idempotence), R3-21 ((?!-) scope), R3-9 (URL pass ordering), R3-12 (blanket-pass discriminating fixture), R3-17 (base_url enforcement point), R1-7/R1-9/R1-10/R1-12/R1-16/R1-23 (previously reported and re-confirmed by the bot as already-known), D2-1/D2-2 (deferred in round 2 already).

One process note: the round-3 bot review anchored at 2a2ec70 while the PR head was already 16c4f99 — worth a re-run at ec72635 so the next round doesn't re-derive fixed findings.

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

Agent-assisted review at ec7263522b223a6e3f3f6377076ff1782584674e — one confirmed regression; residual privacy-policy decision remains. Comment only; no approval implied.

Critical — JSON-escaped registered credentials bypass the relocated exact-value pass

packages/core/src/telemetry/sanitize.ts:222-242 now performs its only maskKnownSecretValues pass BEFORE JSON unescaping. A registered MCP header credential containing a quote/backslash therefore does not occur literally in a JSON-stringified error; normalization subsequently reconstructs it, after the only value-based protection has finished. The spelling masks do not catch an ordinary prose echo. This is not the documented exclusion for unregistered credentials or unknown key spellings.

Verified synthetic example: register SYNTHETIC"SECRET_12345, then redact JSON.stringify([{text: 'upstream echoed SYNTHETIC"SECRET_12345'}]). The returned text still contains the entire registered credential. Real path: header values are registered at qwen-logger/qwen-logger.ts:155-168; non-image MCP failures JSON-stringify response parts at tools/mcp-tool.ts:870-883; telemetry/types.ts:225 → loggers.ts:173-192,302-319 → qwen-logger/qwen-logger.ts:606-628 carries that string to properties.error_message and the RUM queue. Header values are forwarded without a restriction excluding quotes (tools/mcp-client.ts:2516-2518). Usage statistics default on (config/config.ts:2813). Keep the raw-value pass needed for R3-22, but also protect the normalized representation before upload; add a registered-value regression exercising this actual JSON producer shape.

Historical Critical reassessment

  • Fixed: our previous pre-auth crash / R3-1 (getContentGeneratorConfig()?.apiKey, logger:153); hook properties.error omission / R1-3 (:133); secondary Config registration / R3-26 (:233-244); scheme-prefixed MCP credential registration / R3-27 (:164-165).
  • Earlier flag/value, unclosed-quote, auth-label, control-fusion, ANSI, continuation, escaped-quote, quoted-space and folded-header witnesses (R1-18/19/4/2, R2-1/2, R3-19/20/23/24) are addressed by the current patterns/normalization and passed isolated representative probes. R3-22's raw incomplete-CSI witness is fixed, but its reordered pass introduces the regression above.
  • R1-1's unbounded-input work is bounded at 64 KiB before masking (:222), with bounded key runs. This is not a claim of linear regex runtime: synthetic dash-dense inputs took approximately 156 ms (28k chars) and 636 ms (1.4M input, prebounded).
  • R1-5 and R3-18 are not behavioral closures: unregistered JSON token bodies, curl -u, query tokens and the invisible-character Authorization witness still leak in helper probes. The design explicitly records these limitations (lines 26-36,71-82,137-143), satisfying the historical documentation/narrowed-guarantee alternative, not providing a fail-closed privacy boundary. Maintainers still own acceptance of that policy; I am not multiplying documented misses into new regex blockers. OAuth/custom-header/env/clientSecret registry gaps, payload base_url, and the unchanged narrower OTel redactor remain limitations, not certified safe surfaces.

Coverage / validation

Read all six changed files/changed functions and tests, the design, actual shell/MCP → ToolCallEvent → normalization → RUM queue/payload consumers, registration and truncation paths, and paginated review history. Non-test source diff is below the 500-line gate even before excluding comments; external-author fix, not a large refactor. No daemon route changes. Existing noncritical deferrals remain deferred; no new Suggestions.

Executed only an isolated scratch probe of the already-read pure helpers under Linux / Node v22.23.1 (stripTypeScriptTypes + VM), using synthetic values. No application imports, dependency installation, repository tests/build, provider requests, or RUM uploads. No end-to-end telemetry claim.

@bluefateludi

Copy link
Copy Markdown
Contributor Author

Thanks @doudouOUC — confirmed and fixed in d6165f5.

The only maskKnownSecretValues pass ran before JSON unescaping, so a registered credential containing a quote/backslash did not occur literally in a JSON-stringified error and the unescape reconstructed it after value-based protection had finished. The fix keeps the raw pass (still needed for the incomplete-CSI case, R3-22) and runs maskKnownSecretValues a second time on the normalised text, before the URL/pattern passes.

Regression added in sanitize.test.ts exercising the actual producer shape: register SYNTHETIC"SECRET_12345, redact JSON.stringify([{text: 'upstream echoed SYNTHETIC"SECRET_12345'}]), and assert neither the reconstructed value, the escaped form, nor the suffix survives. Mutation-verified: removing the post-normalisation pass turns that test red; the other 36 sanitize tests and the 51 qwen-logger tests stay green.

On the residual policy items (R1-5/R3-18): understood and agreed they remain documented limitations of the narrowed guarantee, as the design records — no new regex blockers from our side either.

@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

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

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge resolution — PR #11424

Root cause — duplicate fix, not adjacent edits. main's 85509c749d (#11649) landed after this PR's merge base 8eb33a860a and closed the same gap (#11198) at the same choke point, QwenLogger.enqueueLogEvent: both sides inserted a redaction call at the top of the same try. qwen-logger.ts was the only conflicted file.

Semantic — same fields, incompatible policies. main is blanket (message, error_message, error_excerpt → '***REDACTED***' always; it also dropped hook properties['error'] and its getTelemetryLogPromptsEnabled() gate). This PR is targeted (redactErrorText() masks only credential-shaped text in those fields plus stack and error, fed by registerProcessSecrets()). Kept both, blanket first:

this.redactEventErrorText(event);      // blanket (#11649)
registerProcessSecrets(this.config);
redactErrorTextFields(event);          // targeted (#11424)

No coverage lost; the base branch's stricter policy is not weakened.

Load-bearing.

  • Don't reorder or drop either call. Output matches today only because redactErrorText('***REDACTED***') is identity — that marker is REDACTED_URL_CREDENTIAL and holds no ://, =, :, -, bearer, or authorization, so every pass misses. Change the marker or those patterns and order becomes correctness-relevant.
  • The targeted pass is the only protection for stack and properties.error; deleting it as "redundant with main" silently re-opens both.
  • registerProcessSecrets must precede redactErrorTextFields (fills the exact-value registry), and its pre-auth guard must survive — a throw there is swallowed by the catch and drops the event.

Could not verify. No build/tests run. qwen-logger.test.ts auto-merged (non-conflicted, untouched) and now holds both suites, so two PR tests pinning the superseded targeted behaviour will fail: :289 (expects partial text in message/error_message) and :390 (expects 'Request failed: Authorization *** rejected'). Both fields are now '***REDACTED***'. A maintainer must delete them or re-point them at stack/properties.error. By inspection the rest holds: main's :1296/:1255 and PR's :316/:340/:367/:412/:447 (:412/:447 assert only absence of the secret, which blanket satisfies).

中文说明

根因:重复修复,非相邻改动。 main 的 85509c749d(#11649)在合并基点 8eb33a860a 之后落地,在同一入口 QwenLogger.enqueueLogEvent 修同一问题(#11198),两边都在同一 try 开头插入脱敏调用。唯一冲突文件是 qwen-logger.ts。

语义冲突:同一批字段,策略不兼容。 main 全量覆盖(message/error_message/error_excerpt 一律换成 '***REDACTED***',并删掉 hook 的 properties['error']);本 PR 定向脱敏(redactErrorText() 只屏蔽形似凭据的文本,另覆盖 stack 与 error,由 registerProcessSecrets() 供名单)。两者都保留,全量在前(见上方代码块),覆盖面不减,也未削弱基线更严格的策略。

关键约束: 不要调换顺序或删任一调用——当前输出一致仅因 redactErrorText('***REDACTED***') 恒等(该标记即 REDACTED_URL_CREDENTIAL),标记或正则一变,顺序即影响正确性。stack 与 properties.error 仅靠定向脱敏保护,当作重复删掉会静默重开口子。registerProcessSecrets 须先于 redactErrorTextFields,其 pre-auth 保护须保留——抛异常会被 catch 吞掉并丢事件。

未能验证: 未跑构建与测试。qwen-logger.test.ts 自动合并(未冲突、未改动),现含两套用例,故两个断言旧定向行为的 PR 用例会失败::289、:390,相关字段现均为 '***REDACTED***'。需维护者删除或改断言 stack/properties.error。其余用例经静态核对一致。

@bluefateludi

Copy link
Copy Markdown
Contributor Author

The resolve run's one open item — the two superseded tests — is closed in a8e9e52 (test-only, on top of 44cedda). Exactly the two the resolution note predicted went red at the resolved head (should redact credentials in error text before queueing, should mask a process-held api key by exact value, 2 failed | 86 passed), and both are re-pointed rather than deleted, so the surviving coverage moves to where the merged policy still distinguishes the two passes:

  • The first test now asserts message and error_message are the blanket ***REDACTED*** constant (that is fix(core): redact error text in usage-statistics telemetry sink #11649's contract after the merge), and adds a properties.error fixture asserting the targeted pass still masks the credential shape there — properties.error is not a blanket key, so this is the observable seam between the two policies. The non-error properties stay untouched as before.
  • The second test keeps its exact-value registry setup but observes the masking on stack (typed via RumExceptionEvent), the other field only the targeted pass protects; message is asserted blanket-replaced. Losing registerProcessSecrets + redactErrorTextFields now turns this red on the raw key appearing in stack.

Mutations re-verified at the new head: removing the targeted pair (registerProcessSecrets + redactErrorTextFields) fails exactly these two tests (plus the pre-existing properties.error one — 3 failed | 48 passed); removing only the blanket pass fails these two plus the two pre-existing blanket tests (4 failed). Both restored: 88/88 across qwen-logger + sanitize, typecheck clean for the touched package (the repo-root quickjs/code-mode errors are missing optional deps in this sandbox, unrelated to the diff), full npm run build green once those deps are present.

The merge-resolution ordering itself (blanket first, registerProcessSecrets before redactErrorTextFields, both passes kept) is untouched — this commit only re-points the assertions the note flagged for a maintainer or the author to handle.

wenshao and others added 2 commits September 13, 2026 10:27
The merge of main re-introduced loggers.test.ts configs that stub only
the telemetry-relevant getters; registerProcessSecrets called
getContentGeneratorConfig unguarded and threw, failing 29 tests and —
because the throw happens inside enqueueLogEvent's catch-all path at
getInstance time — masking it would also drop the event. Call both
getters with optional chaining so partial Config objects register no
generator/MCP secrets instead of crashing.
@bluefateludi

Copy link
Copy Markdown
Contributor Author

The ubuntu Test lane failures at 5d540d91b2 (and at our previous a8e9e52827 push) trace to one line: registerProcessSecrets called config?.getContentGeneratorConfig() unguarded, but loggers.test.ts constructs Config stand-ins that stub only the telemetry-relevant getters — the call throws, and 29 tests fail. The merge of main re-introduced those partial stand-ins alongside this branch's secret registration, so both heads went red in that suite.

Fixed in 8a49a6b: both getContentGeneratorConfig and getMcpServers are now invoked with optional chaining (config?.getContentGeneratorConfig?.()?.apiKey), matching the guard style the function already documents for the pre-auth window. A partial stand-in registers no generator/MCP secrets instead of crashing — and since the throw sat on the getInstance path inside enqueueLogEvent's catch-all, a crash there would also have silently dropped the event, which the guard now prevents.

Verified locally: loggers.test.ts 87/87 (was 58 passed | 29 failed), qwen-logger.test.ts 51/51, sanitize.test.ts 37/37, tsc --noEmit clean.

@bluefateludi

Copy link
Copy Markdown
Contributor Author

The two ubuntu Test failures at bd93e383 are not from this PR's diff — they came in with the main merge.

Both failing tests (ink useBoxMetrics loop guard > still measures on a later resize… and > gives every instance its own measurement budget, in packages/cli/src/ui/use-box-metrics-loop-guard.test.tsx) were added to main in e6e0759 ("fix(cli): break Ink useBoxMetrics commit-phase setState loop (React #185)", #11565), which the branch picked up through the main merge in bd93e38. One hits "Maximum update depth exceeded" inside ink's use-box-metrics emit loop, the other renders an empty frame — both are ink/React rendering behavior this RUM-redaction diff never touches (the PR's changes are confined to core telemetry and its tests; the merged diff between 8a49a6b and bd93e38 shows the failing file arriving wholesale from main).

No branch-side change can legitimately alter that outcome, so I'm leaving the code untouched here — this looks like a main-side flake or an ink patch interaction that will need to be addressed upstream of this PR. Happy to re-run or rebase again if main lands a fix.

@bluefateludi

Copy link
Copy Markdown
Contributor Author

Follow-up with hard evidence on the two ubuntu Test failures at bd93e38: they are a timing flake in the tests added by #11565, confirmed independently of this PR.

I reproduced the same two failures locally with the ink patch correctly applied (the CI run log shows patch-package … ink@7.0.3 ✔ too): repeated runs of the file flake green/red on a fast host, and each test passes in isolation. The guard added in patches/ink+7.0.3.patch refills its 32-tick budget only after a 16 ms wall-clock window; the "later resize" test trips the guard and then flushes the resize synchronously inside act() — on a fast host that lands inside the window, the budget is still exhausted, and the frame stays empty.

Same-base cross-check: #11842 / #11844 / #11845 share main merge-base e04f2ec with runs interleaved with ours and passed the identical lane.

Filed #11850 upstream with the mechanism and suggested deterministic refill hooks. Nothing branch-side to change here.

This branch has not been deployed

No deployments
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.

Usage-statistics telemetry uploads raw tool-error text (including shell command lines) to RUM without redaction

5 participants