codiedev 0.9.79 → 0.9.81

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 (39) hide show
  1. package/assets/opencode-plugin.mjs +6 -0
  2. package/assets/session-key.mjs +48 -0
  3. package/dist/cli.js +113 -9
  4. package/dist/cliSession.d.ts +108 -0
  5. package/dist/cliSession.js +513 -0
  6. package/dist/codie.js +21 -1
  7. package/dist/commands/agentTemplates.generated.d.ts +11 -0
  8. package/dist/commands/agentTemplates.generated.js +139 -0
  9. package/dist/commands/agents.d.ts +7 -0
  10. package/dist/commands/agents.js +1130 -0
  11. package/dist/commands/agentsSdk.generated.d.ts +4 -0
  12. package/dist/commands/agentsSdk.generated.js +10 -0
  13. package/dist/commands/scrub.d.ts +11 -0
  14. package/dist/commands/scrub.js +102 -0
  15. package/dist/configDir.d.ts +9 -0
  16. package/dist/configDir.js +25 -1
  17. package/dist/connect.js +45 -2
  18. package/dist/connectFlow.d.ts +21 -0
  19. package/dist/connectFlow.js +41 -0
  20. package/dist/hook.js +25 -0
  21. package/dist/knownSecrets.d.ts +43 -0
  22. package/dist/knownSecrets.js +241 -0
  23. package/dist/launcher.d.ts +7 -2
  24. package/dist/launcher.js +36 -2
  25. package/dist/localScrub.d.ts +172 -0
  26. package/dist/localScrub.js +820 -0
  27. package/dist/localScrubReport.d.ts +83 -0
  28. package/dist/localScrubReport.js +268 -0
  29. package/dist/managedPi.js +18 -2
  30. package/dist/mcp.js +3 -0
  31. package/dist/scrub/extraRules.d.ts +4 -0
  32. package/dist/scrub/extraRules.js +17 -0
  33. package/dist/scrub/rules.d.ts +86 -0
  34. package/dist/scrub/rules.js +645 -0
  35. package/dist/scrub/scrubProviderRules.d.ts +14 -0
  36. package/dist/scrub/scrubProviderRules.js +149 -0
  37. package/dist/utils.d.ts +2 -0
  38. package/dist/utils.js +9 -7
  39. package/package.json +5 -4
