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:
@@ -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"
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user