@lifeaitools/clauth 1.30.23 → 1.30.24

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 (40) hide show
  1. package/.clauth-skill/SKILL.md +111 -111
  2. package/README.md +25 -0
  3. package/cli/api.classify.test.js +75 -75
  4. package/cli/assets/codevelop/launcher-active.cmd.template +20 -20
  5. package/cli/assets/codevelop/launcher-static.cmd.template +7 -7
  6. package/cli/assets/codevelop/windows-terminal.profiles.json +48 -48
  7. package/cli/assets/watchdog.ps1 +42 -42
  8. package/cli/commands/agent-cron.js +396 -396
  9. package/cli/commands/agent-pool.js +1962 -1962
  10. package/cli/commands/codevelop.js +1190 -1190
  11. package/cli/commands/doctor.js +302 -302
  12. package/cli/commands/install.js +10 -10
  13. package/cli/commands/invite.js +175 -175
  14. package/cli/commands/join.js +179 -179
  15. package/cli/commands/npm.js +182 -182
  16. package/cli/commands/scrub.js +327 -327
  17. package/cli/commands/scrub.test.js +115 -115
  18. package/cli/commands/serve.js +41 -95
  19. package/cli/commands/watchdog.js +209 -209
  20. package/cli/conf-path.js +21 -21
  21. package/cli/enrollment-script.js +82 -82
  22. package/cli/fingerprint.js +143 -143
  23. package/cli/index.js +1053 -1053
  24. package/cli/lib/fs-git.js +282 -282
  25. package/cli/recovery.js +101 -101
  26. package/cli/studio-debug.js +1095 -1095
  27. package/cli/supervisor-registry.js +594 -589
  28. package/cli/supervisor-registry.test.js +397 -397
  29. package/cli/supervisor-ui.test.js +5 -83
  30. package/cli/watchdog-registry.js +209 -209
  31. package/cli/watchdog-registry.test.js +89 -89
  32. package/install.ps1 +21 -21
  33. package/package.json +2 -2
  34. package/scripts/bin/bootstrap-linux +0 -0
  35. package/scripts/bin/bootstrap-macos +0 -0
  36. package/scripts/bin/bootstrap-win.exe +0 -0
  37. package/supabase/migrations/001_clauth_schema.sql +12 -12
  38. package/supabase/migrations/003_clauth_config.sql +13 -13
  39. package/supabase/migrations/003_machine_enrollments.sql +39 -39
  40. package/cli/served-script-syntax.test.mjs +0 -54
