whatileaked 0.1.0 → 0.2.0

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.
Files changed (3) hide show
  1. package/README.md +7 -2
  2. package/dist/cli.js +188 -64
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -192,8 +192,13 @@ npx whatileaked scan || echo "found something"
192
192
 
193
193
  | Agent | Folder |
194
194
  | --- | --- |
195
- | Claude Code | `~/.claude/projects/` |
196
- | Codex | `~/.codex/sessions/` |
195
+ | Claude Code | `~/.claude/projects/` · `~/.claude/CLAUDE.md` |
196
+ | Codex | `~/.codex/sessions/` · `~/.codex/AGENTS.md` |
197
+
198
+ Transcripts record credentials that were already sent. Instruction and memory
199
+ files are worse: they are read again at the start of the next session, so a
200
+ credential in one keeps leaking until the file is edited. Both are scanned, and
201
+ `wipe` rewrites both.
197
202
 
198
203
  Nowhere else. It does not read your repos, your shell history, or your
199
204
  environment. Adding support for another agent is one small file — pull requests
package/dist/cli.js CHANGED
@@ -52,9 +52,20 @@ function home() {
52
52
  }
53
53
 
54
54
  // src/report/paths.ts
55
+ import { realpathSync } from "fs";
55
56
  function shorten(file) {
56
57
  const home2 = process.env.HOME;
57
- return home2 !== void 0 && file.startsWith(home2) ? `~${file.slice(home2.length)}` : file;
58
+ if (home2 === void 0) return file;
59
+ if (file.startsWith(home2)) return `~${file.slice(home2.length)}`;
60
+ const real = realHome(home2);
61
+ return file.startsWith(real) ? `~${file.slice(real.length)}` : file;
62
+ }
63
+ function realHome(home2) {
64
+ try {
65
+ return realpathSync(home2);
66
+ } catch {
67
+ return home2;
68
+ }
58
69
  }
