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
+50 -2
View File
@@ -21,6 +21,7 @@ import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { parseReadContent, parseReadLines, type FileOp } from './ops.ts'
import { redactText } from '../redact.ts'
import { rewriteLocalImageUrls } from '../markdown-images.ts'
import { markdownTextProps } from '../markdown-labels.tsx'
import { splitMermaidBlocks } from '../mermaid-blocks.ts'
@@ -150,8 +151,40 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
// ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ────────────────
const op = target.kind === 'op' ? target.op : null
const prior = target.kind === 'op' ? target.prior : undefined
const opRaw = target.kind === 'op' ? target.op : null
const priorRaw = target.kind === 'op' ? target.prior : undefined
// Secret redaction: on by default, toggle persists per browser (localStorage).
// Every op payload consumer below (diff rows, read rows, markdown source,
// error text) renders from the REDACTED shape, so masked payloads are the
// only thing that can reach the DOM while the toggle is on. Display-only:
// session events and the fs layer keep their original bytes.
const [redactionOn, setRedactionOn] = useState((): boolean => {
try { return localStorage.getItem('dsh-better-sidebar.redaction') !== '0' } catch { return true }
})
const toggleRedaction = (): void => {
setRedactionOn((prev) => {
const next = !prev
try { localStorage.setItem('dsh-better-sidebar.redaction', next ? '1' : '0') } catch { /* storage unavailable */ }
return next
})
}
const { op, prior, redactionHit } = useMemo(() => {
const path = target.kind === 'op' ? target.path : ''
if (opRaw === null || !redactionOn) {
return { op: opRaw, prior: priorRaw, redactionHit: false }
}
const mask = (text: string): string => redactText(path, text).text
const hit = [opRaw.read, opRaw.content, opRaw.edit?.oldString, opRaw.edit?.newString, opRaw.errorText, priorRaw]
.some((text) => text !== undefined && redactText(path, text).hit)
const redacted: FileOp = {
...opRaw,
...(opRaw.read !== undefined ? { read: mask(opRaw.read) } : {}),
...(opRaw.content !== undefined ? { content: mask(opRaw.content) } : {}),
...(opRaw.edit !== undefined ? { edit: { oldString: mask(opRaw.edit.oldString), newString: mask(opRaw.edit.newString) } } : {}),
...(opRaw.errorText !== undefined ? { errorText: mask(opRaw.errorText) } : {}),
}
return { op: redacted, prior: priorRaw === undefined ? undefined : mask(priorRaw), redactionHit: true }
}, [opRaw, priorRaw, target, redactionOn]);
const opLang = useMemo(() => (target.kind === 'op' ? langOfPath(target.path) : undefined), [target])
const opRows = useMemo(() => (op === null ? [] : diffOf(op, prior)), [op, prior])
const opSegments = useMemo(() => buildDiffSegments(opRows), [opRows])
@@ -294,6 +327,21 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
</button>
</>
)}
{redactionHit && redactionOn && (
<span className={css.redactBanner} role="status">{t('changesRedactBanner')}</span>
)}
{target.kind === 'op' && (
<button
type="button"
className={css.mdToggle}
data-on={redactionOn ? 'true' : undefined}
aria-pressed={redactionOn}
onClick={toggleRedaction}
title={redactionOn ? t('changesRedactOff') : t('changesRedactOn')}
>
{redactionOn ? t('changesRedactOnLabel') : t('changesRedactOffLabel')}
</button>
)}
{mdOp && (
<button
type="button"
+1
View File
@@ -342,6 +342,7 @@
/* ── Shared preview pane ───────────────────────────────────────────────── */
.redactBanner { flex: none; font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; border-radius: 7px; color: var(--dsw-alias-state-warning-primary, #b8860b); background: var(--dsw-alias-interactive-bg-hover); white-space: nowrap; }
.mdToggle { flex: none; border: 1px solid var(--dsw-alias-border-l1); border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); padding: 2px 8px; cursor: pointer; }
.mdToggle:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.mdToggle[data-on='true'] { border-color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary); }