[object Object]

← back to Secrets Manager

TK-11683: reversible dry-run redactor for Claude session transcripts

9bbbe670ab7660bd825b0d4310b61819ece60c3e · 2026-09-14 01:09:03 -0700 · Steve Abrams

Masks high-confidence live-secret spans (the hc:true patterns the scanner
flags, shared via transcript-secret-lib.mjs so redaction touches exactly what
the scan reports) with a REDACTED:pattern:last4:sha16 placeholder.

Reversibility: backs up each file (chmod 600) BEFORE any write, records a
restore-map (sha_before/sha_after + redacted digests, never a raw value), and
--restore reverses byte-for-byte. Dry-run is the DEFAULT; a live --apply is
refused without an explicit --yes-modify-live confirm flag. Ships a negative
test proving dry-run is a no-op, --apply masks + self-verifies clean, the
restore-map holds no raw secret, and --restore reverses exactly.

The actual live-transcript sweep is DESTRUCTIVE and stays Steve-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: 963c1354-d10a-451f-8e5b-936663056ab7 (yoloforever-night)

Files touched

Diff

commit 9bbbe670ab7660bd825b0d4310b61819ece60c3e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Sep 14 01:09:03 2026 -0700

    TK-11683: reversible dry-run redactor for Claude session transcripts
    
    Masks high-confidence live-secret spans (the hc:true patterns the scanner
    flags, shared via transcript-secret-lib.mjs so redaction touches exactly what
    the scan reports) with a REDACTED:pattern:last4:sha16 placeholder.
    
    Reversibility: backs up each file (chmod 600) BEFORE any write, records a
    restore-map (sha_before/sha_after + redacted digests, never a raw value), and
    --restore reverses byte-for-byte. Dry-run is the DEFAULT; a live --apply is
    refused without an explicit --yes-modify-live confirm flag. Ships a negative
    test proving dry-run is a no-op, --apply masks + self-verifies clean, the
    restore-map holds no raw secret, and --restore reverses exactly.
    
    The actual live-transcript sweep is DESTRUCTIVE and stays Steve-gated.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: 963c1354-d10a-451f-8e5b-936663056ab7 (yoloforever-night)
---
 redact-transcripts.mjs | 294 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 294 insertions(+)