@@ -1,327 +1,327 @@
1
- // cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs + sidecars
2
- //
3
- // clauth scrub → scrub active transcript (most recent)
4
- // clauth scrub <file> → scrub a specific file
5
- // clauth scrub all → scrub every transcript + tool-result sidecar
6
- // clauth scrub --force → rescrub even if already marked
7
- //
8
- // Three layers of redaction:
9
- // 1. Built-in regex PATTERNS (known token shapes)
10
- // 2. User-editable patterns from ~/.clauth/scrub-patterns.json (no release needed)
11
- // 3. Vault-value redaction — literal occurrences of THIS machine's actual secret
12
- // values, pulled best-effort from the local daemon (covers any format).
13
- // Files scanned: .jsonl transcripts AND tool-results/*.txt sidecars (the sidecars
14
- // were previously skipped — a leaked secret could survive a "clean" scrub there).
15
-
16
- import fs from "fs";
17
- import path from "path";
18
- import os from "os";
19
- import chalk from "chalk";
20
- import ora from "ora";
21
-
22
- const SCRUB_MARKER = "[CLAUTH-SCRUBBED]";
23
- const SCRUB_VERSION = "1.2";
24
- const DAEMON = process.env.CLAUTH_DAEMON_URL || "http://127.0.0.1:52437";
25
- const VAULT_FETCH_DELAY_MS = Number(process.env.CLAUTH_SCRUB_FETCH_DELAY_MS || 2200);
26
-
27
- function sleep(ms) {
28
- return new Promise(resolve => setTimeout(resolve, ms));
29
- }
30
-
31
- // Value-based redaction only considers values that look like real secrets, to avoid
32
- // over-redacting short/benign config values (bucket names, plain URLs, etc.).
33
- const MIN_SECRET_LEN = 16;
34
-
35
- // ──────────────────────────────────────────────
36
- // Built-in credential patterns — regex + replacement
37
- // ──────────────────────────────────────────────
38
- const PATTERNS = [
39
- [/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[SUPABASE_JWT_REDACTED]"],
40
- [/vcp_[A-Za-z0-9]{20,80}/g, "[VERCEL_TOKEN_REDACTED]"],
41
- [/"secret_access_key"\s*:\s*"[a-f0-9]{64}"/g, '"secret_access_key": "[R2_SECRET_REDACTED]"'],
42
- [/"access_key_id"\s*:\s*"[a-f0-9]{32}"/g, '"access_key_id": "[R2_KEY_REDACTED]"'],
43
- [/"admin_token"\s*:\s*"[A-Za-z0-9_-]{20,60}"/g, '"admin_token": "[CF_TOKEN_REDACTED]"'],
44
- [/"account_id"\s*:\s*"[a-f0-9]{32}"/g, '"account_id": "[CF_ACCOUNT_REDACTED]"'],
45
- // GitHub tokens — ghp_ classic is 36 chars, but accept 30+ to be resilient to format shifts
46
- [/ghp_[A-Za-z0-9]{30,}/g, "[GITHUB_TOKEN_REDACTED]"],
47
- [/gho_[A-Za-z0-9]{30,}/g, "[GITHUB_OAUTH_REDACTED]"],
48
- [/ghs_[A-Za-z0-9]{30,}/g, "[GITHUB_SERVER_REDACTED]"],
49
- [/github_pat_[A-Za-z0-9_]{40,100}/g, "[GITHUB_PAT_REDACTED]"],
50
- // AWS access key IDs
51
- [/AKIA[A-Z0-9]{16}/g, "[AWS_KEY_REDACTED]"],
52
- // OpenAI / Anthropic style keys
53
- [/sk-[A-Za-z0-9_-]{20,}/g, "[API_KEY_REDACTED]"],
54
- [/neo4j\+s?:\/\/[^"\\]+/g, "[NEO4J_CONNSTRING_REDACTED]"],
55
- [/Bearer [A-Za-z0-9_-]{20,}/g, "Bearer [TOKEN_REDACTED]"],
56
- ];
57
-
58
- // ──────────────────────────────────────────────
59
- // Layer 2 — user-editable patterns (no clauth release to add a pattern)
60
- // ~/.clauth/scrub-patterns.json: [{ "pattern": "...", "flags": "g", "replacement": "..." }]
61
- // ──────────────────────────────────────────────
62
- export function loadExtraPatterns(filePath) {
63
- const file = filePath || path.join(os.homedir(), ".clauth", "scrub-patterns.json");
64
- try {
65
- if (!fs.existsSync(file)) return [];
66
- const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
67
- const list = Array.isArray(raw) ? raw : Array.isArray(raw.patterns) ? raw.patterns : [];
68
- const out = [];
69
- for (const entry of list) {
70
- if (!entry || !entry.pattern) continue;
71
- try {
72
- const flags = entry.flags && entry.flags.includes("g") ? entry.flags : (entry.flags || "") + "g";
73
- out.push([new RegExp(entry.pattern, flags), entry.replacement ?? "[REDACTED]"]);
74
- } catch { /* skip malformed pattern, never break scrub */ }
75
- }
76
- return out;
77
- } catch {
78
- return [];
79
- }
80
- }
81
-
82
- // ──────────────────────────────────────────────
83
- // Layer 3 — literal secret values from the vault
84
- // ──────────────────────────────────────────────
85
- export function isSecretLike(value) {
86
- if (typeof value !== "string") return false;
87
- const v = value.trim();
88
- if (v.length < MIN_SECRET_LEN) return false; // too short → likely benign config
89
- if (/\s/.test(v)) return false; // whitespace → not a token (URLs/sentences)
90
- if (/^https?:\/\//i.test(v)) return false; // plain endpoint URL, not a secret
91
- return true;
92
- }
93
-
94
- // Best-effort: pull this machine's actual secret values from the local daemon.
95
- // This is intentionally slow because literal redaction must read real secrets.
96
- // Normal service discovery should use /knowledge and never call this.
97
- // Returns [{ name, value }]. Any failure (daemon down/locked/offline) → [] (regex still runs).
98
- export async function fetchVaultValues(base = DAEMON) {
99
- try {
100
- const ctrl = new AbortController();
101
- const t = setTimeout(() => ctrl.abort(), 4000);
102
- const listRes = await fetch(`${base}/knowledge`, { signal: ctrl.signal });
103
- clearTimeout(t);
104
- if (!listRes.ok) return [];
105
- const listJson = await listRes.json();
106
- const names = (listJson.services || []).filter(s => s.has_key !== false).map(s => s.name);
107
- const out = [];
108
- for (let i = 0; i < names.length; i++) {
109
- const name = names[i];
110
- if (i > 0 && VAULT_FETCH_DELAY_MS > 0) await sleep(VAULT_FETCH_DELAY_MS);
111
- try {
112
- const c = new AbortController();
113
- const tt = setTimeout(() => c.abort(), 4000);
114
- const r = await fetch(`${base}/v/${encodeURIComponent(name)}`, { signal: c.signal });
115
- clearTimeout(tt);
116
- if (!r.ok) continue;
117
- const value = (await r.text()).trim();
118
- if (isSecretLike(value)) out.push({ name, value });
119
- } catch { /* skip this service */ }
120
- }
121
- return out;
122
- } catch {
123
- return [];
124
- }
125
- }
126
-
127
- function redactLiterals(content, literals) {
128
- let count = 0;
129
- for (const { name, value } of literals) {
130
- if (!value || !content.includes(value)) continue;
131
- const before = content.length;
132
- const parts = content.split(value);
133
- count += parts.length - 1;
134
- content = parts.join(`[CLAUTH:${name}_REDACTED]`);
135
- void before;
136
- }
137
- return { content, count };
138
- }
139
-
140
- // ──────────────────────────────────────────────
141
- // Marker check / stamp
142
- // ──────────────────────────────────────────────
143
- function isAlreadyScrubbed(filePath) {
144
- try {
145
- const stat = fs.statSync(filePath);
146
- const fd = fs.openSync(filePath, "r");
147
- const bufSize = Math.min(512, stat.size);
148
- const buf = Buffer.alloc(bufSize);
149
- fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
150
- fs.closeSync(fd);
151
- const lastLine = buf.toString("utf-8").trim().split("\n").pop();
152
- if (lastLine && lastLine.includes(SCRUB_MARKER)) {
153
- try { return JSON.parse(lastLine).version === SCRUB_VERSION; } catch { return false; }
154
- }
155
- return false;
156
- } catch { return false; }
157
- }
158
-
159
- function stampScrubbed(filePath) {
160
- const marker = JSON.stringify({
161
- type: SCRUB_MARKER,
162
- version: SCRUB_VERSION,
163
- scrubbed_at: new Date().toISOString(),
164
- });
165
- fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
166
- }
167
-
168
- // ──────────────────────────────────────────────
169
- // Scrub a single file
170
- // ──────────────────────────────────────────────
171
- export function scrubFile(filePath, opts = {}) {
172
- const { force = false, patterns = PATTERNS, literals = [] } = opts;
173
- if (!force && isAlreadyScrubbed(filePath)) return "skipped";
174
-
175
- let content = fs.readFileSync(filePath, "utf-8");
176
- let total = 0;
177
-
178
- for (const [pattern, replacement] of patterns) {
179
- pattern.lastIndex = 0;
180
- const matches = content.match(pattern);
181
- if (matches) {
182
- total += matches.length;
183
- content = content.replace(pattern, replacement);
184
- }
185
- }
186
-
187
- if (literals.length) {
188
- const r = redactLiterals(content, literals);
189
- content = r.content;
190
- total += r.count;
191
- }
192
-
193
- if (total > 0) fs.writeFileSync(filePath, content, "utf-8");
194
- stampScrubbed(filePath);
195
- return total;
196
- }
197
-
198
- // ──────────────────────────────────────────────
199
- // Find all scrubbable files: .jsonl transcripts AND tool-results/*.txt sidecars
200
- // ──────────────────────────────────────────────
201
- const SCRUB_EXTS = new Set([".jsonl", ".txt"]);
202
-
203
- export function findTranscripts(root) {
204
- const claudeDir = root || path.join(os.homedir(), ".claude", "projects");
205
- if (!fs.existsSync(claudeDir)) return [];
206
- const results = [];
207
- function walk(dir) {
208
- let entries;
209
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
210
- for (const entry of entries) {
211
- const full = path.join(dir, entry.name);
212
- if (entry.isDirectory()) walk(full);
213
- else if (SCRUB_EXTS.has(path.extname(entry.name))) results.push(full);
214
- }
215
- }
216
- walk(claudeDir);
217
- return results;
218
- }
219
-
220
- function findMostRecent() {
221
- const files = findTranscripts();
222
- if (files.length === 0) return null;
223
- let newest = files[0];
224
- let newestMtime = fs.statSync(files[0]).mtimeMs;
225
- for (const f of files.slice(1)) {
226
- const mt = fs.statSync(f).mtimeMs;
227
- if (mt > newestMtime) { newest = f; newestMtime = mt; }
228
- }
229
- return newest;
230
- }
231
-
232
- // ──────────────────────────────────────────────
233
- // Session scope — the ending session's transcript + its sidecars.
234
- // A SessionEnd hook pipes JSON ({transcript_path, session_id, cwd, ...}) on stdin.
235
- // Sidecars live in the dir named like the transcript minus the .jsonl extension.
236
- // ──────────────────────────────────────────────
237
- export function sessionTargets(hook) {
238
- const files = [];
239
- const tp = hook && (hook.transcript_path || hook.transcriptPath);
240
- if (tp && fs.existsSync(tp)) files.push(tp);
241
- if (tp) {
242
- const sidecarDir = tp.replace(/\.jsonl$/i, "");
243
- if (sidecarDir !== tp && fs.existsSync(sidecarDir)) {
244
- for (const f of findTranscripts(sidecarDir)) files.push(f);
245
- }
246
- }
247
- return [...new Set(files)];
248
- }
249
-
250
- async function readHookStdin() {
251
- try {
252
- if (process.stdin.isTTY) return null;
253
- const chunks = [];
254
- for await (const chunk of process.stdin) chunks.push(chunk);
255
- const raw = Buffer.concat(chunks).toString("utf-8").trim();
256
- return raw ? JSON.parse(raw) : null;
257
- } catch {
258
- return null;
259
- }
260
- }
261
-
262
- // ──────────────────────────────────────────────
263
- // Exported runner
264
- // ──────────────────────────────────────────────
265
- export async function runScrub(target, opts = {}) {
266
- const force = opts.force || false;
267
-
268
- const patterns = [...PATTERNS, ...loadExtraPatterns()];
269
- const literals = await fetchVaultValues();
270
-
271
- let files;
272
- if (target === "all") {
273
- files = findTranscripts();
274
- if (files.length === 0) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
275
- console.log(chalk.cyan(`\n Scrubbing ${files.length} file(s) (.jsonl + sidecars)...`));
276
- } else if (target === "session") {
277
- // Scrub ONLY the ending session (transcript + its sidecars). For SessionEnd hooks.
278
- const hook = await readHookStdin();
279
- files = sessionTargets(hook);
280
- if (files.length === 0) {
281
- const recent = findMostRecent();
282
- if (!recent) { console.log(chalk.yellow("\n No session transcript found.\n")); return; }
283
- files = [recent];
284
- }
285
- console.log(chalk.gray(`\n Session scrub: ${files.length} file(s)`));
286
- } else if (target && target !== "all") {
287
- const resolved = path.resolve(target);
288
- if (!fs.existsSync(resolved)) { console.log(chalk.red(`\n File not found: ${resolved}\n`)); process.exit(1); }
289
- files = [resolved];
290
- } else {
291
- const recent = findMostRecent();
292
- if (!recent) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
293
- files = [recent];
294
- console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
295
- }
296
-
297
- const layers = [`${patterns.length} pattern(s)`];
298
- if (literals.length) layers.push(chalk.green(`${literals.length} live vault value(s)`));
299
- else layers.push(chalk.gray("vault values: daemon unreachable/locked — regex only"));
300
- console.log(chalk.gray(` Redaction: ${layers.join(", ")}\n`));
301
-
302
- const spinner = ora("Scrubbing credentials...").start();
303
- let grandTotal = 0, skipped = 0, scanned = 0;
304
- for (const f of files) {
305
- const result = scrubFile(f, { force, patterns, literals });
306
- if (result === "skipped") { skipped++; continue; }
307
- scanned++;
308
- if (result > 0) {
309
- spinner.stop();
310
- console.log(chalk.yellow(` ${result} redaction(s) in ${path.basename(f)}`));
311
- spinner.start("Scrubbing credentials...");
312
- grandTotal += result;
313
- }
314
- }
315
- spinner.stop();
316
-
317
- const parts = [];
318
- if (scanned) parts.push(`scanned ${scanned}`);
319
- if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
320
- if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
321
- else if (scanned) parts.push(chalk.green("no credentials found"));
322
- console.log(`\n ${files.length} file(s): ${parts.join(", ")}.`);
323
- if (skipped && !force) console.log(chalk.gray(" (use --force to rescan all files)"));
324
- console.log();
325
- }
326
-
327
- export { PATTERNS, SCRUB_VERSION };
1
+ // cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs + sidecars
2
+ //
3
+ // clauth scrub → scrub active transcript (most recent)
4
+ // clauth scrub <file> → scrub a specific file
5
+ // clauth scrub all → scrub every transcript + tool-result sidecar
6
+ // clauth scrub --force → rescrub even if already marked
7
+ //
8
+ // Three layers of redaction:
9
+ // 1. Built-in regex PATTERNS (known token shapes)
10
+ // 2. User-editable patterns from ~/.clauth/scrub-patterns.json (no release needed)
11
+ // 3. Vault-value redaction — literal occurrences of THIS machine's actual secret
12
+ // values, pulled best-effort from the local daemon (covers any format).
13
+ // Files scanned: .jsonl transcripts AND tool-results/*.txt sidecars (the sidecars
14
+ // were previously skipped — a leaked secret could survive a "clean" scrub there).
15
+
16
+ import fs from "fs";
17
+ import path from "path";
18
+ import os from "os";
19
+ import chalk from "chalk";
20
+ import ora from "ora";
21
+
22
+ const SCRUB_MARKER = "[CLAUTH-SCRUBBED]";
23
+ const SCRUB_VERSION = "1.2";
24
+ const DAEMON = process.env.CLAUTH_DAEMON_URL || "http://127.0.0.1:52437";
25
+ const VAULT_FETCH_DELAY_MS = Number(process.env.CLAUTH_SCRUB_FETCH_DELAY_MS || 2200);
26
+
27
+ function sleep(ms) {
28
+ return new Promise(resolve => setTimeout(resolve, ms));
29
+ }
30
+
31
+ // Value-based redaction only considers values that look like real secrets, to avoid
32
+ // over-redacting short/benign config values (bucket names, plain URLs, etc.).
33
+ const MIN_SECRET_LEN = 16;
34
+
35
+ // ──────────────────────────────────────────────
36
+ // Built-in credential patterns — regex + replacement
37
+ // ──────────────────────────────────────────────
38
+ const PATTERNS = [
39
+ [/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[SUPABASE_JWT_REDACTED]"],
40
+ [/vcp_[A-Za-z0-9]{20,80}/g, "[VERCEL_TOKEN_REDACTED]"],
41
+ [/"secret_access_key"\s*:\s*"[a-f0-9]{64}"/g, '"secret_access_key": "[R2_SECRET_REDACTED]"'],
42
+ [/"access_key_id"\s*:\s*"[a-f0-9]{32}"/g, '"access_key_id": "[R2_KEY_REDACTED]"'],
43
+ [/"admin_token"\s*:\s*"[A-Za-z0-9_-]{20,60}"/g, '"admin_token": "[CF_TOKEN_REDACTED]"'],
44
+ [/"account_id"\s*:\s*"[a-f0-9]{32}"/g, '"account_id": "[CF_ACCOUNT_REDACTED]"'],
45
+ // GitHub tokens — ghp_ classic is 36 chars, but accept 30+ to be resilient to format shifts
46
+ [/ghp_[A-Za-z0-9]{30,}/g, "[GITHUB_TOKEN_REDACTED]"],
47
+ [/gho_[A-Za-z0-9]{30,}/g, "[GITHUB_OAUTH_REDACTED]"],
48
+ [/ghs_[A-Za-z0-9]{30,}/g, "[GITHUB_SERVER_REDACTED]"],
49
+ [/github_pat_[A-Za-z0-9_]{40,100}/g, "[GITHUB_PAT_REDACTED]"],
50
+ // AWS access key IDs
51
+ [/AKIA[A-Z0-9]{16}/g, "[AWS_KEY_REDACTED]"],
52
+ // OpenAI / Anthropic style keys
53
+ [/sk-[A-Za-z0-9_-]{20,}/g, "[API_KEY_REDACTED]"],
54
+ [/neo4j\+s?:\/\/[^"\\]+/g, "[NEO4J_CONNSTRING_REDACTED]"],
55
+ [/Bearer [A-Za-z0-9_-]{20,}/g, "Bearer [TOKEN_REDACTED]"],
56
+ ];
57
+
58
+ // ──────────────────────────────────────────────
59
+ // Layer 2 — user-editable patterns (no clauth release to add a pattern)
60
+ // ~/.clauth/scrub-patterns.json: [{ "pattern": "...", "flags": "g", "replacement": "..." }]
61
+ // ──────────────────────────────────────────────
62
+ export function loadExtraPatterns(filePath) {
63
+ const file = filePath || path.join(os.homedir(), ".clauth", "scrub-patterns.json");
64
+ try {
65
+ if (!fs.existsSync(file)) return [];
66
+ const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
67
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw.patterns) ? raw.patterns : [];
68
+ const out = [];
69
+ for (const entry of list) {
70
+ if (!entry || !entry.pattern) continue;
71
+ try {
72
+ const flags = entry.flags && entry.flags.includes("g") ? entry.flags : (entry.flags || "") + "g";
73
+ out.push([new RegExp(entry.pattern, flags), entry.replacement ?? "[REDACTED]"]);
74
+ } catch { /* skip malformed pattern, never break scrub */ }
75
+ }
76
+ return out;
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ // ──────────────────────────────────────────────
83
+ // Layer 3 — literal secret values from the vault
84
+ // ──────────────────────────────────────────────
85
+ export function isSecretLike(value) {
86
+ if (typeof value !== "string") return false;
87
+ const v = value.trim();
88
+ if (v.length < MIN_SECRET_LEN) return false; // too short → likely benign config
89
+ if (/\s/.test(v)) return false; // whitespace → not a token (URLs/sentences)
90
+ if (/^https?:\/\//i.test(v)) return false; // plain endpoint URL, not a secret
91
+ return true;
92
+ }
93
+
94
+ // Best-effort: pull this machine's actual secret values from the local daemon.
95
+ // This is intentionally slow because literal redaction must read real secrets.
96
+ // Normal service discovery should use /knowledge and never call this.
97
+ // Returns [{ name, value }]. Any failure (daemon down/locked/offline) → [] (regex still runs).
98
+ export async function fetchVaultValues(base = DAEMON) {
99
+ try {
100
+ const ctrl = new AbortController();
101
+ const t = setTimeout(() => ctrl.abort(), 4000);
102
+ const listRes = await fetch(`${base}/knowledge`, { signal: ctrl.signal });
103
+ clearTimeout(t);
104
+ if (!listRes.ok) return [];
105
+ const listJson = await listRes.json();
106
+ const names = (listJson.services || []).filter(s => s.has_key !== false).map(s => s.name);
107
+ const out = [];
108
+ for (let i = 0; i < names.length; i++) {
109
+ const name = names[i];
110
+ if (i > 0 && VAULT_FETCH_DELAY_MS > 0) await sleep(VAULT_FETCH_DELAY_MS);
111
+ try {
112
+ const c = new AbortController();
113
+ const tt = setTimeout(() => c.abort(), 4000);
114
+ const r = await fetch(`${base}/v/${encodeURIComponent(name)}`, { signal: c.signal });
115
+ clearTimeout(tt);
116
+ if (!r.ok) continue;
117
+ const value = (await r.text()).trim();
118
+ if (isSecretLike(value)) out.push({ name, value });
119
+ } catch { /* skip this service */ }
120
+ }
121
+ return out;
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ function redactLiterals(content, literals) {
128
+ let count = 0;
129
+ for (const { name, value } of literals) {
130
+ if (!value || !content.includes(value)) continue;
131
+ const before = content.length;
132
+ const parts = content.split(value);
133
+ count += parts.length - 1;
134
+ content = parts.join(`[CLAUTH:${name}_REDACTED]`);
135
+ void before;
136
+ }
137
+ return { content, count };
138
+ }
139
+
140
+ // ──────────────────────────────────────────────
141
+ // Marker check / stamp
142
+ // ──────────────────────────────────────────────
143
+ function isAlreadyScrubbed(filePath) {
144
+ try {
145
+ const stat = fs.statSync(filePath);
146
+ const fd = fs.openSync(filePath, "r");
147
+ const bufSize = Math.min(512, stat.size);
148
+ const buf = Buffer.alloc(bufSize);
149
+ fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
150
+ fs.closeSync(fd);
151
+ const lastLine = buf.toString("utf-8").trim().split("\n").pop();
152
+ if (lastLine && lastLine.includes(SCRUB_MARKER)) {
153
+ try { return JSON.parse(lastLine).version === SCRUB_VERSION; } catch { return false; }
154
+ }
155
+ return false;
156
+ } catch { return false; }
157
+ }
158
+
159
+ function stampScrubbed(filePath) {
160
+ const marker = JSON.stringify({
161
+ type: SCRUB_MARKER,
162
+ version: SCRUB_VERSION,
163
+ scrubbed_at: new Date().toISOString(),
164
+ });
165
+ fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
166
+ }
167
+
168
+ // ──────────────────────────────────────────────
169
+ // Scrub a single file
170
+ // ──────────────────────────────────────────────
171
+ export function scrubFile(filePath, opts = {}) {
172
+ const { force = false, patterns = PATTERNS, literals = [] } = opts;
173
+ if (!force && isAlreadyScrubbed(filePath)) return "skipped";
174
+
175
+ let content = fs.readFileSync(filePath, "utf-8");
176
+ let total = 0;
177
+
178
+ for (const [pattern, replacement] of patterns) {
179
+ pattern.lastIndex = 0;
180
+ const matches = content.match(pattern);
181
+ if (matches) {
182
+ total += matches.length;
183
+ content = content.replace(pattern, replacement);
184
+ }
185
+ }
186
+
187
+ if (literals.length) {
188
+ const r = redactLiterals(content, literals);
189
+ content = r.content;
190
+ total += r.count;
191
+ }
192
+
193
+ if (total > 0) fs.writeFileSync(filePath, content, "utf-8");
194
+ stampScrubbed(filePath);
195
+ return total;
196
+ }
197
+
198
+ // ──────────────────────────────────────────────
199
+ // Find all scrubbable files: .jsonl transcripts AND tool-results/*.txt sidecars
200
+ // ──────────────────────────────────────────────
201
+ const SCRUB_EXTS = new Set([".jsonl", ".txt"]);
202
+
203
+ export function findTranscripts(root) {
204
+ const claudeDir = root || path.join(os.homedir(), ".claude", "projects");
205
+ if (!fs.existsSync(claudeDir)) return [];
206
+ const results = [];
207
+ function walk(dir) {
208
+ let entries;
209
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
210
+ for (const entry of entries) {
211
+ const full = path.join(dir, entry.name);
212
+ if (entry.isDirectory()) walk(full);
213
+ else if (SCRUB_EXTS.has(path.extname(entry.name))) results.push(full);
214
+ }
215
+ }
216
+ walk(claudeDir);
217
+ return results;
218
+ }
219
+
220
+ function findMostRecent() {
221
+ const files = findTranscripts();
222
+ if (files.length === 0) return null;
223
+ let newest = files[0];
224
+ let newestMtime = fs.statSync(files[0]).mtimeMs;
225
+ for (const f of files.slice(1)) {
226
+ const mt = fs.statSync(f).mtimeMs;
227
+ if (mt > newestMtime) { newest = f; newestMtime = mt; }
228
+ }
229
+ return newest;
230
+ }
231
+
232
+ // ──────────────────────────────────────────────
233
+ // Session scope — the ending session's transcript + its sidecars.
234
+ // A SessionEnd hook pipes JSON ({transcript_path, session_id, cwd, ...}) on stdin.
235
+ // Sidecars live in the dir named like the transcript minus the .jsonl extension.
236
+ // ──────────────────────────────────────────────
237
+ export function sessionTargets(hook) {
238
+ const files = [];
239
+ const tp = hook && (hook.transcript_path || hook.transcriptPath);
240
+ if (tp && fs.existsSync(tp)) files.push(tp);
241
+ if (tp) {
242
+ const sidecarDir = tp.replace(/\.jsonl$/i, "");
243
+ if (sidecarDir !== tp && fs.existsSync(sidecarDir)) {
244
+ for (const f of findTranscripts(sidecarDir)) files.push(f);
245
+ }
246
+ }
247
+ return [...new Set(files)];
248
+ }
249
+
250
+ async function readHookStdin() {
251
+ try {
252
+ if (process.stdin.isTTY) return null;
253
+ const chunks = [];
254
+ for await (const chunk of process.stdin) chunks.push(chunk);
255
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
256
+ return raw ? JSON.parse(raw) : null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+
262
+ // ──────────────────────────────────────────────
263
+ // Exported runner
264
+ // ──────────────────────────────────────────────
265
+ export async function runScrub(target, opts = {}) {
266
+ const force = opts.force || false;
267
+
268
+ const patterns = [...PATTERNS, ...loadExtraPatterns()];
269
+ const literals = await fetchVaultValues();
270
+
271
+ let files;
272
+ if (target === "all") {
273
+ files = findTranscripts();
274
+ if (files.length === 0) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
275
+ console.log(chalk.cyan(`\n Scrubbing ${files.length} file(s) (.jsonl + sidecars)...`));
276
+ } else if (target === "session") {
277
+ // Scrub ONLY the ending session (transcript + its sidecars). For SessionEnd hooks.
278
+ const hook = await readHookStdin();
279
+ files = sessionTargets(hook);
280
+ if (files.length === 0) {
281
+ const recent = findMostRecent();
282
+ if (!recent) { console.log(chalk.yellow("\n No session transcript found.\n")); return; }
283
+ files = [recent];
284
+ }
285
+ console.log(chalk.gray(`\n Session scrub: ${files.length} file(s)`));
286
+ } else if (target && target !== "all") {
287
+ const resolved = path.resolve(target);
288
+ if (!fs.existsSync(resolved)) { console.log(chalk.red(`\n File not found: ${resolved}\n`)); process.exit(1); }
289
+ files = [resolved];
290
+ } else {
291
+ const recent = findMostRecent();
292
+ if (!recent) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
293
+ files = [recent];
294
+ console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
295
+ }
296
+
297
+ const layers = [`${patterns.length} pattern(s)`];
298
+ if (literals.length) layers.push(chalk.green(`${literals.length} live vault value(s)`));
299
+ else layers.push(chalk.gray("vault values: daemon unreachable/locked — regex only"));
300
+ console.log(chalk.gray(` Redaction: ${layers.join(", ")}\n`));
301
+
302
+ const spinner = ora("Scrubbing credentials...").start();
303
+ let grandTotal = 0, skipped = 0, scanned = 0;
304
+ for (const f of files) {
305
+ const result = scrubFile(f, { force, patterns, literals });
306
+ if (result === "skipped") { skipped++; continue; }
307
+ scanned++;
308
+ if (result > 0) {
309
+ spinner.stop();
310
+ console.log(chalk.yellow(` ${result} redaction(s) in ${path.basename(f)}`));
311
+ spinner.start("Scrubbing credentials...");
312
+ grandTotal += result;
313
+ }
314
+ }
315
+ spinner.stop();
316
+
317
+ const parts = [];
318
+ if (scanned) parts.push(`scanned ${scanned}`);
319
+ if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
320
+ if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
321
+ else if (scanned) parts.push(chalk.green("no credentials found"));
322
+ console.log(`\n ${files.length} file(s): ${parts.join(", ")}.`);
323
+ if (skipped && !force) console.log(chalk.gray(" (use --force to rescan all files)"));
324
+ console.log();
325
+ }
326
+
327
+ export { PATTERNS, SCRUB_VERSION };