← 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 →