feat(changes): secret redaction layer for op previews

The changes-tab op previews replay read/write/edit payloads into the DOM
(diff rows, read rows, markdown reading mode, error text) — when the model
reads a credentials file, that plaintext must not land on screen or in
inspectable DOM. New display-only redaction layer, on by default with a
one-click pane toggle (persisted in localStorage):

- path layer: credential-shaped filenames (.env / *secret* / *credential* /
  *token* / *api-key* / private keys...) mask whole files line-wise
- content layer: ordinary files mask only secret-shaped spans — secret
  field assignments keep the field name (api_key: [REDACTED]), plus bare
  token prefixes (sk- / AKIA / ghp_ / xox / ya29.), Bearer headers, and
  PEM private-key block headers
- redacted payloads flow through the same viewOp shape into diff rows,
  read rows, markdown reading mode, and error text

Known edge (documented in README): an unquoted mermaid node label
containing a redacted secret breaks the diagram and falls back to source;
workaround is quoting the label. Session data is untouched — this is
display-only and not a boundary against same-context malicious plugins.

Locales: zh/en dictionaries + all 19 shipped third-language overrides
(key-set-equal gate green). 9 new unit tests; typecheck clean; targeted
suites 40/40 green. The fs-operations failure is pre-existing on the base
branch (environment-dependent, verified via stash) and unrelated.
This commit is contained in:
lhh010
2026-09-04 13:10:21 +08:00
parent 2eb9f66ce8
commit a2d3faf7b5
26 changed files with 349 additions and 4 deletions
+132
View File
@@ -0,0 +1,132 @@
/**
* Secret redaction for rendered file content. The tracer replays read/write
* tool results into the DOM (diff, read view, markdown mode) — when the
* model reads a credentials file, that plaintext must not land on screen or
* in inspectable DOM. Two layers, both pure and unit-tested:
*
* 1. Path layer: files whose NAME marks them as secrets (.env, *secret*,
* *credential*, *token*, *api-key*, private keys, …) are masked whole —
* every non-empty line becomes [REDACTED].
* 2. Content layer: for ordinary files, secret-SHAPED lines and tokens are
* masked in place (api_key/token/password assignments, sk-/AKIA/ghp_/
* xox prefixes, Bearer headers, PEM private-key block headers).
*
* Redaction is display-only: the session log and the tool results keep their
* original bytes; this layer guarantees only that this plugin never renders
* the secret. It is NOT a security boundary against a same-context malicious
* plugin (which can read anything on the page or call host APIs itself) —
* it removes the tracer's own contribution to secret exposure.
*/
/** Placeholder substituted for every secret found. */
export const REDACTED = '[REDACTED]'
/** Result of redacting one text: the masked text + whether anything was hit. */
export interface RedactionOutcome {
readonly text: string
readonly hit: boolean
}
/** Lowercased filename substrings that mark a whole file as sensitive. */
const SENSITIVE_PATH_PATTERNS: readonly string[] = [
'.env', 'secret', 'credential', 'token', 'api-key', 'apikey', 'password',
'passwd', 'private_key', 'privatekey', 'id_rsa', 'id_ed25519',
'.pem', '.key', '.p12', '.pfx',
]
/**
* Whether a path's own name marks the file as sensitive (case-insensitive,
* matched against the full path so .env in any directory counts).
*/
export function isSensitivePath(path: string): boolean {
const lower = path.toLowerCase()
return SENSITIVE_PATH_PATTERNS.some((pattern) => lower.includes(pattern))
}
/**
* Assignment-shaped secret lines: "api_key: …", "TOKEN = …", "password": ….
* The key name survives; the value is masked so the user still sees WHICH
* field was secret.
*/
const ASSIGNMENT_RE = /^(\s*["']?[A-Za-z0-9_.-]*["']?\s*[:=]\s*)(\S.*)$/
/**
* Field names whose assigned value is a secret. Stored in NORMALIZED form
* (lower-case, separators stripped — see normalizeField) so api_key/API-KEY/
* "api-key" all match one entry.
*/
const SECRET_FIELDS = new Set([
'apikey', 'key', 'secret', 'secretkey',
'token', 'apitoken', 'accesstoken', 'refreshtoken', 'idtoken', 'auth',
'password', 'passwd', 'pass', 'pwd',
'clientsecret', 'privatekey',
'accesskey', 'accesskeyid', 'secretaccesskey',
'bearer', 'authorization',
])
/** Bearer-scheme token in free text (covers HTTP headers in logs/configs). */
const BEARER_RE = /(\bBearer\s+)[A-Za-z0-9\-_.+/]{16,}/gi
/** Well-known credential token prefixes (OpenAI/AWS/GitHub/Slack/Google). */
const TOKEN_RES: readonly RegExp[] = [
/\bsk-[A-Za-z0-9_-]{12,}/g,
/\bAKIA[0-9A-Z]{16}\b/g,
/\bghp_[A-Za-z0-9]{20,}\b/g,
/\bgho_[A-Za-z0-9]{20,}\b/g,
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
/\bya29\.[A-Za-z0-9_-]{20,}\b/g,
]
/** Mask an entire non-empty line, keeping whitespace shape. */
function maskLine(line: string): string {
return line.trim().length === 0 ? line : REDACTED
}
/** Normalize a field name for SECRET_FIELDS lookup. */
function normalizeField(raw: string): string {
return raw.replace(/["'\s:=]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '')
}
/** Redact one possibly-secret assignment line; undefined when not a secret field. */
function redactAssignment(line: string): string | undefined {
const match = ASSIGNMENT_RE.exec(line)
const prefix = match?.[1]
if (prefix === undefined || match?.[2] === undefined) return undefined
if (!SECRET_FIELDS.has(normalizeField(prefix))) return undefined
return prefix + REDACTED
}
/** Whether a line is the header of a PEM private-key block. */
function isPemPrivateKeyHeader(line: string): boolean {
return line.includes('-----BEGIN') && line.includes('PRIVATE KEY-----')
}
/**
* Redact secret-shaped content from an ordinary file's text. Only the secret
* itself is masked; surrounding code/config stays readable.
*/
export function redactContentText(text: string): string {
return text
.split('\n')
.map((line) => {
if (isPemPrivateKeyHeader(line)) return REDACTED
const assignment = redactAssignment(line)
if (assignment !== undefined) return assignment
let masked = line.replace(BEARER_RE, (_m, prefix) => prefix + REDACTED)
for (const re of TOKEN_RES) masked = masked.replace(re, () => REDACTED)
return masked
})
.join('\n')
}
/**
* Apply the full two-layer redaction for one file's text. Sensitive paths
* mask whole lines; ordinary files mask only secret-shaped spans.
*/
export function redactText(path: string, text: string): RedactionOutcome {
if (isSensitivePath(path)) {
return { text: text.split('\n').map(maskLine).join('\n'), hit: text.trim().length > 0 }
}
const masked = redactContentText(text)
return { text: masked, hit: masked !== text }
}