59
70
  function plural(count2, noun) {
60
71
  return `${count2.toLocaleString("en-US")} ${noun}${count2 === 1 ? "" : "s"}`;
@@ -71,12 +82,12 @@ var Progress = class {
71
82
  this.#write = write;
72
83
  this.#enabled = enabled2;
73
84
  }
74
- advance(transcripts, project) {
85
+ advance(files, project) {
75
86
  if (!this.#enabled) return;
76
- if (transcripts % 8 !== 0) return;
87
+ if (files % 8 !== 0) return;
77
88
  const spinner = SPINNER[this.#frame % SPINNER.length] ?? "|";
78
89
  this.#frame++;
79
- this.#write(`\r${CLEAR_LINE} ${spinner} reading ${transcripts} transcripts \u2014 ${clip(project)}`);
90
+ this.#write(`\r${CLEAR_LINE} ${spinner} reading ${files} files \u2014 ${clip(project)}`);
80
91
  }
81
92
  done() {
82
93
  if (this.#enabled) this.#write(`\r${CLEAR_LINE}`);
@@ -86,6 +97,9 @@ function clip(project) {
86
97
  return project.length > 40 ? `${project.slice(0, 39)}\u2026` : project;
87
98
  }
88
99
 
100
+ // src/sources/source.ts
101
+ var FileKind = { transcript: "transcript", memory: "memory" };
102
+
89
103
  // src/report/aggregate.ts
90
104
  function aggregate(findings) {
91
105
  const byRule = /* @__PURE__ */ new Map();
@@ -94,21 +108,22 @@ function aggregate(findings) {
94
108
  totals.set(finding.rule, (totals.get(finding.rule) ?? 0) + 1);
95
109
  const sites = byRule.get(finding.rule) ?? /* @__PURE__ */ new Map();
96
110
  byRule.set(finding.rule, sites);
97
- const key = `${finding.fingerprint}:${finding.project}`;
111
+ const where2 = finding.kind === FileKind.memory ? finding.file : finding.project;
112
+ const key = `${finding.fingerprint}:${where2}:${finding.kind}`;
98
113
  const site = sites.get(key);
99
114
  if (site) {
100
115
  site.occurrences++;
101
- site.firstEntryIndex = Math.min(site.firstEntryIndex, finding.entryIndex);
116
+ site.firstAt = Math.min(site.firstAt, finding.at);
102
117
  continue;
103
118
  }
104
119
  sites.set(key, {
105
120
  fingerprint: finding.fingerprint,
121
+ kind: finding.kind,
106
122
  project: finding.project,
107
- sessionId: finding.sessionId,
108
123
  file: finding.file,
109
124
  context: finding.context,
110
125
  occurrences: 1,
111
- firstEntryIndex: finding.entryIndex
126
+ firstAt: finding.at
112
127
  });
113
128
  }
114
129
  return [...byRule.entries()].map(([rule, sites]) => ({
@@ -135,9 +150,12 @@ var TerminalReporter = class {
135
150
  }
136
151
  report(findings, stats) {
137
152
  const totals = aggregate(findings);
138
- const secrets = totals.reduce((sum, total) => sum + total.distinct, 0);
153
+ const sent = distinct(totals, FileKind.transcript);
154
+ const onDisk = distinct(totals, FileKind.memory);
139
155
  this.#write(
140
- dim(` scanned ${count(stats.transcripts)} transcripts \xB7 ${count(stats.entries)} messages`)
156
+ dim(
157
+ ` scanned ${count(stats.transcripts)} transcripts \xB7 ${count(stats.entries)} messages \xB7 ${count(stats.memoryFiles)} memory files`
158
+ )
141
159
  );
142
160
  this.#write("");
143
161
  if (totals.length === 0) {
@@ -149,16 +167,24 @@ var TerminalReporter = class {
149
167
  for (const total of totals) {
150
168
  this.#write(`${red("*")} ${bold(total.rule)} ${dim(plural(total.distinct, "secret"))}`);
151
169
  for (const site of total.sites) {
152
- const times = site.occurrences === 1 ? "sent once" : `sent ${count(site.occurrences)} times`;
170
+ const times = occurrences(site);
153
171
  this.#write(
154
172
  ` ${yellow(site.fingerprint.slice(0, FINGERPRINT_CHARS))} ${site.project.padEnd(PROJECT_COLUMN)} ${dim(times)}`
155
173
  );
156
174
  if (site.context !== "") this.#write(` ${dim(`${site.context}***`)}`);
157
- this.#write(` ${dim(shorten(site.file))}`);
175
+ this.#write(` ${dim(where(site))}`);
158
176
  }
159
177
  this.#write("");
160
178
  }
161
- this.#write(bold(red(`${plural(secrets, "credential")} sent to a model provider.`)));
179
+ this.#write(bold(red(`${plural(sent, "credential")} sent to a model provider.`)));
180
+ if (onDisk > 0) {
181
+ this.#write(
182
+ bold(red(`${plural(onDisk, "credential")} still on disk, in files your agent reads.`))
183
+ );
184
+ this.#write(
185
+ red("Remove them there as well as rotating, or the next session sends them again.")
186
+ );
187
+ }
162
188
  if (stats.unscannable > 0) {
163
189
  this.#write(
164
190
  yellow(`${plural(stats.unscannable, "message")} could not be scanned and are not counted.`)
@@ -175,6 +201,22 @@ var TerminalReporter = class {
175
201
  function count(value) {
176
202
  return value.toLocaleString("en-US");
177
203
  }
204
+ function occurrences(site) {
205
+ if (site.kind === FileKind.memory) {
206
+ return site.occurrences === 1 ? "on one line" : `on ${count(site.occurrences)} lines`;
207
+ }
208
+ return site.occurrences === 1 ? "sent once" : `sent ${count(site.occurrences)} times`;
209
+ }
210
+ function where(site) {
211
+ const path = shorten(site.file);
212
+ return site.kind === FileKind.memory ? `${path}:${site.firstAt}` : path;
213
+ }
214
+ function distinct(totals, kind) {
215
+ return totals.reduce((sum, total) => {
216
+ const matching = total.sites.filter((site) => site.kind === kind);
217
+ return sum + new Set(matching.map((site) => site.fingerprint)).size;
218
+ }, 0);
219
+ }
178
220
 
179
221
  // src/scan/keyword-index.ts
180
222
  var WINDOW_CHARS = 512;
@@ -2319,16 +2361,36 @@ var SECRET_RULE_SOURCES = [
2319
2361
  }
2320
2362
  ];
2321
2363
 
2322
- // src/transcript/reader.ts
2364
+ // src/memory/reader.ts
2323
2365
  import { createReadStream } from "fs";
2324
2366
  import { createInterface } from "readline";
2367
+ async function* readLines(path) {
2368
+ const stream = createReadStream(path, "utf8");
2369
+ stream.on("error", () => {
2370
+ });
2371
+ const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
2372
+ let number = 0;
2373
+ try {
2374
+ for await (const text of lines) {
2375
+ number++;
2376
+ if (text.trim().length === 0) continue;
2377
+ yield { number, text };
2378
+ }
2379
+ } catch {
2380
+ return;
2381
+ }
2382
+ }
2383
+
2384
+ // src/transcript/reader.ts
2385
+ import { createReadStream as createReadStream2 } from "fs";
2386
+ import { createInterface as createInterface2 } from "readline";
2325
2387
  var TranscriptReader = class {
2326
2388
  stats = { entries: 0, skipped: 0 };
2327
2389
  async *read(path) {
2328
- const stream = createReadStream(path, "utf8");
2390
+ const stream = createReadStream2(path, "utf8");
2329
2391
  stream.on("error", () => {
2330
2392
  });
2331
- const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
2393
+ const lines = createInterface2({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
2332
2394
  let index = -1;
2333
2395
  try {
2334
2396
  for await (const line of lines) {
@@ -2366,48 +2428,65 @@ var ScanRun = class {
2366
2428
  * re-deriving it from the reporter's output. */
2367
2429
  async run() {
2368
2430
  const findings = [];
2369
- const stats = { transcripts: 0, entries: 0, skipped: 0, unscannable: 0 };
2431
+ const stats = {
2432
+ transcripts: 0,
2433
+ memoryFiles: 0,
2434
+ entries: 0,
2435
+ skipped: 0,
2436
+ unscannable: 0
2437
+ };
2370
2438
  for (const source of this.#sources) {
2371
2439
  for await (const file of source.discover()) {
2372
- stats.transcripts++;
2373
- this.#progress.advance(stats.transcripts, file.project);
2374
- const reader = new TranscriptReader();
2375
- for await (const entry of reader.read(file.path)) {
2376
- let matches = [];
2377
- try {
2378
- matches = this.#scanner.scan(entry.line);
2379
- } catch {
2380
- stats.unscannable++;
2381
- continue;
2440
+ if (file.kind === FileKind.transcript) {
2441
+ stats.transcripts++;
2442
+ const reader = new TranscriptReader();
2443
+ for await (const entry of reader.read(file.path)) {
2444
+ this.#collect(file, entry.line, entry.index, findings, stats);
2382
2445
  }
2383
- for (const match of matches) {
2384
- findings.push({
2385
- rule: match.rule,
2386
- fingerprint: match.fingerprint,
2387
- context: match.context,
2388
- source: file.source,
2389
- project: file.project,
2390
- sessionId: file.sessionId,
2391
- file: file.path,
2392
- entryIndex: entry.index
2393
- });
2446
+ stats.entries += reader.stats.entries;
2447
+ stats.skipped += reader.stats.skipped;
2448
+ } else {
2449
+ stats.memoryFiles++;
2450
+ for await (const line of readLines(file.path)) {
2451
+ this.#collect(file, line.text, line.number, findings, stats);
2394
2452
  }
2395
2453
  }
2396
- stats.entries += reader.stats.entries;
2397
- stats.skipped += reader.stats.skipped;
2454
+ this.#progress.advance(stats.transcripts + stats.memoryFiles, file.project);
2398
2455
  }
2399
2456
  }
2400
2457
  this.#progress.done();
2401
2458
  this.#reporter.report(findings, stats);
2402
2459
  return findings.length;
2403
2460
  }
2461
+ /** One entry the engine chokes on must not cost the other 700,000. */
2462
+ #collect(file, text, at, findings, stats) {
2463
+ let matches = [];
2464
+ try {
2465
+ matches = this.#scanner.scan(text);
2466
+ } catch {
2467
+ stats.unscannable++;
2468
+ return;
2469
+ }
2470
+ for (const match of matches) {
2471
+ findings.push({
2472
+ rule: match.rule,
2473
+ fingerprint: match.fingerprint,
2474
+ context: match.context,
2475
+ source: file.source,
2476
+ kind: file.kind,
2477
+ project: file.project,
2478
+ file: file.path,
2479
+ at
2480
+ });
2481
+ }
2482
+ }
2404
2483
  };
2405
2484
 
2406
2485
  // src/sources/claude-code-source.ts
2407
2486
  import { basename, join as join2 } from "path";
2408
2487
 
2409
2488
  // src/sources/listing.ts
2410
- import { readdir } from "fs/promises";
2489
+ import { readdir, realpath, stat } from "fs/promises";
2411
2490
  import { join } from "path";
2412
2491
  async function listDirectories(root) {
2413
2492
  try {
@@ -2417,14 +2496,23 @@ async function listDirectories(root) {
2417
2496
  return [];
2418
2497
  }
2419
2498
  }
2420
- async function listJsonl(dir) {
2499
+ var FileExtension = { jsonl: ".jsonl", markdown: ".md" };
2500
+ async function listFiles(dir, extension) {
2421
2501
  try {
2422
2502
  const entries = await readdir(dir, { withFileTypes: true });
2423
- return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")).map((entry) => entry.name);
2503
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(extension)).map((entry) => entry.name);
2424
2504
  } catch {
2425
2505
  return [];
2426
2506
  }
2427
2507
  }
2508
+ async function resolveFile(path) {
2509
+ try {
2510
+ const real = await realpath(path);
2511
+ return (await stat(real)).isFile() ? real : null;
2512
+ } catch {
2513
+ return null;
2514
+ }
2515
+ }
2428
2516
  async function walkDirectories(root) {
2429
2517
  const found = [root];
2430
2518
  for (const name of await listDirectories(root)) {
@@ -2460,28 +2548,44 @@ var ClaudeCodeSource = class {
2460
2548
  this.#home = home2;
2461
2549
  }
2462
2550
  async *discover() {
2551
+ const instructions = await resolveFile(join2(this.#home, ".claude", "CLAUDE.md"));
2552
+ if (instructions !== null) {
2553
+ yield { source: this.name, kind: FileKind.memory, path: instructions, project: this.name };
2554
+ }
2463
2555
  const root = join2(this.#home, ".claude", "projects");
2464
2556
  for (const slug of await listDirectories(root)) {
2465
2557
  const dir = join2(root, slug);
2466
- for (const file of await listJsonl(dir)) {
2558
+ let project = slug;
2559
+ for (const file of await listFiles(dir, FileExtension.jsonl)) {
2467
2560
  const path = join2(dir, file);
2468
- const meta = await firstMeta(path);
2561
+ const cwd = await firstCwd(path);
2562
+ if (cwd !== null) project = basename(cwd);
2469
2563
  yield {
2470
2564
  source: this.name,
2565
+ kind: FileKind.transcript,
2471
2566
  path,
2472
- sessionId: meta === null ? basename(file, ".jsonl") : meta.sessionId,
2473
- project: meta === null ? slug : basename(meta.cwd)
2567
+ project: cwd === null ? slug : basename(cwd)
2474
2568
  };
2475
2569
  }
2570
+ for (const nested of await walkDirectories(join2(dir, "memory"))) {
2571
+ for (const file of await listFiles(nested, FileExtension.markdown)) {
2572
+ yield {
2573
+ source: this.name,
2574
+ kind: FileKind.memory,
2575
+ path: join2(nested, file),
2576
+ project
2577
+ };
2578
+ }
2579
+ }
2476
2580
  }
2477
2581
  }
2478
2582
  };
2479
- async function firstMeta(path) {
2583
+ async function firstCwd(path) {
2480
2584
  const reader = new TranscriptReader();
2481
2585
  for await (const entry of reader.read(path)) {
2482
2586
  const sessionId = stringField(entry.payload, "sessionId");
2483
2587
  const cwd = stringField(entry.payload, "cwd");
2484
- if (sessionId !== null && cwd !== null) return { sessionId, cwd };
2588
+ if (sessionId !== null && cwd !== null) return cwd;
2485
2589
  }
2486
2590
  return null;
2487
2591
  }
@@ -2495,40 +2599,44 @@ var CodexSource = class {
2495
2599
  this.#home = home2;
2496
2600
  }
2497
2601
  async *discover() {
2602
+ const instructions = await resolveFile(join3(this.#home, ".codex", "AGENTS.md"));
2603
+ if (instructions !== null) {
2604
+ yield { source: this.name, kind: FileKind.memory, path: instructions, project: this.name };
2605
+ }
2498
2606
  const root = join3(this.#home, ".codex", "sessions");
2499
2607
  for (const dir of await walkDirectories(root)) {
2500
- for (const file of await listJsonl(dir)) {
2608
+ for (const file of await listFiles(dir, FileExtension.jsonl)) {
2501
2609
  const path = join3(dir, file);
2502
- const meta = await firstMeta2(path);
2610
+ const cwd = await firstCwd2(path);
2503
2611
  yield {
2504
2612
  source: this.name,
2613
+ kind: FileKind.transcript,
2505
2614
  path,
2506
- sessionId: meta === null ? basename2(file, ".jsonl") : meta.id,
2507
- project: meta === null ? basename2(dir) : basename2(meta.cwd)
2615
+ project: cwd === null ? basename2(dir) : basename2(cwd)
2508
2616
  };
2509
2617
  }
2510
2618
  }
2511
2619
  }
2512
2620
  };
2513
- async function firstMeta2(path) {
2621
+ async function firstCwd2(path) {
2514
2622
  const reader = new TranscriptReader();
2515
2623
  for await (const entry of reader.read(path)) {
2516
2624
  if (stringField(entry.payload, "type") !== "session_meta") continue;
2517
2625
  const payload = objectField(entry.payload, "payload");
2518
2626
  const id = stringField(payload, "id");
2519
2627
  const cwd = stringField(payload, "cwd");
2520
- if (id !== null && cwd !== null) return { id, cwd };
2628
+ if (id !== null && cwd !== null) return cwd;
2521
2629
  }
2522
2630
  return null;
2523
2631
  }
2524
2632
 
2525
2633
  // src/wipe/confirm.ts
2526
- import { createInterface as createInterface2 } from "readline/promises";
2634
+ import { createInterface as createInterface3 } from "readline/promises";
2527
2635
  var CONFIRMATION = "wipe";
2528
2636
  var TerminalPrompt = class {
2529
2637
  async ask(question) {
2530
2638
  if (process.stdin.isTTY !== true) return "";
2531
- const readline = createInterface2({ input: process.stdin, output: process.stdout });
2639
+ const readline = createInterface3({ input: process.stdin, output: process.stdout });
2532
2640
  try {
2533
2641
  return (await readline.question(question)).trim();
2534
2642
  } finally {
@@ -2538,7 +2646,7 @@ var TerminalPrompt = class {
2538
2646
  };
2539
2647
 
2540
2648
  // src/wipe/wipe-run.ts
2541
- import { readFile, rename, writeFile } from "fs/promises";
2649
+ import { readFile, rename, stat as stat2, writeFile } from "fs/promises";
2542
2650
 
2543
2651
  // src/wipe/redact.ts
2544
2652
  function placeholder(rule) {
@@ -2588,20 +2696,31 @@ var WipeRun = class {
2588
2696
  const plans = [];
2589
2697
  for (const source of this.#sources) {
2590
2698
  for await (const file of source.discover()) {
2591
- const reader = new TranscriptReader();
2592
2699
  let lines = 0;
2593
2700
  let secrets = 0;
2594
- for await (const entry of reader.read(file.path)) {
2595
- const found = this.#scanner.secretsOf(entry.line);
2701
+ for await (const text of this.#textOf(file)) {
2702
+ const found = this.#scanner.secretsOf(text);
2596
2703
  if (found.size === 0) continue;
2597
2704
  lines++;
2598
2705
  secrets += found.size;
2599
2706
  }
2600
- if (lines > 0) plans.push({ file: file.path, project: file.project, lines, secrets });
2707
+ if (lines > 0) {
2708
+ plans.push({ file: file.path, project: file.project, kind: file.kind, lines, secrets });
2709
+ }
2601
2710
  }
2602
2711
  }
2603
2712
  return plans;
2604
2713
  }
2714
+ /** The rewrite below is textual and kind-blind; only counting needs to know
2715
+ * which reader gets the file. */
2716
+ async *#textOf(file) {
2717
+ if (file.kind === FileKind.memory) {
2718
+ for await (const line of readLines(file.path)) yield line.text;
2719
+ return;
2720
+ }
2721
+ const reader = new TranscriptReader();
2722
+ for await (const entry of reader.read(file.path)) yield entry.line;
2723
+ }
2605
2724
  /** Rewrites through a temporary file and a rename, so an interrupted run
2606
2725
  * leaves the original intact rather than half a transcript. */
2607
2726
  async #rewrite(file) {
@@ -2615,8 +2734,9 @@ var WipeRun = class {
2615
2734
  redacted += secrets.size;
2616
2735
  return redactLine(line, secrets);
2617
2736
  });
2737
+ const { mode } = await stat2(file);
2618
2738
  const temporary = `${file}.whatileaked-tmp`;
2619
- await writeFile(temporary, rewritten.join("\n"), "utf8");
2739
+ await writeFile(temporary, rewritten.join("\n"), { encoding: "utf8", mode });
2620
2740
  await rename(temporary, file);
2621
2741
  return redacted;
2622
2742
  }
@@ -2626,8 +2746,9 @@ var WipeRun = class {
2626
2746
  this.#write(dim(` ${plural(secrets, "secret")} across ${plural(plans.length, "file")}`));
2627
2747
  this.#write("");
2628
2748
  for (const plan of plans) {
2749
+ const unit = plan.kind === FileKind.memory ? "line" : "message";
2629
2750
  this.#write(
2630
- ` ${yellow("*")} ${bold(plan.project.padEnd(project))} ${dim(`${plural(plan.secrets, "secret")} in ${plural(plan.lines, "message")}`)}`
2751
+ ` ${yellow("*")} ${bold(plan.project.padEnd(project))} ${dim(`${plural(plan.secrets, "secret")} in ${plural(plan.lines, unit)}`)}`
2631
2752
  );
2632
2753
  this.#write(` ${dim(shorten(plan.file))}`);
2633
2754
  }
@@ -2661,6 +2782,9 @@ function colorful() {
2661
2782
  return process.stdout.isTTY === true && process.env.NO_COLOR === void 0;
2662
2783
  }
2663
2784
  async function scan(sources, scanner, write) {
2785
+ write(` ${dim("Welcome to whatileaked \u2014 built by Selan")} ${cyan("(selan.ai)")}`);
2786
+ write(dim(" checking what you have already sent to a model provider"));
2787
+ write("");
2664
2788
  const progress = new Progress((text) => process.stderr.write(text), colorful());
2665
2789
  return await new ScanRun(sources, scanner, new TerminalReporter(write), progress).run();
2666
2790
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatileaked",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Scan your local Claude Code and Codex transcripts for credentials you already sent to a model provider.",
5
5
  "type": "module",
6
6
  "imports": {
@@ -21,6 +21,7 @@
21
21
  "scripts": {
22
22
  "dev": "node src/cli.ts",
23
23
  "build": "tsc --noEmit && tsup",
24
+ "prepublishOnly": "npm run lint && npm test && npm run build",
24
25
  "typecheck": "tsc --noEmit",
25
26
  "test": "node --test \"src/**/*.test.ts\"",
26
27
  "format": "biome check --write",