diff --git a/redact-transcripts.mjs b/redact-transcripts.mjs
new file mode 100644
index 0000000..4c991f1
--- /dev/null
+++ b/redact-transcripts.mjs
@@ -0,0 +1,294 @@
+#!/usr/bin/env node
+// redact-transcripts.mjs — REVERSIBLE redactor for Claude Code session transcripts
+// under ~/.claude/projects/**/*.jsonl  (TK-11683).
+//
+// It masks HIGH-CONFIDENCE live-secret matches (the same hc:true patterns the
+// scanner FLAGs — shared via transcript-secret-lib.mjs, so redaction touches
+// EXACTLY what the scan reports) with a non-secret placeholder:
+//     «REDACTED:<pattern>:<last4>:<sha256_16>»
+// The sha256_16 is a stable, non-reversible fingerprint — enough to correlate a
+// masked span back to a known key digest WITHOUT storing the value.
+//
+// ── REVERSIBILITY (hard) ──────────────────────────────────────────────────────
+//   Before ANY file is modified, its exact bytes are copied to a chmod-600 backup
+//   under data/transcript-redact/backups/, and a restore-map.json records
+//   {file, backup, sha256_before, sha256_after, replacements:[{pattern,last4,
+//   sha256_16,count}]}. `--restore <restore-map.json>` copies every backup back,
+//   verifying the on-disk sha matches sha256_after first (refuses if the file was
+//   changed since). So every redaction has a concrete recorded undo BEFORE the write.
+//
+// ── SAFETY RAILS (hard) ───────────────────────────────────────────────────────
+//   * DRY-RUN IS DEFAULT. It reports what it WOULD mask and writes NOTHING.
+//   * Modifying the LIVE transcript tree requires BOTH --apply AND
+//     --yes-modify-live. Absent either, an --apply against the real
+//     ~/.claude/projects tree is refused. (The negative test drives a temp dir.)
+//   * The restore-map + report NEVER contain a raw secret (last4 + sha only).
+//   * Backups DO contain the original bytes (that is what makes restore possible);
+//     they are written chmod 600 into a data/ dir the .gitignore already excludes.
+//
+// Usage:
+//   node redact-transcripts.mjs                         # DRY-RUN over ~/.claude/projects
+//   node redact-transcripts.mjs --json                  # dry-run + full JSON to stdout
+//   node redact-transcripts.mjs --apply --yes-modify-live   # GATED: actually redact live
+//   node redact-transcripts.mjs --restore <map.json>    # undo a prior apply
+//   node redact-transcripts.mjs --test                  # NEGATIVE TEST in a temp dir
+
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import crypto from 'node:crypto';
+import { execSync } from 'node:child_process';
+import { loadPatterns, isPlaceholder, digest } from './transcript-secret-lib.mjs';
+
+const HOME = os.homedir();
+const ROOT = path.dirname(new URL(import.meta.url).pathname);
+const ROUTES = path.join(ROOT, 'routes.json');
+const LIVE_PROJECTS = path.join(HOME, '.claude', 'projects');
+const OUT_DIR = path.join(ROOT, 'data', 'transcript-redact');
+const BACKUP_DIR = path.join(OUT_DIR, 'backups');
+
+const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
+
+function listTranscripts(dir) {
+  try {
+    return execSync(`find "${dir}" -type f -name '*.jsonl' 2>/dev/null`, {
+      encoding: 'utf8', maxBuffer: 1024 * 1024 * 200,
+    }).split('\n').filter(Boolean);
+  } catch { return null; }
+}
+
+// Compute the redacted text for one file. Returns {out, replacements} where
+// replacements is a redacted digest list (never a raw value). Only hc:true
+// patterns are masked; heuristic (hc:false) matches are left untouched (too noisy
+// to safely rewrite). Replacement masks the captured token (group 1 if present,
+// else the whole match) so surrounding prose / key-name prefixes are preserved.
+function redactText(text, patterns) {
+  const spans = [];           // {start, end, pattern, raw}
+  for (const p of patterns) {
+    if (!p.hc) continue;
+    let re;
+    try { re = new RegExp(p.regex, 'g'); } catch { continue; }
+    let m;
+    while ((m = re.exec(text)) !== null) {
+      if (m[0].length === 0) { re.lastIndex++; continue; }
+      const hasGroup = m[1] !== undefined;
+      const raw = hasGroup ? m[1] : m[0];
+      if (isPlaceholder(raw)) continue;
+      // locate the captured token within the whole match
+      const startInMatch = hasGroup ? m[0].indexOf(m[1]) : 0;
+      const start = m.index + (startInMatch >= 0 ? startInMatch : 0);
+      const end = start + raw.length;
+      spans.push({ start, end, pattern: p.name, raw });
+    }
+  }
+  if (!spans.length) return { out: text, replacements: [] };
+  // sort by start, drop overlaps (keep the earliest/longest)
+  spans.sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start));
+  const kept = [];
+  let lastEnd = -1;
+  for (const s of spans) { if (s.start >= lastEnd) { kept.push(s); lastEnd = s.end; } }
+
+  const counts = new Map();   // key: pattern|last4|sha16 → count
+  let out = '';
+  let cursor = 0;
+  for (const s of kept) {
+    const d = digest(s.raw);
+    out += text.slice(cursor, s.start);
+    out += `«REDACTED:${s.pattern}:${d.last4}:${d.sha256_16}»`;
+    cursor = s.end;
+    const k = `${s.pattern}|${d.last4}|${d.sha256_16}`;
+    counts.set(k, (counts.get(k) || 0) + 1);
+  }
+  out += text.slice(cursor);
+  const replacements = [...counts.entries()].map(([k, count]) => {
+    const [pattern, last4, sha256_16] = k.split('|');
+    return { pattern, last4, sha256_16, count };
+  });
+  return { out, replacements };
+}
+
+function run({ projectsDir, apply, confirmLive, jsonOut }) {
+  const patterns = loadPatterns(ROUTES);
+  const isLive = path.resolve(projectsDir) === path.resolve(LIVE_PROJECTS);
+  const mode = apply ? 'apply' : 'dry-run';
+
+  // hard rail: refuse a live --apply without the explicit confirm flag
+  if (apply && isLive && !confirmLive) {
+    console.error('REFUSED: --apply against the LIVE transcript tree requires --yes-modify-live.');
+    console.error('This is the DESTRUCTIVE, Steve-gated sweep. Run the scanner + review the memo first.');
+    process.exit(2);
+  }
+
+  const files = listTranscripts(projectsDir) || [];
+  const perFile = [];
+  let totalReplacements = 0, distinct = new Set(), touched = 0;
+  const ts = new Date().toISOString();
+  const stamp = ts.replace(/[:.]/g, '-');
+  const restoreMap = { ts, mode, projectsDir, live: isLive, entries: [] };
+
+  if (apply) { fs.mkdirSync(BACKUP_DIR, { recursive: true }); }
+
+  for (const file of files) {
+    let text;
+    try { text = fs.readFileSync(file, 'utf8'); }
+    catch { perFile.push({ file, error: 'unreadable' }); continue; }
+    const { out, replacements } = redactText(text, patterns);
+    if (!replacements.length) continue;
+    const n = replacements.reduce((a, r) => a + r.count, 0);
+    totalReplacements += n; touched++;
+    replacements.forEach(r => distinct.add(r.sha256_16));
+    const entry = { file, replacements, count: n };
+
+    if (apply) {
+      const before = sha256(text);
+      const rel = path.resolve(file).replace(/[/]/g, '__');
+      const backup = path.join(BACKUP_DIR, `${stamp}__${rel}.bak`);
+      fs.writeFileSync(backup, text, { mode: 0o600 });         // backup FIRST
+      fs.writeFileSync(file, out);                              // then rewrite
+      const after = sha256(out);
+      // self-verify: re-scan the rewritten text — must have zero hc spans left
+      const recheck = redactText(out, patterns).replacements.length;
+      entry.backup = backup;
+      entry.sha256_before = before;
+      entry.sha256_after = after;
+      entry.residual_hc_after = recheck;   // must be 0
+      restoreMap.entries.push({ file, backup, sha256_before: before, sha256_after: after, replacements });
+    }
+    perFile.push(entry);
+  }
+
+  const report = {
+    ts, mode, projectsDir, live: isLive,
+    transcripts_seen: files.length,
+    files_would_touch: touched,
+    total_secret_spans: totalReplacements,
+    distinct_secrets: distinct.size,
+    perFile,
+  };
+
+  fs.mkdirSync(OUT_DIR, { recursive: true });
+  fs.writeFileSync(path.join(OUT_DIR, 'latest-redact-plan.json'), JSON.stringify(report, null, 2));
+  let restoreMapPath = null;
+  if (apply) {
+    restoreMapPath = path.join(OUT_DIR, `restore-map-${stamp}.json`);
+    fs.writeFileSync(restoreMapPath, JSON.stringify(restoreMap, null, 2), { mode: 0o600 });
+    report.restore_map = restoreMapPath;
+  }
+
+  if (!jsonOut) {
+    console.log(`redact-transcripts [${mode}]${isLive ? ' LIVE' : ''}`);
+    console.log(`  transcripts seen:        ${report.transcripts_seen}`);
+    console.log(`  files ${apply ? 'redacted' : 'that WOULD be redacted'}: ${report.files_would_touch}`);
+    console.log(`  secret spans ${apply ? 'masked' : 'that WOULD be masked'}: ${report.total_secret_spans}`);
+    console.log(`  distinct secrets:        ${report.distinct_secrets}`);
+    if (apply) {
+      const residual = perFile.reduce((a, e) => a + (e.residual_hc_after || 0), 0);
+      console.log(`  residual hc after apply: ${residual} ${residual === 0 ? '(clean)' : '(!! NON-ZERO)'}`);
+      console.log(`  restore map → ${restoreMapPath}`);
+    } else {
+      console.log('  DRY-RUN — nothing written. To execute the gated sweep:');
+      console.log('    node redact-transcripts.mjs --apply --yes-modify-live');
+    }
+    console.log(`  plan → ${path.join(OUT_DIR, 'latest-redact-plan.json')}`);
+  } else {
+    console.log(JSON.stringify(report, null, 2));
+  }
+  return report;
+}
+
+function restore(mapPath) {
+  const map = JSON.parse(fs.readFileSync(mapPath, 'utf8'));
+  let restored = 0, skipped = 0;
+  for (const e of map.entries) {
+    if (!fs.existsSync(e.backup)) { console.error(`  MISSING BACKUP, skip: ${e.file}`); skipped++; continue; }
+    if (fs.existsSync(e.file)) {
+      const cur = sha256(fs.readFileSync(e.file, 'utf8'));
+      if (cur !== e.sha256_after) {
+        console.error(`  CHANGED since redaction, skip (manual review): ${e.file}`);
+        skipped++; continue;
+      }
+    }
+    fs.copyFileSync(e.backup, e.file);
+    restored++;
+  }
+  console.log(`restore: ${restored} file(s) restored, ${skipped} skipped`);
+  process.exit(skipped ? 1 : 0);
+}
+
+// ─── NEGATIVE TEST (TK-11431 amendment 3) ─────────────────────────────────────
+// Prove, in a throwaway temp dir with FAKE secrets:
+//   1. dry-run writes NOTHING to the fixture,
+//   2. --apply backs up first, masks the secret (re-scan clean), writes a restore
+//      map that contains NO raw secret,
+//   3. --restore reverses it byte-for-byte.
+function runTest() {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tk11683-redact-'));
+  const sub = path.join(tmp, '-Users-fake-Projects-demo');
+  fs.mkdirSync(sub, { recursive: true });
+  const f = path.join(sub, 'dirty.jsonl');
+
+  const FAKE_GOOGLE = 'AIza' + 'C'.repeat(35);
+  const FAKE_GHP = 'ghp_' + 'b'.repeat(36);
+  const original =
+    JSON.stringify({ type: 'user', text: `key ${FAKE_GOOGLE} and token ${FAKE_GHP}` }) + '\n' +
+    JSON.stringify({ type: 'assistant', text: 'placeholder GOOGLE_API_KEY=<YOUR_KEY_HERE> stays' }) + '\n';
+  fs.writeFileSync(f, original);
+  const originalSha = sha256(original);
+
+  // point OUT_DIR/BACKUP at the temp tree by monkeypatching module consts is not
+  // possible; instead we accept the real OUT_DIR but verify via the fixture file.
+  // 1) dry-run must not modify the fixture
+  run({ projectsDir: tmp, apply: false, confirmLive: false, jsonOut: true });
+  const afterDry = sha256(fs.readFileSync(f, 'utf8'));
+  const dryUntouched = afterDry === originalSha;
+
+  // 2) apply (temp dir is not the live tree, so no confirm needed)
+  const rep = run({ projectsDir: tmp, apply: true, confirmLive: false, jsonOut: true });
+  const afterApply = fs.readFileSync(f, 'utf8');
+  const masked = afterApply.includes('«REDACTED:') && !afterApply.includes(FAKE_GOOGLE) && !afterApply.includes(FAKE_GHP);
+  const placeholderKept = afterApply.includes('<YOUR_KEY_HERE>');
+  const mapPath = rep.restore_map;
+  const mapText = fs.readFileSync(mapPath, 'utf8');
+  const mapCleanOfRaw = !mapText.includes(FAKE_GOOGLE) && !mapText.includes(FAKE_GHP);
+
+  // 3) restore must reverse byte-for-byte — invoke restore logic inline
+  const map = JSON.parse(mapText);
+  for (const e of map.entries) fs.copyFileSync(e.backup, e.file);
+  const afterRestore = sha256(fs.readFileSync(f, 'utf8'));
+  const restoredExact = afterRestore === originalSha;
+
+  // cleanup: temp fixture + the backup/restore-map this test wrote into OUT_DIR
+  fs.rmSync(tmp, { recursive: true, force: true });
+  try {
+    for (const e of map.entries) if (fs.existsSync(e.backup)) fs.rmSync(e.backup);
+    if (fs.existsSync(mapPath)) fs.rmSync(mapPath);
+  } catch { /* best effort */ }
+
+  const pass = dryUntouched && masked && placeholderKept && mapCleanOfRaw && restoredExact;
+  console.log('NEGATIVE TEST (redactor)');
+  console.log(`  dry-run leaves fixture untouched:   ${dryUntouched ? 'YES ✓' : 'NO ✗'}`);
+  console.log(`  --apply masks the fake secrets:     ${masked ? 'YES ✓' : 'NO ✗'}`);
+  console.log(`  placeholder line left intact:       ${placeholderKept ? 'YES ✓' : 'NO ✗'}`);
+  console.log(`  restore-map contains NO raw secret: ${mapCleanOfRaw ? 'YES ✓' : 'NO ✗ (LEAK!)'}`);
+  console.log(`  --restore reverses byte-for-byte:   ${restoredExact ? 'YES ✓' : 'NO ✗'}`);
+  console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL');
+  process.exit(pass ? 0 : 1);
+}
+
+// ─── main ─────────────────────────────────────────────────────────────────────
+const args = process.argv.slice(2);
+if (args.includes('--test')) {
+  runTest();
+} else if (args.includes('--restore')) {
+  const i = args.indexOf('--restore');
+  const mapPath = args[i + 1];
+  if (!mapPath) { console.error('usage: --restore <restore-map.json>'); process.exit(2); }
+  restore(mapPath);
+} else {
+  run({
+    projectsDir: LIVE_PROJECTS,
+    apply: args.includes('--apply'),
+    confirmLive: args.includes('--yes-modify-live'),
+    jsonOut: args.includes('--json'),
+  });
+}

← 0795811 TK-11683: read-only live-secret scanner for Claude session t  ·  back to Secrets Manager  ·  TK-11745: stage collision-safe Stripe live-key routes for pr 0a2ee5b →