whatileaked 0.4.0 → 0.5.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 +19 -11
  2. package/dist/cli.js +203 -48
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -205,18 +205,26 @@ npx whatileaked scan || echo "found something"
205
205
 
206
206
  | Agent | Folder |
207
207
  | --- | --- |
208
- | Claude Code | `~/.claude/projects/` · `~/.claude/CLAUDE.md` |
209
- | Codex | `~/.codex/sessions/` · `~/.codex/archived_sessions/` · `~/.codex/AGENTS.md` |
208
+ | Claude Code | `~/.claude/projects/` · `~/.claude/history.jsonl` · `~/.claude/paste-cache/` · `~/.claude/CLAUDE.md` |
209
+ | Codex | `~/.codex/sessions/` · `~/.codex/archived_sessions/` · `~/.codex/history.jsonl` · `~/.codex/AGENTS.md` |
210
210
  | Cursor | `~/.cursor/projects/*/agent-transcripts/` |
211
-
212
- Cursor keeps its older Ask-mode chats in SQLite rather than jsonl, and those are
213
- not scanned. Reading them needs `node:sqlite`, which arrived in Node 22.5, and
214
- this runs on Node 20. Agent transcripts are covered; Ask history is not.
215
-
216
- Transcripts record credentials that were already sent. Instruction and memory
217
- files are worse: they are read again at the start of the next session, so a
218
- credential in one keeps leaking until the file is edited. Both are scanned, and
219
- `wipe` rewrites both.
211
+ | opencode | `~/.local/share/opencode/opencode.db` |
212
+
213
+ opencode keeps its sessions in a SQLite database. Reading it needs Node's
214
+ built-in `node:sqlite`, which loads without a flag from Node 22.13. On an older
215
+ Node the scan still finds the file and says, in one line, that it was not
216
+ scanned, so a skipped database never looks clean. Only the messages table is
217
+ read, and `wipe` does not rewrite the database: delete those sessions in
218
+ opencode, and rotate.
219
+
220
+ Cursor keeps its older Ask-mode chats in SQLite as well, and those are not
221
+ scanned yet. Agent transcripts are covered; Ask history is not.
222
+
223
+ Transcripts record credentials that were already sent. So does prompt history,
224
+ which keeps every prompt you typed and every long paste after the session itself
225
+ has been cleaned up. Instruction and memory files are worse: they are read again
226
+ at the start of the next session, so a credential in one keeps leaking until the
227
+ file is edited. All of them are scanned, and `wipe` rewrites all of them.
220
228
 
221
229
  Nowhere else. It does not read your repos, your shell history, or your
222
230
  environment. Adding support for another agent is one small file — pull requests