@@ -0,0 +1,820 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.RUN_INTERVAL_MS = exports.MIN_AGE_MS = void 0;
37
+ exports.sessionStores = sessionStores;
38
+ exports.cursorIdeDb = cursorIdeDb;
39
+ exports.cleanJson = cleanJson;
40
+ exports.cleanJsonl = cleanJsonl;
41
+ exports.cleanText = cleanText;
42
+ exports.cleanYaml = cleanYaml;
43
+ exports.cleanFor = cleanFor;
44
+ exports.cleanFile = cleanFile;
45
+ exports.filesOf = filesOf;
46
+ exports.cleanOpenCodeDb = cleanOpenCodeDb;
47
+ exports.countCursorDb = countCursorDb;
48
+ exports.runLocalScrub = runLocalScrub;
49
+ exports.runAndReport = runAndReport;
50
+ exports.scrubPolicy = scrubPolicy;
51
+ exports.scrubAllowed = scrubAllowed;
52
+ exports.formatRules = formatRules;
53
+ exports.runScheduledScrub = runScheduledScrub;
54
+ exports.maybeSpawnLocalScrub = maybeSpawnLocalScrub;
55
+ // Local session scrub: removes keys from the session files every harness keeps
56
+ // on this machine. The server already scrubs what we upload; this cleans the
57
+ // copies left on the laptop (Claude Code, Codex, Cursor, Copilot, OpenCode /
58
+ // Codie harness, pi / Codie pi).
59
+ //
60
+ // Runs in a detached child at most once an hour, started from a session-start
61
+ // hook (Codex: first prompt), and only when the person turned it on in
62
+ // Security → Settings or their company requires it (scrubAllowed). Off by
63
+ // default. `codiedev scrub` runs it by hand regardless. Never touches a session that may be open:
64
+ // - files changed in the last hour are skipped;
65
+ // - a cleaned copy is written next to the file and swapped in only if the
66
+ // file is byte-for-byte unchanged since it was read;
67
+ // - the original mtime is restored so resume lists keep their order;
68
+ // - every line of a JSONL file must still parse after cleaning, else that
69
+ // line is cleaned value by value instead.
70
+ // Uses the same rules as the server (scrub/rules.ts mirrors
71
+ // convex/lib/scrubSecrets.ts), so the laptop and the server agree on what a
72
+ // key is. Never records a key: the state and log hold paths and counts only.
73
+ const fs = __importStar(require("fs"));
74
+ const os = __importStar(require("os"));
75
+ const path = __importStar(require("path"));
76
+ const child_process_1 = require("child_process");
77
+ const extraRules_1 = require("./scrub/extraRules");
78
+ const knownSecrets_1 = require("./knownSecrets");
79
+ const harnessDirs_1 = require("./harnessDirs");
80
+ const copilotHooks_1 = require("./copilotHooks");
81
+ const configDir_1 = require("./configDir");
82
+ const localScrubReport_1 = require("./localScrubReport");
83
+ exports.MIN_AGE_MS = 60 * 60_000;
84
+ exports.RUN_INTERVAL_MS = 60 * 60_000;
85
+ const MAX_FILE_BYTES = 100 * 1024 * 1024;
86
+ const DEFAULT_BUDGET_MS = 2 * 60_000;
87
+ const STATE_VERSION = 1;
88
+ const isJsonl = (f) => f.endsWith(".jsonl");
89
+ /** Every place a harness keeps conversation text on this machine. Snapshots
90
+ * of the user's own files (Copilot rewind, Claude file-history) are left
91
+ * alone: cleaning them would change code the user restores later. */
92
+ function sessionStores(env = process.env, home = os.homedir(), stateDir = (0, configDir_1.codiedevStateDir)()) {
93
+ const claude = (0, harnessDirs_1.claudeConfigDir)(env, home);
94
+ const codex = (0, harnessDirs_1.codexHome)(env, home);
95
+ const dataHome = env.XDG_DATA_HOME?.trim() ? path.resolve(env.XDG_DATA_HOME.trim()) : path.join(home, ".local", "share");
96
+ const openCodeData = path.join(dataHome, "opencode");
97
+ return [
98
+ { harness: "claude-code", kind: "jsonl", root: path.join(claude, "projects"), match: isJsonl },
99
+ { harness: "claude-code", kind: "jsonl", root: path.join(claude, "history.jsonl") },
100
+ { harness: "codex", kind: "jsonl", root: path.join(codex, "sessions"), match: isJsonl },
101
+ { harness: "codex", kind: "jsonl", root: path.join(codex, "archived_sessions"), match: isJsonl },
102
+ { harness: "codex", kind: "jsonl", root: path.join(codex, "history.jsonl") },
103
+ {
104
+ harness: "cursor",
105
+ kind: "jsonl",
106
+ root: path.join(home, ".cursor", "projects"),
107
+ match: (f) => isJsonl(f) && f.split(path.sep).includes("agent-transcripts"),
108
+ },
109
+ {
110
+ harness: "copilot",
111
+ kind: "jsonl",
112
+ root: path.join((0, copilotHooks_1.copilotHome)(env, home), "session-state"),
113
+ match: (f) => path.basename(f) === "events.jsonl",
114
+ },
115
+ // Copilot puts the session's title/summary (often the first prompt) here.
116
+ {
117
+ harness: "copilot",
118
+ kind: "yaml",
119
+ root: path.join((0, copilotHooks_1.copilotHome)(env, home), "session-state"),
120
+ match: (f) => path.basename(f) === "workspace.yaml",
121
+ },
122
+ { harness: "pi", kind: "jsonl", root: path.join((0, harnessDirs_1.piAgentDir)(env, home), "sessions"), match: isJsonl },
123
+ { harness: "pi", kind: "jsonl", root: path.join(stateDir, "managed", "pi", "agent", "sessions"), match: isJsonl },
124
+ { harness: "opencode", kind: "json", root: path.join(stateDir, "opencode-captures"), match: (f) => f.endsWith(".json") },
125
+ { harness: "opencode", kind: "text", root: path.join(openCodeData, "tool-output") },
126
+ { harness: "opencode", kind: "opencode-db", root: path.join(openCodeData, "opencode.db") },
127
+ // Counted only. cursor-agent resumes chats from these stores.
128
+ {
129
+ harness: "cursor",
130
+ kind: "cursor-agent-db",
131
+ root: path.join(home, ".cursor", "chats"),
132
+ match: (f) => path.basename(f) === "store.db",
133
+ },
134
+ { harness: "cursor", kind: "cursor-ide-db", root: cursorIdeDb(env, home) },
135
+ ];
136
+ }
137
+ /** The Cursor IDE's chat database (VS Code layout, per platform). */
138
+ function cursorIdeDb(env = process.env, home = os.homedir(), platform = process.platform) {
139
+ const base = platform === "darwin"
140
+ ? path.join(home, "Library", "Application Support")
141
+ : platform === "win32"
142
+ ? env.APPDATA || path.join(home, "AppData", "Roaming")
143
+ : env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config");
144
+ return path.join(base, "Cursor", "User", "globalStorage", "state.vscdb");
145
+ }
146
+ /** Clean every string inside a parsed JSON value. */
147
+ function cleanValue(value, count) {
148
+ if (typeof value === "string") {
149
+ const r = (0, extraRules_1.scrub)(value);
150
+ count.n += r.redactions.length;
151
+ return r.text;
152
+ }
153
+ if (Array.isArray(value))
154
+ return value.map((v) => cleanValue(v, count));
155
+ if (value && typeof value === "object") {
156
+ const out = {};
157
+ for (const [k, v] of Object.entries(value))
158
+ out[k] = cleanValue(v, count);
159
+ return out;
160
+ }
161
+ return value;
162
+ }
163
+ function parses(text) {
164
+ try {
165
+ JSON.parse(text);
166
+ return true;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ /** One JSON document. Cleans the raw text (keeps formatting byte-for-byte
173
+ * outside the keys); if that would break the JSON, cleans value by value. */
174
+ function cleanJson(text) {
175
+ const raw = (0, extraRules_1.scrub)(text);
176
+ if (raw.redactions.length === 0)
177
+ return { text, redactions: 0 };
178
+ if (parses(raw.text) || !parses(text))
179
+ return { text: raw.text, redactions: raw.redactions.length };
180
+ const count = { n: 0 };
181
+ const cleaned = cleanValue(JSON.parse(text), count);
182
+ return { text: JSON.stringify(cleaned), redactions: count.n };
183
+ }
184
+ /** JSONL: each line is its own JSON document. Line endings are kept. */
185
+ function cleanJsonl(text) {
186
+ const lines = text.split("\n");
187
+ let redactions = 0;
188
+ for (let i = 0; i < lines.length; i++) {
189
+ const line = lines[i];
190
+ if (!line.trim())
191
+ continue;
192
+ const cr = line.endsWith("\r");
193
+ const body = cr ? line.slice(0, -1) : line;
194
+ const r = cleanJson(body);
195
+ if (r.redactions === 0)
196
+ continue;
197
+ redactions += r.redactions;
198
+ lines[i] = cr ? `${r.text}\r` : r.text;
199
+ }
200
+ return redactions === 0 ? { text, redactions: 0 } : { text: lines.join("\n"), redactions };
201
+ }
202
+ function cleanText(text) {
203
+ const r = (0, extraRules_1.scrub)(text);
204
+ return { text: r.text, redactions: r.redactions.length };
205
+ }
206
+ /** Flat `key: value` YAML. A redaction at the start of an unquoted value
207
+ * would read as a YAML list (`[REDACTED:…]`), so such values get quoted. */
208
+ function cleanYaml(text) {
209
+ let redactions = 0;
210
+ const lines = text.split("\n").map((line) => {
211
+ const r = (0, extraRules_1.scrub)(line);
212
+ if (r.redactions.length === 0)
213
+ return line;
214
+ redactions += r.redactions.length;
215
+ const m = r.text.match(/^(\s*[^\s:#][^:]*:\s+)(\[REDACTED:.*?)(\r?)$/);
216
+ if (!m)
217
+ return r.text;
218
+ return `${m[1]}'${m[2].replace(/'/g, "''")}'${m[3]}`;
219
+ });
220
+ return redactions === 0 ? { text, redactions: 0 } : { text: lines.join("\n"), redactions };
221
+ }
222
+ function cleanFor(kind, text) {
223
+ return kind === "jsonl" ? cleanJsonl(text) : kind === "json" ? cleanJson(text) : kind === "yaml" ? cleanYaml(text) : cleanText(text);
224
+ }
225
+ /** Clean one file in place, safely. Returns the outcome, how many keys, and
226
+ * (when keys were found) which ones plus the session's title and folder. */
227
+ function cleanFile(file, kind, deps) {
228
+ let before;
229
+ try {
230
+ before = fs.statSync(file);
231
+ }
232
+ catch {
233
+ return { outcome: "error", redactions: 0 };
234
+ }
235
+ if (deps.now - before.mtimeMs < deps.minAgeMs)
236
+ return { outcome: "too-recent", redactions: 0 };
237
+ if (before.size > MAX_FILE_BYTES)
238
+ return { outcome: "too-large", redactions: 0 };
239
+ let original;
240
+ try {
241
+ original = fs.readFileSync(file);
242
+ }
243
+ catch {
244
+ return { outcome: "error", redactions: 0 };
245
+ }
246
+ const originalText = original.toString("utf8");
247
+ const result = cleanFor(kind, originalText);
248
+ if (result.redactions === 0)
249
+ return { outcome: "clean", redactions: 0 };
250
+ const detail = { keys: (0, localScrubReport_1.keysIn)(originalText), ...(0, localScrubReport_1.describeText)(kind, result.text) };
251
+ if (deps.dryRun)
252
+ return { outcome: "would-clean", redactions: result.redactions, detail };
253
+ const tmp = `${file}.codiedev-scrub-${process.pid}.tmp`;
254
+ try {
255
+ fs.writeFileSync(tmp, result.text, { mode: before.mode & 0o777 });
256
+ deps.beforeSwap?.(file);
257
+ // Swap only if nobody wrote to the file since we read it.
258
+ const now = fs.statSync(file);
259
+ if (now.size !== before.size || now.mtimeMs !== before.mtimeMs || !fs.readFileSync(file).equals(original)) {
260
+ fs.rmSync(tmp, { force: true });
261
+ return { outcome: "changed-while-cleaning", redactions: 0 };
262
+ }
263
+ fs.renameSync(tmp, file);
264
+ // Keep the original times so "resume" pickers keep their order.
265
+ fs.utimesSync(file, before.atime, before.mtime);
266
+ return { outcome: "cleaned", redactions: result.redactions, detail };
267
+ }
268
+ catch {
269
+ try {
270
+ fs.rmSync(tmp, { force: true });
271
+ }
272
+ catch { /* ignore */ }
273
+ return { outcome: "error", redactions: 0 };
274
+ }
275
+ }
276
+ function* walk(root) {
277
+ let entries;
278
+ try {
279
+ entries = fs.readdirSync(root, { withFileTypes: true });
280
+ }
281
+ catch {
282
+ return;
283
+ }
284
+ for (const e of entries) {
285
+ const p = path.join(root, e.name);
286
+ if (e.isDirectory())
287
+ yield* walk(p);
288
+ else if (e.isFile() && !e.name.includes(".codiedev-scrub-"))
289
+ yield p;
290
+ }
291
+ }
292
+ function filesOf(store) {
293
+ let st;
294
+ try {
295
+ st = fs.statSync(store.root);
296
+ }
297
+ catch {
298
+ return [];
299
+ }
300
+ if (st.isFile())
301
+ return [store.root];
302
+ const out = [];
303
+ for (const f of walk(store.root))
304
+ if (!store.match || store.match(f))
305
+ out.push(f);
306
+ return out;
307
+ }
308
+ function openSqlite(file, readOnly = false) {
309
+ try {
310
+ // node:sqlite ships with Node 22.5+; older Nodes skip the database.
311
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
312
+ const { DatabaseSync } = require("node:sqlite");
313
+ const db = readOnly ? new DatabaseSync(file, { readOnly: true }) : new DatabaseSync(file);
314
+ db.exec("PRAGMA busy_timeout = 2000");
315
+ return db;
316
+ }
317
+ catch {
318
+ return null;
319
+ }
320
+ }
321
+ /** Tables holding conversation JSON in OpenCode 1.x (`opencode.db`). Missing
322
+ * tables are skipped, so older and newer schemas both work. */
323
+ const OPENCODE_TABLES = [
324
+ { table: "message", session: "session_id", time: "time_updated" },
325
+ { table: "part", session: "session_id", time: "time_updated" },
326
+ { table: "session_message", session: "session_id", time: "time_updated" },
327
+ { table: "event", session: "aggregate_id" },
328
+ ];
329
+ function cleanOpenCodeDb(file, deps) {
330
+ if (!fs.existsSync(file))
331
+ return { outcome: "clean", redactions: 0, rows: 0 };
332
+ const db = openSqlite(file);
333
+ if (!db)
334
+ return { outcome: "no-sqlite", redactions: 0, rows: 0 };
335
+ const sessions = new Map();
336
+ let redactions = 0;
337
+ let rows = 0;
338
+ try {
339
+ const cutoff = deps.now - deps.minAgeMs;
340
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => t.name));
341
+ // A session touched in the last hour may be open: leave all of it alone.
342
+ const recent = new Set();
343
+ for (const t of OPENCODE_TABLES) {
344
+ if (!tables.has(t.table) || !t.time)
345
+ continue;
346
+ for (const r of db.prepare(`SELECT DISTINCT ${t.session} AS s FROM ${t.table} WHERE ${t.time} >= ?`).all(cutoff)) {
347
+ recent.add(r.s);
348
+ }
349
+ }
350
+ const updates = [];
351
+ for (const t of OPENCODE_TABLES) {
352
+ if (!tables.has(t.table))
353
+ continue;
354
+ const all = db.prepare(`SELECT id, ${t.session} AS s, data FROM ${t.table}`).all();
355
+ for (const row of all) {
356
+ if (recent.has(row.s) || typeof row.data !== "string")
357
+ continue;
358
+ if (deps.onlySessions && !deps.onlySessions.has(row.s))
359
+ continue;
360
+ const r = cleanJson(row.data);
361
+ if (r.redactions === 0)
362
+ continue;
363
+ redactions += r.redactions;
364
+ updates.push({ table: t.table, id: row.id, data: r.text, before: row.data });
365
+ const cur = sessions.get(row.s) ?? { keys: [] };
366
+ // The event log repeats message/part data; count keys once per session.
367
+ if (t.table !== "event")
368
+ cur.keys = (0, localScrubReport_1.mergeKeys)(cur.keys, (0, localScrubReport_1.keysIn)(row.data));
369
+ sessions.set(row.s, cur);
370
+ }
371
+ }
372
+ rows = updates.length;
373
+ if (rows === 0)
374
+ return { outcome: "clean", redactions: 0, rows: 0 };
375
+ if (tables.has("session")) {
376
+ const get = db.prepare("SELECT title, directory FROM session WHERE id = ?");
377
+ for (const [id, d] of sessions) {
378
+ const row = get.all(id)[0];
379
+ if (typeof row?.title === "string")
380
+ d.title = (0, extraRules_1.scrub)(row.title).text.slice(0, 80);
381
+ if (typeof row?.directory === "string")
382
+ d.cwd = row.directory;
383
+ }
384
+ }
385
+ if (deps.dryRun)
386
+ return { outcome: "would-clean", redactions, rows, sessions };
387
+ db.exec("BEGIN IMMEDIATE");
388
+ try {
389
+ // Only touch rows still exactly as we read them.
390
+ for (const u of updates) {
391
+ db.prepare(`UPDATE ${u.table} SET data = ? WHERE id = ? AND data = ?`).run(u.data, u.id, u.before);
392
+ }
393
+ db.exec("COMMIT");
394
+ }
395
+ catch (e) {
396
+ try {
397
+ db.exec("ROLLBACK");
398
+ }
399
+ catch { /* ignore */ }
400
+ throw e;
401
+ }
402
+ return { outcome: "cleaned", redactions, rows, sessions };
403
+ }
404
+ catch {
405
+ return { outcome: "error", redactions: 0, rows: 0 };
406
+ }
407
+ finally {
408
+ try {
409
+ db.close();
410
+ }
411
+ catch { /* ignore */ }
412
+ }
413
+ }
414
+ function decodeMetaName(hexOrJson) {
415
+ try {
416
+ const text = /^[0-9a-f]+$/i.test(hexOrJson) ? Buffer.from(hexOrJson, "hex").toString("utf8") : hexOrJson;
417
+ const name = JSON.parse(text).name;
418
+ return typeof name === "string" ? name : undefined;
419
+ }
420
+ catch {
421
+ return undefined;
422
+ }
423
+ }
424
+ function countCursorDb(file, kind) {
425
+ const db = openSqlite(file, true);
426
+ if (!db)
427
+ return null;
428
+ try {
429
+ const rows = (kind === "cursor-agent-db"
430
+ ? db.prepare("SELECT data AS v FROM blobs").all()
431
+ : db.prepare("SELECT key AS k, value AS v FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' OR key LIKE 'composerData:%'").all());
432
+ const chats = new Map();
433
+ const titles = new Map();
434
+ // One store.db is one chat, named by its folder (the agent id).
435
+ const agentChat = path.basename(path.dirname(file));
436
+ for (const row of rows) {
437
+ const text = typeof row.v === "string" ? row.v : row.v instanceof Uint8Array ? Buffer.from(row.v).toString("latin1") : "";
438
+ if (!text)
439
+ continue;
440
+ // IDE rows are keyed bubbleId:<composerId>:<bubbleId> / composerData:<composerId>.
441
+ const chat = kind === "cursor-agent-db" ? agentChat : (row.k ?? "").split(":")[1] ?? "";
442
+ if (kind === "cursor-ide-db" && row.k?.startsWith("composerData:")) {
443
+ const name = decodeMetaName(text);
444
+ if (name)
445
+ titles.set(chat, name);
446
+ }
447
+ const keys = (0, localScrubReport_1.keysIn)(text);
448
+ if (keys.length === 0)
449
+ continue;
450
+ chats.set(chat, (0, localScrubReport_1.mergeKeys)(chats.get(chat) ?? [], keys));
451
+ }
452
+ if (kind === "cursor-agent-db" && chats.size > 0) {
453
+ try {
454
+ const meta = db.prepare("SELECT value AS v FROM meta").all();
455
+ for (const m of meta) {
456
+ const name = decodeMetaName(String(m.v));
457
+ if (name)
458
+ titles.set(agentChat, name);
459
+ }
460
+ }
461
+ catch { /* no meta */ }
462
+ }
463
+ const details = [...chats].map(([chat, keys]) => ({
464
+ harness: "cursor",
465
+ sessionKey: chat,
466
+ ...(titles.get(chat) ? { title: (0, extraRules_1.scrub)(titles.get(chat)).text.slice(0, 80) } : {}),
467
+ keys,
468
+ status: "not_removed",
469
+ reason: "cursor_chat_store",
470
+ }));
471
+ return { keys: details.reduce((n, d) => n + d.keys.reduce((m, k) => m + k.count, 0), 0), chats: details.length, details };
472
+ }
473
+ catch {
474
+ return null;
475
+ }
476
+ finally {
477
+ try {
478
+ db.close();
479
+ }
480
+ catch { /* ignore */ }
481
+ }
482
+ }
483
+ const scrubDir = (stateDir) => path.join(stateDir, "local-scrub");
484
+ /** Changes whenever the rule set changes, so new rules re-check old files. */
485
+ function rulesFingerprint() {
486
+ try {
487
+ const src = fs.readFileSync(path.join(__dirname, "scrub", "rules.js"), "utf8");
488
+ let h = 0;
489
+ for (let i = 0; i < src.length; i++)
490
+ h = (h * 31 + src.charCodeAt(i)) | 0;
491
+ return String(h);
492
+ }
493
+ catch {
494
+ return "unknown";
495
+ }
496
+ }
497
+ function readState(dir, rules) {
498
+ try {
499
+ const s = JSON.parse(fs.readFileSync(path.join(dir, "state.json"), "utf8"));
500
+ if (s.version === STATE_VERSION && s.rules === rules && s.files)
501
+ return s;
502
+ }
503
+ catch { /* fresh */ }
504
+ return { version: STATE_VERSION, rules, files: {} };
505
+ }
506
+ function writeAtomic(file, text) {
507
+ const tmp = `${file}.${process.pid}.tmp`;
508
+ fs.writeFileSync(tmp, text, { mode: 0o600 });
509
+ fs.renameSync(tmp, file);
510
+ }
511
+ function runLocalScrub(opts = {}) {
512
+ const now = opts.now ?? Date.now();
513
+ const deps = {
514
+ now,
515
+ minAgeMs: opts.minAgeMs ?? exports.MIN_AGE_MS,
516
+ dryRun: Boolean(opts.dryRun),
517
+ ...(opts.onlySessions ? { onlySessions: new Set(opts.onlySessions) } : {}),
518
+ };
519
+ const started = Date.now();
520
+ const budget = opts.budgetMs ?? DEFAULT_BUDGET_MS;
521
+ const stateDir = opts.stateDir ?? (0, configDir_1.codiedevStateDir)();
522
+ const dir = scrubDir(stateDir);
523
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
524
+ const rules = rulesFingerprint();
525
+ const state = readState(dir, rules);
526
+ const only = opts.only ? new Set(opts.only.map((p) => path.resolve(p))) : null;
527
+ const summary = {
528
+ dryRun: deps.dryRun, scanned: 0, cleaned: 0, redactions: 0, skippedRecent: 0, skippedLarge: 0,
529
+ changedWhileCleaning: 0, errors: 0, unfinished: false, byHarness: {}, leftInCursor: { keys: 0, chats: 0 }, sessions: [],
530
+ };
531
+ const found = new Map();
532
+ const detect = (harness, sessionKey, d) => {
533
+ const id = `${harness}\u0000${sessionKey}`;
534
+ const cur = found.get(id);
535
+ if (cur) {
536
+ cur.keys = (0, localScrubReport_1.mergeKeys)(cur.keys, d.keys);
537
+ cur.title ??= d.title;
538
+ cur.cwd ??= d.cwd;
539
+ }
540
+ else {
541
+ found.set(id, { harness, sessionKey, ...(d.title ? { title: d.title } : {}), ...(d.cwd ? { cwd: d.cwd } : {}), keys: d.keys, status: "removed" });
542
+ }
543
+ };
544
+ const counted = (state.counted ??= {});
545
+ const tally = (h, n) => {
546
+ const t = (summary.byHarness[h] ??= { files: 0, redactions: 0 });
547
+ t.files += 1;
548
+ t.redactions += n;
549
+ };
550
+ outer: for (const store of opts.stores ?? sessionStores(process.env, os.homedir(), stateDir)) {
551
+ if (store.kind === "cursor-agent-db" || store.kind === "cursor-ide-db") {
552
+ if (only || opts.onlySessions)
553
+ continue;
554
+ for (const file of filesOf(store)) {
555
+ let sig;
556
+ try {
557
+ const st = fs.statSync(file);
558
+ // A WAL holds the newest rows; include it so new chats are re-counted.
559
+ let wal = "";
560
+ try {
561
+ const w = fs.statSync(`${file}-wal`);
562
+ wal = `:${w.size}:${w.mtimeMs}`;
563
+ }
564
+ catch { /* no wal */ }
565
+ sig = `${st.size}:${st.mtimeMs}${wal}`;
566
+ }
567
+ catch {
568
+ continue;
569
+ }
570
+ if (counted[file]?.sig !== sig) {
571
+ const c = countCursorDb(file, store.kind);
572
+ if (!c)
573
+ continue;
574
+ counted[file] = { sig, ...c };
575
+ }
576
+ }
577
+ continue;
578
+ }
579
+ if (store.kind === "opencode-db") {
580
+ if (only && !only.has(path.resolve(store.root)))
581
+ continue;
582
+ const r = cleanOpenCodeDb(store.root, deps);
583
+ if (r.outcome === "no-sqlite")
584
+ continue;
585
+ summary.scanned += 1;
586
+ if (r.outcome === "error")
587
+ summary.errors += 1;
588
+ if (r.redactions > 0) {
589
+ summary.redactions += r.redactions;
590
+ if (r.outcome === "cleaned")
591
+ summary.cleaned += 1;
592
+ tally(store.harness, r.redactions);
593
+ for (const [id, d] of r.sessions ?? [])
594
+ detect(store.harness, id, d);
595
+ }
596
+ continue;
597
+ }
598
+ for (const file of filesOf(store)) {
599
+ // --session targets the OpenCode database only; files need --path.
600
+ if ((only || opts.onlySessions) && !only?.has(path.resolve(file)))
601
+ continue;
602
+ if (Date.now() - started > budget) {
603
+ summary.unfinished = true;
604
+ break outer;
605
+ }
606
+ let st;
607
+ try {
608
+ st = fs.statSync(file);
609
+ }
610
+ catch {
611
+ continue;
612
+ }
613
+ const sig = `${st.size}:${st.mtimeMs}`;
614
+ if (!only && state.files[file] === sig)
615
+ continue; // unchanged since last check
616
+ summary.scanned += 1;
617
+ const r = cleanFile(file, store.kind, deps);
618
+ switch (r.outcome) {
619
+ case "too-recent":
620
+ summary.skippedRecent += 1;
621
+ break;
622
+ case "too-large":
623
+ summary.skippedLarge += 1;
624
+ state.files[file] = sig;
625
+ break;
626
+ case "changed-while-cleaning":
627
+ summary.changedWhileCleaning += 1;
628
+ break;
629
+ case "error":
630
+ summary.errors += 1;
631
+ break;
632
+ case "clean":
633
+ state.files[file] = sig;
634
+ break;
635
+ case "would-clean":
636
+ summary.redactions += r.redactions;
637
+ tally(store.harness, r.redactions);
638
+ if (r.detail)
639
+ detect(store.harness, (0, localScrubReport_1.sessionKeyOf)(store.harness, file), r.detail);
640
+ break;
641
+ case "cleaned": {
642
+ summary.cleaned += 1;
643
+ summary.redactions += r.redactions;
644
+ tally(store.harness, r.redactions);
645
+ if (r.detail)
646
+ detect(store.harness, (0, localScrubReport_1.sessionKeyOf)(store.harness, file), r.detail);
647
+ try {
648
+ const after = fs.statSync(file);
649
+ state.files[file] = `${after.size}:${after.mtimeMs}`;
650
+ }
651
+ catch { /* next run re-checks */ }
652
+ break;
653
+ }
654
+ }
655
+ }
656
+ }
657
+ for (const [f, c] of Object.entries(counted)) {
658
+ if (!fs.existsSync(f)) {
659
+ delete counted[f];
660
+ continue;
661
+ }
662
+ if (only || opts.onlySessions)
663
+ continue;
664
+ summary.leftInCursor.keys += c.keys;
665
+ summary.leftInCursor.chats += c.chats;
666
+ summary.sessions.push(...(c.details ?? []));
667
+ }
668
+ summary.sessions.unshift(...found.values());
669
+ if (!deps.dryRun) {
670
+ // Forget files that no longer exist so the state doesn't grow forever.
671
+ for (const f of Object.keys(state.files))
672
+ if (!fs.existsSync(f))
673
+ delete state.files[f];
674
+ try {
675
+ writeAtomic(path.join(dir, "state.json"), JSON.stringify(state));
676
+ }
677
+ catch { /* ignore */ }
678
+ try {
679
+ fs.appendFileSync(path.join(dir, "log.jsonl"), JSON.stringify({ at: new Date(now).toISOString(), ...summary }) + "\n", { mode: 0o600 });
680
+ }
681
+ catch { /* ignore */ }
682
+ }
683
+ return summary;
684
+ }
685
+ // ---------------------------------------------------------------------------
686
+ // Run and report (the background child and `codiedev scrub`)
687
+ // ---------------------------------------------------------------------------
688
+ /** Run the scrub and send the audit to Codiedev when connected. Dry runs and
689
+ * runs limited by --path/--session are never sent. */
690
+ async function runAndReport(opts = {}) {
691
+ const at = opts.now ?? Date.now();
692
+ const summary = runLocalScrub(opts);
693
+ let reported = false;
694
+ if (!summary.dryRun && !opts.only && !opts.onlySessions) {
695
+ try {
696
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
697
+ const { readConfig } = require("./utils");
698
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
699
+ const { CLI_VERSION } = require("./version");
700
+ const { buildReport, machineId, sendReport } = await Promise.resolve().then(() => __importStar(require("./localScrubReport")));
701
+ const config = readConfig();
702
+ if (config?.token && config.backendUrl) {
703
+ const payload = buildReport({
704
+ machineId: machineId(scrubDir(opts.stateDir ?? (0, configDir_1.codiedevStateDir)())),
705
+ at,
706
+ complete: !summary.unfinished,
707
+ summary,
708
+ sessions: summary.sessions,
709
+ ignoredPaths: config.captureIgnoredPaths,
710
+ });
711
+ reported = await sendReport(config.backendUrl, config.token, payload, CLI_VERSION);
712
+ }
713
+ }
714
+ catch { /* best-effort */ }
715
+ }
716
+ return { ...summary, reported };
717
+ }
718
+ const defaultConfig = () => {
719
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
720
+ const { readConfig } = require("./utils");
721
+ return readConfig();
722
+ };
723
+ const OFF = { enabled: false, formats: [], ownSecrets: "off" };
724
+ async function scrubPolicy(fetchImpl = fetch, getConfig = defaultConfig) {
725
+ try {
726
+ const config = getConfig();
727
+ if (!config?.token || !config.backendUrl)
728
+ return OFF;
729
+ const res = await fetchImpl(`${config.backendUrl}/api/cli/localScrubPolicy`, {
730
+ headers: { Authorization: `Bearer ${config.token}` },
731
+ signal: AbortSignal.timeout(10_000),
732
+ });
733
+ if (!res.ok)
734
+ return OFF;
735
+ const body = (await res.json());
736
+ const formats = Array.isArray(body.formats)
737
+ ? body.formats.filter((f) => f && typeof f.name === "string" && typeof f.source === "string" && f.source.length <= 300).slice(0, 50)
738
+ : [];
739
+ // An older server says nothing: match on the laptop, send nothing.
740
+ const ownSecrets = body.ownSecrets === "off" || body.ownSecrets === "server" ? body.ownSecrets : "laptop";
741
+ const key = typeof body.fingerprintKey === "string" && /^[0-9a-f]{64}$/.test(body.fingerprintKey) ? body.fingerprintKey : undefined;
742
+ return { enabled: body.enabled === true, formats, ownSecrets, ...(key ? { fingerprintKey: key } : {}) };
743
+ }
744
+ catch {
745
+ return OFF;
746
+ }
747
+ }
748
+ /** Should this person's machine run the background scrub? */
749
+ async function scrubAllowed(fetchImpl = fetch, getConfig = defaultConfig) {
750
+ return (await scrubPolicy(fetchImpl, getConfig)).enabled;
751
+ }
752
+ /** Turn the company's token formats into scrub rules; a bad one is skipped. */
753
+ function formatRules(formats) {
754
+ const out = [];
755
+ for (const f of formats) {
756
+ try {
757
+ out.push({ name: `custom:${f.name}`, re: new RegExp(`(?<![A-Za-z0-9])${f.source}(?![A-Za-z0-9_-])`, "g"), ...(f.keyword ? { keywords: [f.keyword] } : {}) });
758
+ }
759
+ catch { /* skip */ }
760
+ }
761
+ return out;
762
+ }
763
+ /** The scheduled run: policy check, then this person's own secrets (as the
764
+ * admin set it: not read / matched here / also keyed fingerprints to the
765
+ * server) and the company's formats, then the scrub and its report. */
766
+ async function runScheduledScrub(opts = {}) {
767
+ const getConfig = opts.getConfig ?? defaultConfig;
768
+ const policy = await scrubPolicy(opts.fetchImpl ?? fetch, getConfig);
769
+ if (!policy.enabled)
770
+ return false;
771
+ const stateDir = opts.stateDir ?? (0, configDir_1.codiedevStateDir)();
772
+ const known = policy.ownSecrets === "off" ? [] : (0, knownSecrets_1.collectKnownSecrets)(opts.home ?? os.homedir(), (0, knownSecrets_1.recentCwds)(scrubDir(stateDir)));
773
+ if (policy.ownSecrets === "server" && policy.fingerprintKey)
774
+ await (0, knownSecrets_1.uploadKnownSecrets)(getConfig(), policy.fingerprintKey, known, opts.fetchImpl ?? fetch);
775
+ const knownRule = (0, knownSecrets_1.knownSecretRule)(known);
776
+ (0, extraRules_1.setExtraRules)([...formatRules(policy.formats), ...(knownRule ? [knownRule] : [])]);
777
+ try {
778
+ await runAndReport({ stateDir });
779
+ }
780
+ finally {
781
+ (0, extraRules_1.setExtraRules)([]);
782
+ }
783
+ return true;
784
+ }
785
+ /** Start a background scrub if the last one was over an hour ago. Stamps
786
+ * first so sessions opening at the same time don't each start one. */
787
+ function maybeSpawnLocalScrub(opts = {}) {
788
+ try {
789
+ const env = opts.env ?? process.env;
790
+ if (opts.connected === false || env.CODIEDEV_LOCAL_SCRUB === "off" || env.CODIEDEV_LOCAL_SCRUB_CHILD)
791
+ return "disabled";
792
+ const now = opts.now ?? Date.now();
793
+ const dir = scrubDir(opts.stateDir ?? (0, configDir_1.codiedevStateDir)());
794
+ // Which repos are this person's, for their .env files (read only when the
795
+ // scrub is on; this just remembers the path).
796
+ (0, knownSecrets_1.rememberCwd)(dir, opts.cwd);
797
+ const stamp = path.join(dir, "last-run");
798
+ try {
799
+ const last = fs.statSync(stamp).mtimeMs;
800
+ if (now - last < exports.RUN_INTERVAL_MS && last <= now)
801
+ return "recent";
802
+ }
803
+ catch { /* never ran */ }
804
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
805
+ fs.writeFileSync(stamp, String(now));
806
+ const t = new Date(now);
807
+ fs.utimesSync(stamp, t, t);
808
+ const child = (opts.spawn ?? child_process_1.spawn)(process.execPath, [opts.hookEntry ?? path.join(__dirname, "hook.js"), "local-scrub"], {
809
+ detached: true,
810
+ stdio: "ignore",
811
+ env: { ...env, CODIEDEV_LOCAL_SCRUB_CHILD: "1" },
812
+ });
813
+ child.on?.("error", () => { });
814
+ child.unref?.();
815
+ return "spawned";
816
+ }
817
+ catch {
818
+ return "error";
819
+ }
820
+ }