/** * Strip machine-identifying filesystem paths, and optional keywords, from a session * transcript. Pure: raw JSONL in, JSONL out, no I/O. */ export const DEFAULT_PLACEHOLDER = '~/repo'; export const HOME_DIR_PLACEHOLDER = '~'; export const REDACTION_PLACEHOLDER = '[redacted]'; export interface SanitizeOptions { /** Replacement for the cwd-prefix. Its dash-encoded form is derived from it. */ placeholder?: string; /** Keyword regexes to redact. Empty by default, leaving a pure path-scrubber. */ forbiddenMarkers?: readonly RegExp[]; /** * Exact prefix to strip. An inferred one is only the repo root when some cwd sat * there, so callers that know the root pass it here. */ cwdPrefix?: string; /** Several roots at once (a session spanning two checkouts). Wins over `cwdPrefix`. */ cwdPrefixes?: readonly string[]; /** * Also strip home-rooted paths in the CONTENT: a sandbox-recorded session has a * sandbox `cwd`, so the cwd passes never see the local checkout it still mentions. */ scrubEmbeddedHomePaths?: boolean; } export interface SanitizeResult { sanitized: string; prefixStripped: string | null; encodedPrefixStripped: string | null; homeDirStripped: string | null; encodedHomeDirStripped: string | null; embeddedPrefixStripped: string | null; embeddedHomeDirStripped: string | null; /** Replacement count per marker, keyed by the regex's source string. */ markersScrubbed: Record; } /** Longest common prefix by path COMPONENT: `/a/bb` and `/a/b` share `/a`, not `/a/b`. * Returns `''` when only the root `/` is common. */ export function findLongestCommonPathPrefix(paths: Iterable): string { const arr = Array.from(paths); if (arr.length === 0) return ''; const splits = arr.map((p) => p.split('/')); const minLen = Math.min(...splits.map((s) => s.length)); let lastShared = 0; for (let i = 0; i < minLen; i++) { const c = splits[0][i]; if (splits.some((s) => s[i] !== c)) break; lastShared = i + 1; } // Only the leading empty piece matched → just the root, not useful. if (lastShared <= 1) return ''; return splits[0].slice(0, lastShared).join('/'); } /** The home-dir portion of an absolute path, or `null` for an unrecognized shape — * better to skip the home pass than strip what may be repo content. */ export function extractHomeDir(cwdPrefix: string): string | null { if (!cwdPrefix.startsWith('/')) return null; // Windows-under-WSL shapes first: the generic drive shape below would stop at the // drive letter and leave the account name in. A volume or drive root carries no // identity by itself, so those take the directory under it. const patterns: RegExp[] = [ /^\/mnt\/host\/[^/]+\/Users\/[^/]+/, /^\/mnt\/[^/]+\/Users\/[^/]+/, /^\/Users\/[^/]+/, /^\/home\/[^/]+/, /^\/Volumes\/[^/]+\/[^/]+/, /^\/mnt\/[^/]+\/[^/]+/, /^\/var\/root(?=\/|$)/, /^\/root(?=\/|$)/, ]; for (const re of patterns) { const m = cwdPrefix.match(re); if (m) return m[0]; } return null; } /** Every distinct `cwd` in the transcript. Read at the top level (Claude Code) and * under `payload` (codex), so both harnesses are covered. Bad lines are skipped. */ export function collectCwds(raw: string): Set { const out = new Set(); const add = (v: unknown) => { if (typeof v === 'string' && v.startsWith('/')) out.add(v); }; for (const line of raw.split('\n')) { if (!line.trim()) continue; let parsed: unknown; try { parsed = JSON.parse(line); } catch { continue; } if (typeof parsed !== 'object' || parsed === null) continue; const rec = parsed as { cwd?: unknown; payload?: unknown }; add(rec.cwd); if (typeof rec.payload === 'object' && rec.payload !== null) { add((rec.payload as { cwd?: unknown }).cwd); } } return out; } /** One path segment: stops at `/`, whitespace, quotes and JSON punctuation. */ const COMP = String.raw`[^/\s"'\\,:;)\]}<>]+`; // macOS/Windows display names can contain spaces, but only consume them while // more path follows, so a bare home-dir mention doesn't swallow trailing prose. const USER_WITH_SPACES = `${COMP}(?:(?: +${COMP})+(?=/))?`; const EMBEDDED_HOME_RE = new RegExp( '(?:' + String.raw`\/home\/${COMP}` + '|' + String.raw`\/Users\/${USER_WITH_SPACES}` + '|' + String.raw`\/mnt\/c\/Users\/${USER_WITH_SPACES}` + '|' + // Component boundary, so these don't match inside `/rootfs` or `/root_ca.pem`. String.raw`\/var\/root(?![^/])` + '|' + String.raw`\/root(?![^/])` + ')' + String.raw`(?:\/${COMP})*`, 'g' ); export function collectEmbeddedHomePaths(raw: string): Set { const out = new Set(); for (const m of raw.matchAll(EMBEDDED_HOME_RE)) out.add(m[0]); return out; } function literalReplaceAll(haystack: string, needle: string, replacement: string): string { if (!needle) return haystack; return haystack.split(needle).join(replacement); } /** Can `ch` continue a path component? A `.` counts only mid-component, so `…/repo.git` * is one component but `…/repo.` ending a sentence is not. */ function continuesComponent(text: string, at: number): boolean { const ch = text[at]; if (ch === undefined) return false; if (/[A-Za-z0-9_-]/.test(ch)) return true; return ch === '.' && at + 1 < text.length && /[A-Za-z0-9_-]/.test(text[at + 1]); } /** Replace `needle` only where it ends at a component boundary, so stripping `…/wt/repo` * can't turn `…/wt/repo-backup` into `-backup`. Skipped ones go to the home pass. */ function replacePrefixAtBoundary(haystack: string, needle: string, replacement: string): string { if (!needle) return haystack; let out = ''; let from = 0; for (;;) { const i = haystack.indexOf(needle, from); if (i === -1) return out + haystack.slice(from); const end = i + needle.length; out += haystack.slice(from, i) + (continuesComponent(haystack, end) ? needle : replacement); from = end; } } /** Replace a prefix and its dash-encoded form (`.claude/projects//`). */ function stripBothForms(haystack: string, needle: string, replacement: string): string { const out = literalReplaceAll(haystack, needle, replacement); return literalReplaceAll(out, needle.replace(/\//g, '-'), replacement.replace(/\//g, '-')); } export function sanitizeSessionJsonl(raw: string, opts: SanitizeOptions = {}): SanitizeResult { const placeholder = opts.placeholder ?? DEFAULT_PLACEHOLDER; const markers = opts.forbiddenMarkers ?? []; const cwds = collectCwds(raw); let working = raw; let prefixStripped: string | null = null; let encodedPrefixStripped: string | null = null; let homeDirStripped: string | null = null; let encodedHomeDirStripped: string | null = null; let embeddedPrefixStripped: string | null = null; let embeddedHomeDirStripped: string | null = null; const requested = opts.cwdPrefixes?.length ? [...opts.cwdPrefixes] : opts.cwdPrefix ? [opts.cwdPrefix] : cwds.size > 0 ? [findLongestCommonPathPrefix(cwds)] : []; // Longest first, so a shorter root sharing a prefix can't partly clobber a nested one. const prefixes = [...new Set(requested.filter(Boolean))].sort((a, b) => b.length - a.length); // EVERY root before ANY home dir: a home pass run between roots would rewrite a // sibling root's own prefix, leaving it unmatched when its turn came. for (const prefix of prefixes) { const encodedPrefix = prefix.replace(/\//g, '-'); working = replacePrefixAtBoundary(working, prefix, placeholder); working = literalReplaceAll(working, encodedPrefix, placeholder.replace(/\//g, '-')); prefixStripped ??= prefix; encodedPrefixStripped ??= encodedPrefix; } // Only catches what is left outside the roots, e.g. `/home//.claude/projects/`. const homeDirs = new Set( prefixes .map((p) => extractHomeDir(p)) .filter((h): h is string => h !== null && !prefixes.includes(h)) ); for (const homeDir of homeDirs) { const encodedHomeDir = homeDir.replace(/\//g, '-'); working = replacePrefixAtBoundary(working, homeDir, HOME_DIR_PLACEHOLDER); working = literalReplaceAll(working, encodedHomeDir, HOME_DIR_PLACEHOLDER.replace(/\//g, '-')); homeDirStripped ??= homeDir; encodedHomeDirStripped ??= encodedHomeDir; } if (opts.scrubEmbeddedHomePaths) { const embedded = collectEmbeddedHomePaths(working); if (embedded.size > 0) { // Take each path's own shortest `/repo`-terminated prefix rather than a // common prefix, which mis-collapses when paths diverge above the root. const repoRoots = new Set(); const homeDirs = new Set(); for (const p of embedded) { const h = extractHomeDir(p); if (h) homeDirs.add(h); const m = p.match(/^(.*?\/repo)(?:\/|$)/); if (m) repoRoots.add(m[1]); } // Longest first, so a shorter root sharing a prefix can't partly clobber a nested one. const sortedRoots = [...repoRoots].sort((a, b) => b.length - a.length); for (const root of sortedRoots) working = stripBothForms(working, root, placeholder); for (const h of homeDirs) working = stripBothForms(working, h, HOME_DIR_PLACEHOLDER); embeddedPrefixStripped = sortedRoots[0] ?? null; embeddedHomeDirStripped = [...homeDirs][0] ?? null; } } const markersScrubbed: Record = {}; for (const re of markers) { let count = 0; const flags = re.flags.includes('g') ? re.flags : re.flags + 'g'; const global = new RegExp(re.source, flags); working = working.replace(global, () => { count++; return REDACTION_PLACEHOLDER; }); if (count > 0) markersScrubbed[re.source] = count; } return { sanitized: working, prefixStripped, encodedPrefixStripped, homeDirStripped, encodedHomeDirStripped, embeddedPrefixStripped, embeddedHomeDirStripped, markersScrubbed, }; }