package/dist/cli.js CHANGED
@@ -161,6 +161,8 @@ var TerminalReporter = class {
161
161
  if (totals.length === 0) {
162
162
  this.#write(green("No credentials found."));
163
163
  this.#write("");
164
+ this.#unread(stats);
165
+ if (stats.unread.length > 0) this.#write("");
164
166
  this.#write(dim(DISCLOSURE));
165
167
  this.#share(sent, stats);
166
168
  return;
@@ -191,6 +193,7 @@ var TerminalReporter = class {
191
193
  yellow(`${plural(stats.unscannable, "message")} could not be scanned and are not counted.`)
192
194
  );
193
195
  }
196
+ this.#unread(stats);
194
197
  this.#write("");
195
198
  this.#write(dim("The 8-character code is a fingerprint, never the secret: a truncated"));
196
199
  this.#write(dim("SHA-256, so one credential shows the same code everywhere it leaked."));
@@ -199,6 +202,13 @@ var TerminalReporter = class {
199
202
  this.#write(dim(DISCLOSURE));
200
203
  this.#share(sent, stats);
201
204
  }
205
+ /** One plain line per file found and not read. Printed under a clean result
206
+ * too, which is exactly where leaving it out would mislead. */
207
+ #unread(stats) {
208
+ for (const file of stats.unread) {
209
+ this.#write(yellow(`Found ${shorten(file.path)} but did not scan it: ${file.reason}.`));
210
+ }
211
+ }
202
212
  /**
203
213
  * The one line meant to be pasted somewhere public. Everything above it names
204
214
  * projects, paths and fingerprints, so nobody shares it; this is counts only,
@@ -2398,6 +2408,50 @@ async function* readLines(path) {
2398
2408
  }
2399
2409
  }
2400
2410
 
2411
+ // src/sources/listing.ts
2412
+ import { readdir, realpath, stat } from "fs/promises";
2413
+ import { join } from "path";
2414
+ async function listDirectories(root) {
2415
+ try {
2416
+ const entries = await readdir(root, { withFileTypes: true });
2417
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2418
+ } catch {
2419
+ return [];
2420
+ }
2421
+ }
2422
+ var FileExtension = {
2423
+ jsonl: ".jsonl",
2424
+ markdown: ".md",
2425
+ text: ".txt",
2426
+ database: ".db"
2427
+ };
2428
+ function isJsonl(path) {
2429
+ return path.endsWith(FileExtension.jsonl);
2430
+ }
2431
+ async function listFiles(dir, extension) {
2432
+ try {
2433
+ const entries = await readdir(dir, { withFileTypes: true });
2434
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(extension)).map((entry) => entry.name);
2435
+ } catch {
2436
+ return [];
2437
+ }
2438
+ }
2439
+ async function resolveFile(path) {
2440
+ try {
2441
+ const real = await realpath(path);
2442
+ return (await stat(real)).isFile() ? real : null;
2443
+ } catch {
2444
+ return null;
2445
+ }
2446
+ }
2447
+ async function walkDirectories(root) {
2448
+ const found = [root];
2449
+ for (const name of await listDirectories(root)) {
2450
+ found.push(...await walkDirectories(join(root, name)));
2451
+ }
2452
+ return found;
2453
+ }
2454
+
2401
2455
  // src/transcript/reader.ts
2402
2456
  import { createReadStream as createReadStream2 } from "fs";
2403
2457
  import { createInterface as createInterface2 } from "readline";
@@ -2430,6 +2484,7 @@ var TranscriptReader = class {
2430
2484
  };
2431
2485
 
2432
2486
  // src/scan-run.ts
2487
+ var NEEDS_SQLITE = "reading it needs Node 22.13 or newer";
2433
2488
  var ScanRun = class {
2434
2489
  #sources;
2435
2490
  #scanner;
@@ -2450,12 +2505,19 @@ var ScanRun = class {
2450
2505
  memoryFiles: 0,
2451
2506
  entries: 0,
2452
2507
  skipped: 0,
2453
- unscannable: 0
2508
+ unscannable: 0,
2509
+ unread: []
2454
2510
  };
2455
2511
  for (const source of this.#sources) {
2456
2512
  for await (const file of source.discover()) {
2457
- if (file.kind === FileKind.transcript) {
2458
- stats.transcripts++;
2513
+ if (file.messages !== void 0) {
2514
+ await this.#readDatabase(file, file.messages, findings, stats);
2515
+ this.#progress.advance(stats.transcripts + stats.memoryFiles, file.project);
2516
+ continue;
2517
+ }
2518
+ if (file.kind === FileKind.transcript) stats.transcripts++;
2519
+ else stats.memoryFiles++;
2520
+ if (isJsonl(file.path)) {
2459
2521
  const reader = new TranscriptReader();
2460
2522
  for await (const entry of reader.read(file.path)) {
2461
2523
  this.#collect(file, entry.line, entry.index, findings, stats);
@@ -2463,7 +2525,7 @@ var ScanRun = class {
2463
2525
  stats.entries += reader.stats.entries;
2464
2526
  stats.skipped += reader.stats.skipped;
2465
2527
  } else {
2466
- stats.memoryFiles++;
2528
+ if (file.kind === FileKind.transcript) stats.entries++;
2467
2529
  for await (const line of readLines(file.path)) {
2468
2530
  this.#collect(file, line.text, line.number, findings, stats);
2469
2531
  }
@@ -2475,6 +2537,24 @@ var ScanRun = class {
2475
2537
  this.#reporter.report(findings, stats);
2476
2538
  return findings.length;
2477
2539
  }
2540
+ /** A database counts as one transcript once it has been read. One it could
2541
+ * not open is listed instead, so a scan that skipped it never reads as clean. */
2542
+ async #readDatabase(file, messages, findings, stats) {
2543
+ if (messages === null) {
2544
+ stats.unread.push({ path: file.path, reason: NEEDS_SQLITE });
2545
+ return;
2546
+ }
2547
+ let index = 0;
2548
+ try {
2549
+ for await (const message of messages()) {
2550
+ stats.entries++;
2551
+ this.#collect({ ...file, project: message.project }, message.text, index++, findings, stats);
2552
+ }
2553
+ stats.transcripts++;
2554
+ } catch {
2555
+ stats.unread.push({ path: file.path, reason: "it could not be opened" });
2556
+ }
2557
+ }
2478
2558
  /** One entry the engine chokes on must not cost the other 700,000. */
