@vieko/pi-redact 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * scrub-sessions: rewrite vendor-shaped keys out of saved pi sessions, and
3
+ * report how many leaked, how often, and how they got in.
4
+ *
5
+ * node scripts/scrub-sessions.ts [--write] [--dir ~/.pi/agent/sessions]
6
+ * [--report out.json] [--skip-recent-min 15]
7
+ *
8
+ * Dry run by default. Only high-confidence matches (vendor shapes and private
9
+ * key blocks) are rewritten; entropy guesses are not, because old history
10
+ * cannot be restored and a false positive would corrupt it for good. Each key
11
+ * becomes `[REDACTED-SECRET-<sha256/8>]`, the same tag for the same key in
12
+ * every file, so the report can track one key across sessions without
13
+ * holding the value. Signature fields are never touched.
14
+ *
15
+ * Files that are open in a process or modified in the last N minutes are
16
+ * skipped: pi appends to the live session file. Rewritten files keep their
17
+ * mtime, so /resume ordering and session-audit windows do not move.
18
+ */
19
+
20
+ import { createHash } from "node:crypto";
21
+ import { execFileSync } from "node:child_process";
22
+ import { readdirSync, readFileSync, renameSync, statSync, utimesSync, writeFileSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { findVendorKeys } from "../src/detect.ts";
26
+
27
+ const args = process.argv.slice(2);
28
+ const flag = (name: string): string | undefined => {
29
+ const i = args.indexOf(name);
30
+ return i >= 0 ? args[i + 1] : undefined;
31
+ };
32
+ const WRITE = args.includes("--write");
33
+ const DIR = flag("--dir") ?? join(homedir(), ".pi", "agent", "sessions");
34
+ const REPORT = flag("--report");
35
+ const RECENT_MS = Number(flag("--skip-recent-min") ?? 15) * 60_000;
36
+
37
+ /** Fields whose value is signed or opaque; editing them breaks a resume. */
38
+ const SKIP_KEYS = new Set(["thinkingSignature", "signature", "textSignature", "encrypted_content", "id", "parentId"]);
39
+
40
+ /** A match that reads like a docs example or a test fixture, not a key. */
41
+ const FAKE = /(.)\1{5}|abcdef|123456|xxxx|example|sandbox|dummy|fake|test|0000/i;
42
+
43
+ type Occurrence = { file: string; ts: string; channel: string };
44
+ type KeyStats = { family: string; likelyFake: boolean; length: number; occurrences: Occurrence[] };
45
+
46
+ const keys = new Map<string, KeyStats>();
47
+ const errors: string[] = [];
48
+ let filesScanned = 0;
49
+ let filesChanged = 0;
50
+ let linesChanged = 0;
51
+ const skipped: string[] = [];
52
+
53
+ const tagFor = (value: string): string => `[REDACTED-SECRET-${createHash("sha256").update(value).digest("hex").slice(0, 8)}]`;
54
+
55
+ function openFiles(): Set<string> {
56
+ try {
57
+ const out = execFileSync("lsof", ["-Fn", "-c", "node", "-c", "pi", "-c", "bun"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
58
+ return new Set(out.split("\n").filter((l) => l.startsWith("n") && l.endsWith(".jsonl")).map((l) => l.slice(1)));
59
+ } catch {
60
+ return new Set();
61
+ }
62
+ }
63
+
64
+ function channelOf(entry: any, path: string[]): string {
65
+ const m = entry.message;
66
+ const parts = [entry.type];
67
+ if (m?.role) parts.push(m.role);
68
+ if (m?.role === "toolResult" && m.toolName) parts.push(m.toolName);
69
+ if (m?.role === "custom" || entry.type === "custom_message") parts.push(m?.customType ?? entry.customType ?? "");
70
+ const field = path.filter((p) => !/^\d+$/.test(p)).slice(-2).join(".");
71
+ return `${parts.filter(Boolean).join("/")} :: ${field}`;
72
+ }
73
+
74
+ function scrubValue(value: unknown, path: string[], onHit: (v: string, family: string, path: string[]) => void): unknown {
75
+ if (typeof value === "string") {
76
+ const hits = findVendorKeys(value);
77
+ if (hits.length === 0) return value;
78
+ let out = value;
79
+ for (const { value: v, family } of hits.sort((a, b) => b.value.length - a.value.length)) {
80
+ if (!out.includes(v)) continue;
81
+ onHit(v, family, path);
82
+ out = out.split(v).join(tagFor(v));
83
+ }
84
+ return out;
85
+ }
86
+ if (Array.isArray(value)) {
87
+ let changed = false;
88
+ const out = value.map((v, i) => {
89
+ const r = scrubValue(v, [...path, String(i)], onHit);
90
+ if (r !== v) changed = true;
91
+ return r;
92
+ });
93
+ return changed ? out : value;
94
+ }
95
+ if (value && typeof value === "object") {
96
+ let changed = false;
97
+ const out: Record<string, unknown> = {};
98
+ for (const [k, v] of Object.entries(value)) {
99
+ const r = SKIP_KEYS.has(k) ? v : scrubValue(v, [...path, k], onHit);
100
+ if (r !== v) changed = true;
101
+ out[k] = r;
102
+ }
103
+ return changed ? out : value;
104
+ }
105
+ return value;
106
+ }
107
+
108
+ function sessionFiles(dir: string): string[] {
109
+ const out: string[] = [];
110
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
111
+ const p = join(dir, e.name);
112
+ if (e.isDirectory()) out.push(...sessionFiles(p));
113
+ else if (e.name.endsWith(".jsonl")) out.push(p);
114
+ }
115
+ return out;
116
+ }
117
+
118
+ const open = openFiles();
119
+ const now = Date.now();
120
+ for (const file of sessionFiles(DIR)) {
121
+ filesScanned++;
122
+ const st = statSync(file);
123
+ const raw = readFileSync(file, "utf8");
124
+ // Cheap gate: most files hold no vendor key at all.
125
+ if (findVendorKeys(raw).length === 0) continue;
126
+ const lines = raw.split("\n");
127
+ let fileChanged = false;
128
+ const outLines = lines.map((line) => {
129
+ if (!line.trim()) return line;
130
+ let entry: any;
131
+ try {
132
+ entry = JSON.parse(line);
133
+ } catch {
134
+ return line;
135
+ }
136
+ const ts = entry.timestamp ?? entry.message?.timestamp ?? "";
137
+ const scrubbed = scrubValue(entry, [], (v, family, path) => {
138
+ const id = tagFor(v);
139
+ const k = keys.get(id) ?? { family, likelyFake: FAKE.test(v), length: v.length, occurrences: [] };
140
+ k.occurrences.push({ file, ts: typeof ts === "number" ? new Date(ts).toISOString() : String(ts), channel: channelOf(entry, path) });
141
+ keys.set(id, k);
142
+ });
143
+ if (scrubbed === entry) return line;
144
+ fileChanged = true;
145
+ linesChanged++;
146
+ return JSON.stringify(scrubbed);
147
+ });
148
+ if (!fileChanged) continue;
149
+ filesChanged++;
150
+ const live = open.has(file) || now - st.mtimeMs < RECENT_MS;
151
+ if (live) {
152
+ skipped.push(file);
153
+ continue;
154
+ }
155
+ if (!WRITE) continue;
156
+ const next = outLines.join("\n");
157
+ // Verify before swapping: same line count, every line still parses, no key left.
158
+ const check = next.split("\n");
159
+ if (check.length !== lines.length || check.some((l) => l.trim() && (() => { try { JSON.parse(l); return false; } catch { return true; } })())) {
160
+ errors.push(`verify failed, left as is: ${file}`);
161
+ continue;
162
+ }
163
+ const tmp = `${file}.pi-redact-tmp`;
164
+ writeFileSync(tmp, next, { mode: st.mode });
165
+ const left = findVendorKeys(readFileSync(tmp, "utf8"));
166
+ renameSync(tmp, file);
167
+ utimesSync(file, st.atime, st.mtime);
168
+ if (left.length > 0) errors.push(`${left.length} match(es) remain in signed fields or ids: ${file}`);
169
+ }
170
+
171
+ // ------------------------------------------------------------------ report
172
+
173
+ const report = {
174
+ generatedAt: new Date().toISOString(),
175
+ mode: WRITE ? "write" : "dry-run",
176
+ dir: DIR,
177
+ filesScanned,
178
+ filesWithKeys: filesChanged,
179
+ linesChanged,
180
+ skippedLive: skipped,
181
+ errors,
182
+ keys: [...keys].map(([tag, k]) => {
183
+ const files = new Set(k.occurrences.map((o) => o.file));
184
+ const times = k.occurrences.map((o) => o.ts).filter(Boolean).sort();
185
+ const channels: Record<string, number> = {};
186
+ for (const o of k.occurrences) channels[o.channel] = (channels[o.channel] ?? 0) + 1;
187
+ const first = [...k.occurrences].sort((a, b) => a.ts.localeCompare(b.ts))[0];
188
+ return {
189
+ tag,
190
+ family: k.family,
191
+ likelyFake: k.likelyFake,
192
+ length: k.length,
193
+ occurrences: k.occurrences.length,
194
+ sessions: files.size,
195
+ firstSeen: times[0] ?? null,
196
+ lastSeen: times.at(-1) ?? null,
197
+ origin: first?.channel ?? null,
198
+ channels,
199
+ sessionFiles: [...files].map((f) => f.replace(DIR, "")),
200
+ };
201
+ }),
202
+ };
203
+
204
+ const json = JSON.stringify(report, null, 2);
205
+ if (REPORT) writeFileSync(REPORT, json);
206
+ else console.log(json);
207
+ console.error(
208
+ `${report.mode}: ${filesScanned} files, ${filesChanged} with keys, ${linesChanged} lines, ${keys.size} distinct keys, ${skipped.length} live skipped, ${errors.length} errors`,
209
+ );