2479
2559
  #collect(file, text, at, findings, stats) {
2480
2560
  let matches = [];
@@ -2502,47 +2582,12 @@ var ScanRun = class {
2502
2582
  // src/sources/claude-code-source.ts
2503
2583
  import { basename, join as join2 } from "path";
2504
2584
 
2505
- // src/sources/listing.ts
2506
- import { readdir, realpath, stat } from "fs/promises";
2507
- import { join } from "path";
2508
- async function listDirectories(root) {
2509
- try {
2510
- const entries = await readdir(root, { withFileTypes: true });
2511
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2512
- } catch {
2513
- return [];
2514
- }
2515
- }
2516
- var FileExtension = { jsonl: ".jsonl", markdown: ".md" };
2517
- async function listFiles(dir, extension) {
2518
- try {
2519
- const entries = await readdir(dir, { withFileTypes: true });
2520
- return entries.filter((entry) => entry.isFile() && entry.name.endsWith(extension)).map((entry) => entry.name);
2521
- } catch {
2522
- return [];
2523
- }
2524
- }
2525
- async function resolveFile(path) {
2526
- try {
2527
- const real = await realpath(path);
2528
- return (await stat(real)).isFile() ? real : null;
2529
- } catch {
2530
- return null;
2531
- }
2532
- }
2533
- async function walkDirectories(root) {
2534
- const found = [root];
2535
- for (const name of await listDirectories(root)) {
2536
- found.push(...await walkDirectories(join(root, name)));
2537
- }
2538
- return found;
2539
- }
2540
-
2541
2585
  // src/transcript/entry.ts
2542
2586
  var SourceName = {
2543
2587
  "claude-code": "claude-code",
2544
2588
  codex: "codex",
2545
- cursor: "cursor"
2589
+ cursor: "cursor",
2590
+ opencode: "opencode"
2546
2591
  };
2547
2592
 
2548
2593
  // src/transcript/shape.ts
@@ -2570,6 +2615,19 @@ var ClaudeCodeSource = class {
2570
2615
  if (instructions !== null) {
2571
2616
  yield { source: this.name, kind: FileKind.memory, path: instructions, project: this.name };
2572
2617
  }
2618
+ const history = await resolveFile(join2(this.#home, ".claude", "history.jsonl"));
2619
+ if (history !== null) {
2620
+ yield { source: this.name, kind: FileKind.transcript, path: history, project: this.name };
2621
+ }
2622
+ const pastes = join2(this.#home, ".claude", "paste-cache");
2623
+ for (const file of await listFiles(pastes, FileExtension.text)) {
2624
+ yield {
2625
+ source: this.name,
2626
+ kind: FileKind.transcript,
2627
+ path: join2(pastes, file),
2628
+ project: this.name
2629
+ };
2630
+ }
2573
2631
  const root = join2(this.#home, ".claude", "projects");
2574
2632
  for (const slug of await listDirectories(root)) {
2575
2633
  const dir = join2(root, slug);
@@ -2623,6 +2681,10 @@ var CodexSource = class {
2623
2681
  if (instructions !== null) {
2624
2682
  yield { source: this.name, kind: FileKind.memory, path: instructions, project: this.name };
2625
2683
  }
2684
+ const history = await resolveFile(join3(this.#home, ".codex", "history.jsonl"));
2685
+ if (history !== null) {
2686
+ yield { source: this.name, kind: FileKind.transcript, path: history, project: this.name };
2687
+ }
2626
2688
  for (const root of ["sessions", "archived_sessions"]) {
2627
2689
  for (const dir of await walkDirectories(join3(this.#home, ".codex", root))) {
2628
2690
  for (const file of await listFiles(dir, FileExtension.jsonl)) {
@@ -2706,6 +2768,73 @@ async function isDirectory(path) {
2706
2768
  }
2707
2769
  }
2708
2770
 
2771
+ // src/sources/opencode-source.ts
2772
+ import { basename as basename4, join as join5 } from "path";
2773
+ var OpencodeSource = class {
2774
+ name = SourceName.opencode;
2775
+ #home;
2776
+ #load;
2777
+ constructor(home2, load = loadSqlite) {
2778
+ this.#home = home2;
2779
+ this.#load = load;
2780
+ }
2781
+ async *discover() {
2782
+ const dir = join5(this.#home, ".local", "share", "opencode");
2783
+ for (const file of await listFiles(dir, FileExtension.database)) {
2784
+ const path = join5(dir, file);
2785
+ const sqlite = await this.#load();
2786
+ yield {
2787
+ source: this.name,
2788
+ kind: FileKind.transcript,
2789
+ path,
2790
+ project: this.name,
2791
+ messages: sqlite === null ? null : () => readMessages(sqlite, path, this.name)
2792
+ };
2793
+ }
2794
+ }
2795
+ };
2796
+ async function* readMessages(sqlite, path, fallback) {
2797
+ const db = new sqlite.DatabaseSync(path, { readOnly: true });
2798
+ try {
2799
+ const directories = /* @__PURE__ */ new Map();
2800
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (?, ?)").all("session", "session_v2");
2801
+ for (const { name } of tables) {
2802
+ for (const row of db.prepare(`SELECT id, directory FROM "${String(name)}"`).iterate()) {
2803
+ if (typeof row.id === "string" && typeof row.directory === "string") {
2804
+ directories.set(row.id, row.directory);
2805
+ }
2806
+ }
2807
+ }
2808
+ for (const row of db.prepare("SELECT session_id, data FROM session_message").iterate()) {
2809
+ if (typeof row.data !== "string") continue;
2810
+ const directory = typeof row.session_id === "string" ? directories.get(row.session_id) : null;
2811
+ yield { project: directory == null ? fallback : basename4(directory), text: row.data };
2812
+ }
2813
+ } finally {
2814
+ db.close();
2815
+ }
2816
+ }
2817
+ var loading = null;
2818
+ function loadSqlite() {
2819
+ loading ??= importSqlite();
2820
+ return loading;
2821
+ }
2822
+ async function importSqlite() {
2823
+ const emitWarning = process.emitWarning;
2824
+ process.emitWarning = ((warning, ...rest) => {
2825
+ if (String(warning).includes("SQLite")) return;
2826
+ Reflect.apply(emitWarning, process, [warning, ...rest]);
2827
+ });
2828
+ try {
2829
+ const specifier = "node:sqlite";
2830
+ return await import(specifier);
2831
+ } catch {
2832
+ return null;
2833
+ } finally {
2834
+ process.emitWarning = emitWarning;
2835
+ }
2836
+ }
2837
+
2709
2838
  // src/wipe/confirm.ts
2710
2839
  import { createInterface as createInterface3 } from "readline/promises";
2711
2840
  var CONFIRMATION = "wipe";
@@ -2749,16 +2878,16 @@ var WipeRun = class {
2749
2878
  this.#write = write;
2750
2879
  }
2751
2880
  async run() {
2752
- const plans = await this.#plan();
2881
+ const { plans, untouched } = await this.#plan();
2753
2882
  if (plans.length === 0) {
2754
- return { planned: [], filesRewritten: 0, secretsRedacted: 0, confirmed: false };
2883
+ return { planned: [], untouched, filesRewritten: 0, secretsRedacted: 0, confirmed: false };
2755
2884
  }
2756
2885
  this.#preview(plans);
2757
2886
  const answer = await this.#prompt.ask(
2758
2887
  ` Type ${bold(red(CONFIRMATION))} to rewrite these files, or anything else to stop: `
2759
2888
  );
2760
2889
  if (answer !== CONFIRMATION) {
2761
- return { planned: plans, filesRewritten: 0, secretsRedacted: 0, confirmed: false };
2890
+ return { planned: plans, untouched, filesRewritten: 0, secretsRedacted: 0, confirmed: false };
2762
2891
  }
2763
2892
  let filesRewritten = 0;
2764
2893
  let secretsRedacted = 0;
@@ -2766,12 +2895,18 @@ var WipeRun = class {
2766
2895
  secretsRedacted += await this.#rewrite(plan.file);
2767
2896
  filesRewritten++;
2768
2897
  }
2769
- return { planned: plans, filesRewritten, secretsRedacted, confirmed: true };
2898
+ return { planned: plans, untouched, filesRewritten, secretsRedacted, confirmed: true };
2770
2899
  }
2771
2900
  async #plan() {
2772
2901
  const plans = [];
2902
+ const untouched = [];
2773
2903
  for (const source of this.#sources) {
2774
2904
  for await (const file of source.discover()) {
2905
+ if (file.messages !== void 0) {
2906
+ if (!await this.#holdsSecret(file.messages)) continue;
2907
+ untouched.push(file.path);
2908
+ continue;
2909
+ }
2775
2910
  let lines = 0;
2776
2911
  let secrets = 0;
2777
2912
  for await (const text of this.#textOf(file)) {
@@ -2785,12 +2920,25 @@ var WipeRun = class {
2785
2920
  }
2786
2921
  }
2787
2922
  }
2788
- return plans;
2923
+ return { plans, untouched };
2924
+ }
2925
+ /** True for a database that cannot be read: it may hold one, and saying
2926
+ * nothing about it would let a wipe pass for complete. */
2927
+ async #holdsSecret(messages) {
2928
+ if (messages == null) return true;
2929
+ try {
2930
+ for await (const message of messages()) {
2931
+ if (this.#scanner.secretsOf(message.text).size > 0) return true;
2932
+ }
2933
+ return false;
2934
+ } catch {
2935
+ return true;
2936
+ }
2789
2937
  }
2790
2938
  /** The rewrite below is textual and kind-blind; only counting needs to know
2791
2939
  * which reader gets the file. */
2792
2940
  async *#textOf(file) {
2793
- if (file.kind === FileKind.memory) {
2941
+ if (!isJsonl(file.path)) {
2794
2942
  for await (const line of readLines(file.path)) yield line.text;
2795
2943
  return;
2796
2944
  }
@@ -2853,7 +3001,8 @@ async function main(argv) {
2853
3001
  const sources = [
2854
3002
  new ClaudeCodeSource(homedir()),
2855
3003
  new CodexSource(homedir()),
2856
- new CursorSource(homedir())
3004
+ new CursorSource(homedir()),
3005
+ new OpencodeSource(homedir())
2857
3006
  ];
2858
3007
  const scanner = new Scanner(new KeywordIndex(compileRules(SECRET_RULE_SOURCES)));
2859
3008
  return command === Command.wipe ? await wipe(sources, scanner, write) : await scan(sources, scanner, write);
@@ -2872,7 +3021,13 @@ async function wipe(sources, scanner, write) {
2872
3021
  write(dim(" replacing credentials in your local transcripts with a placeholder"));
2873
3022
  write("");
2874
3023
  const outcome = await new WipeRun(sources, scanner, new TerminalPrompt(), write).run();
3024
+ for (const path of outcome.untouched) {
3025
+ write(yellow(` Not rewritten: ${shorten(path)} is a database, and wipe only rewrites text.`));
3026
+ write(dim(" Delete those sessions in the agent itself, and rotate what they held."));
3027
+ write("");
3028
+ }
2875
3029
  if (outcome.planned.length === 0) {
3030
+ if (outcome.untouched.length > 0) return 1;
2876
3031
  write(green(" No credentials found. Nothing to wipe."));
2877
3032
  return 0;
2878
3033
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "whatileaked",
3
- "version": "0.4.0",
4
- "description": "Scan your local Claude Code, Codex and Cursor transcripts for credentials you already sent to a model provider.",
3
+ "version": "0.5.0",
4
+ "description": "Scan your local Claude Code, Codex, Cursor and opencode transcripts for credentials you already sent to a model provider.",
5
5
  "type": "module",
6
6
  "imports": {
7
7
  "#scripts/*": "./scripts/*.ts",