@lifeaitools/clauth 1.30.13 → 1.30.15

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.
@@ -1,9 +1,17 @@
1
- // cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs
1
+ // cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs + sidecars
2
2
  //
3
- // clauth scrub → scrub active transcript (most recent .jsonl)
3
+ // clauth scrub → scrub active transcript (most recent)
4
4
  // clauth scrub <file> → scrub a specific file
5
- // clauth scrub all → scrub every transcript .jsonl
5
+ // clauth scrub all → scrub every transcript + tool-result sidecar
6
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).
7
15
 
8
16
  import fs from "fs";
9
17
  import path from "path";
@@ -12,53 +20,125 @@ import chalk from "chalk";
12
20
  import ora from "ora";
13
21
 
14
22
  const SCRUB_MARKER = "[CLAUTH-SCRUBBED]";
15
- const SCRUB_VERSION = "1.1";
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;
16
34
 
17
35
  // ──────────────────────────────────────────────
18
- // Credential patterns — regex + replacement
36
+ // Built-in credential patterns — regex + replacement
19
37
  // ──────────────────────────────────────────────
20
38
  const PATTERNS = [
21
- // Supabase JWTs (anon, service_role)
22
- [/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
23
- "[SUPABASE_JWT_REDACTED]"],
24
-
25
- // Vercel tokens
26
- [/vcp_[A-Za-z0-9]{20,80}/g,
27
- "[VERCEL_TOKEN_REDACTED]"],
28
-
29
- // R2 / S3 secret access keys (64-char hex)
30
- [/"secret_access_key"\s*:\s*"[a-f0-9]{64}"/g,
31
- '"secret_access_key": "[R2_SECRET_REDACTED]"'],
32
-
33
- // R2 / S3 access key IDs (32-char hex)
34
- [/"access_key_id"\s*:\s*"[a-f0-9]{32}"/g,
35
- '"access_key_id": "[R2_KEY_REDACTED]"'],
36
-
37
- // Cloudflare admin tokens
38
- [/"admin_token"\s*:\s*"[A-Za-z0-9_-]{20,60}"/g,
39
- '"admin_token": "[CF_TOKEN_REDACTED]"'],
40
-
41
- // Cloudflare account IDs (32-char hex)
42
- [/"account_id"\s*:\s*"[a-f0-9]{32}"/g,
43
- '"account_id": "[CF_ACCOUNT_REDACTED]"'],
44
-
45
- // GitHub tokens
46
- [/ghp_[A-Za-z0-9]{36}/g,
47
- "[GITHUB_TOKEN_REDACTED]"],
48
- [/github_pat_[A-Za-z0-9_]{40,100}/g,
49
- "[GITHUB_PAT_REDACTED]"],
50
-
51
- // Neo4j connection strings with passwords
52
- [/neo4j\+s?:\/\/[^"\\]+/g,
53
- "[NEO4J_CONNSTRING_REDACTED]"],
54
-
55
- // Generic Bearer tokens in Authorization headers
56
- [/Bearer [A-Za-z0-9_-]{20,}/g,
57
- "Bearer [TOKEN_REDACTED]"],
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]"],
58
56
  ];
59
57
 
60
58
  // ──────────────────────────────────────────────
61
- // Marker checkread last 512 bytes
59
+ // Layer 2user-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
62
142
  // ──────────────────────────────────────────────
63
143
  function isAlreadyScrubbed(filePath) {
64
144
  try {
@@ -68,28 +148,19 @@ function isAlreadyScrubbed(filePath) {
68
148
  const buf = Buffer.alloc(bufSize);
69
149
  fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
70
150
  fs.closeSync(fd);
71
-
72
- const tail = buf.toString("utf-8");
73
- const lastLine = tail.trim().split("\n").pop();
151
+ const lastLine = buf.toString("utf-8").trim().split("\n").pop();
74
152
  if (lastLine && lastLine.includes(SCRUB_MARKER)) {
75
- try {
76
- const marker = JSON.parse(lastLine);
77
- return marker.version === SCRUB_VERSION;
78
- } catch { return false; }
153
+ try { return JSON.parse(lastLine).version === SCRUB_VERSION; } catch { return false; }
79
154
  }
80
155
  return false;
81
156
  } catch { return false; }
82
157
  }
83
158
 
84
- // ──────────────────────────────────────────────
85
- // Stamp file as scrubbed
86
- // ──────────────────────────────────────────────
87
159
  function stampScrubbed(filePath) {
88
160
  const marker = JSON.stringify({
89
161
  type: SCRUB_MARKER,
90
162
  version: SCRUB_VERSION,
91
163
  scrubbed_at: new Date().toISOString(),
92
- patterns: PATTERNS.length,
93
164
  });
94
165
  fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
95
166
  }
@@ -97,16 +168,14 @@ function stampScrubbed(filePath) {
97
168
  // ──────────────────────────────────────────────
98
169
  // Scrub a single file
99
170
  // ──────────────────────────────────────────────
100
- function scrubFile(filePath, force = false) {
101
- if (!force && isAlreadyScrubbed(filePath)) {
102
- return "skipped";
103
- }
171
+ export function scrubFile(filePath, opts = {}) {
172
+ const { force = false, patterns = PATTERNS, literals = [] } = opts;
173
+ if (!force && isAlreadyScrubbed(filePath)) return "skipped";
104
174
 
105
175
  let content = fs.readFileSync(filePath, "utf-8");
106
176
  let total = 0;
107
177
 
108
- for (const [pattern, replacement] of PATTERNS) {
109
- // Reset lastIndex for global regexes
178
+ for (const [pattern, replacement] of patterns) {
110
179
  pattern.lastIndex = 0;
111
180
  const matches = content.match(pattern);
112
181
  if (matches) {
@@ -115,41 +184,42 @@ function scrubFile(filePath, force = false) {
115
184
  }
116
185
  }
117
186
 
118
- if (total > 0) {
119
- fs.writeFileSync(filePath, content, "utf-8");
187
+ if (literals.length) {
188
+ const r = redactLiterals(content, literals);
189
+ content = r.content;
190
+ total += r.count;
120
191
  }
121
192
 
122
- // Stamp as clean (whether creds found or not)
193
+ if (total > 0) fs.writeFileSync(filePath, content, "utf-8");
123
194
  stampScrubbed(filePath);
124
195
  return total;
125
196
  }
126
197
 
127
198
  // ──────────────────────────────────────────────
128
- // Find all transcript .jsonl files
199
+ // Find all scrubbable files: .jsonl transcripts AND tool-results/*.txt sidecars
129
200
  // ──────────────────────────────────────────────
130
- function findTranscripts() {
131
- const claudeDir = path.join(os.homedir(), ".claude", "projects");
132
- if (!fs.existsSync(claudeDir)) return [];
201
+ const SCRUB_EXTS = new Set([".jsonl", ".txt"]);
133
202
 
203
+ export function findTranscripts(root) {
204
+ const claudeDir = root || path.join(os.homedir(), ".claude", "projects");
205
+ if (!fs.existsSync(claudeDir)) return [];
134
206
  const results = [];
135
207
  function walk(dir) {
136
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
208
+ let entries;
209
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
210
+ for (const entry of entries) {
137
211
  const full = path.join(dir, entry.name);
138
212
  if (entry.isDirectory()) walk(full);
139
- else if (entry.name.endsWith(".jsonl")) results.push(full);
213
+ else if (SCRUB_EXTS.has(path.extname(entry.name))) results.push(full);
140
214
  }
141
215
  }
142
216
  walk(claudeDir);
143
217
  return results;
144
218
  }
145
219
 
146
- // ──────────────────────────────────────────────
147
- // Find the most recent transcript (active session)
148
- // ──────────────────────────────────────────────
149
220
  function findMostRecent() {
150
221
  const files = findTranscripts();
151
222
  if (files.length === 0) return null;
152
-
153
223
  let newest = files[0];
154
224
  let newestMtime = fs.statSync(files[0]).mtimeMs;
155
225
  for (const f of files.slice(1)) {
@@ -159,73 +229,99 @@ function findMostRecent() {
159
229
  return newest;
160
230
  }
161
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
+
162
262
  // ──────────────────────────────────────────────
163
263
  // Exported runner
164
264
  // ──────────────────────────────────────────────
165
265
  export async function runScrub(target, opts = {}) {
166
266
  const force = opts.force || false;
167
267
 
168
- // Determine which files to scrub
268
+ const patterns = [...PATTERNS, ...loadExtraPatterns()];
269
+ const literals = await fetchVaultValues();
270
+
169
271
  let files;
170
272
  if (target === "all") {
171
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);
172
280
  if (files.length === 0) {
173
- console.log(chalk.yellow("\n No transcript files found.\n"));
174
- return;
281
+ const recent = findMostRecent();
282
+ if (!recent) { console.log(chalk.yellow("\n No session transcript found.\n")); return; }
283
+ files = [recent];
175
284
  }
176
- console.log(chalk.cyan(`\n Scrubbing ${files.length} transcript file(s)...\n`));
285
+ console.log(chalk.gray(`\n Session scrub: ${files.length} file(s)`));
177
286
  } else if (target && target !== "all") {
178
- // Specific file
179
287
  const resolved = path.resolve(target);
180
- if (!fs.existsSync(resolved)) {
181
- console.log(chalk.red(`\n File not found: ${resolved}\n`));
182
- process.exit(1);
183
- }
288
+ if (!fs.existsSync(resolved)) { console.log(chalk.red(`\n File not found: ${resolved}\n`)); process.exit(1); }
184
289
  files = [resolved];
185
290
  } else {
186
- // No target — find most recent
187
291
  const recent = findMostRecent();
188
- if (!recent) {
189
- console.log(chalk.yellow("\n No transcript files found.\n"));
190
- return;
191
- }
292
+ if (!recent) { console.log(chalk.yellow("\n No transcript files found.\n")); return; }
192
293
  files = [recent];
193
294
  console.log(chalk.gray(`\n Active transcript: ${path.basename(recent)}`));
194
295
  }
195
296
 
196
- // Scrub
197
- const spinner = ora("Scrubbing credentials...").start();
198
- let grandTotal = 0;
199
- let skipped = 0;
200
- let scanned = 0;
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`));
201
301
 
302
+ const spinner = ora("Scrubbing credentials...").start();
303
+ let grandTotal = 0, skipped = 0, scanned = 0;
202
304
  for (const f of files) {
203
- const result = scrubFile(f, force);
204
- if (result === "skipped") {
205
- skipped++;
206
- } else {
207
- scanned++;
208
- if (result > 0) {
209
- spinner.stop();
210
- console.log(chalk.yellow(` ${result} redaction(s) in ${path.basename(f)}`));
211
- spinner.start("Scrubbing credentials...");
212
- grandTotal += result;
213
- }
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;
214
313
  }
215
314
  }
216
-
217
315
  spinner.stop();
218
316
 
219
- // Summary
220
317
  const parts = [];
221
318
  if (scanned) parts.push(`scanned ${scanned}`);
222
319
  if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
223
320
  if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
224
321
  else if (scanned) parts.push(chalk.green("no credentials found"));
225
-
226
322
  console.log(`\n ${files.length} file(s): ${parts.join(", ")}.`);
227
- if (skipped && !force) {
228
- console.log(chalk.gray(" (use --force to rescan all files)"));
229
- }
323
+ if (skipped && !force) console.log(chalk.gray(" (use --force to rescan all files)"));
230
324
  console.log();
231
325
  }
326
+
327
+ export { PATTERNS, SCRUB_VERSION };
@@ -0,0 +1,115 @@
1
+ // node --test cli/commands/scrub.test.js
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+
8
+ import {
9
+ PATTERNS,
10
+ findTranscripts,
11
+ scrubFile,
12
+ isSecretLike,
13
+ loadExtraPatterns,
14
+ sessionTargets,
15
+ } from "./scrub.js";
16
+
17
+ const GH_TOKEN = "ghp_0123456789abcdefABCDEF0123456789abcd"; // ghp_ + 36 chars
18
+ const VAULT_VALUE = "SuperSecretVaultValue_abc123XYZ"; // secret-like literal
19
+ const CUSTOM_SECRET = "MYCORP-TOKEN-998877"; // only an editable pattern catches it
20
+
21
+ function seedProjects() {
22
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-scrub-"));
23
+ const sess = path.join(root, "proj", "session-uuid");
24
+ const sidecarDir = path.join(sess, "tool-results");
25
+ fs.mkdirSync(sidecarDir, { recursive: true });
26
+
27
+ const jsonl = path.join(root, "proj", "session-uuid.jsonl");
28
+ const txt = path.join(sidecarDir, "toolu_abc.txt");
29
+ const body = `token=${GH_TOKEN} value=${VAULT_VALUE} custom=${CUSTOM_SECRET}\n`;
30
+ fs.writeFileSync(jsonl, `{"x":"${body.trim()}"}\n`, "utf-8");
31
+ fs.writeFileSync(txt, body, "utf-8"); // the sidecar that the old scrubber skipped
32
+ return { root, jsonl, txt };
33
+ }
34
+
35
+ test("findTranscripts discovers .jsonl AND tool-results/*.txt sidecars", () => {
36
+ const { root, jsonl, txt } = seedProjects();
37
+ const found = findTranscripts(root);
38
+ assert.ok(found.includes(jsonl), "should find the .jsonl transcript");
39
+ assert.ok(found.includes(txt), "should find the .txt sidecar (the previously-skipped leak path)");
40
+ });
41
+
42
+ test("scrubFile redacts the github token in BOTH the jsonl and the sidecar", () => {
43
+ const { jsonl, txt } = seedProjects();
44
+ for (const f of [jsonl, txt]) {
45
+ const n = scrubFile(f, { force: true, patterns: PATTERNS, literals: [] });
46
+ assert.ok(n >= 1, `expected >=1 redaction in ${path.basename(f)}`);
47
+ const after = fs.readFileSync(f, "utf-8");
48
+ assert.ok(!after.includes(GH_TOKEN), "github token must be gone");
49
+ assert.ok(after.includes("[GITHUB_TOKEN_REDACTED]"), "redaction marker present");
50
+ assert.ok(after.includes("[CLAUTH-SCRUBBED]"), "file stamped as scrubbed");
51
+ }
52
+ });
53
+
54
+ test("vault-value (literal) redaction removes an arbitrary secret regardless of format", () => {
55
+ const { txt } = seedProjects();
56
+ const n = scrubFile(txt, { force: true, patterns: [], literals: [{ name: "test-svc", value: VAULT_VALUE }] });
57
+ assert.equal(n, 1, "exactly one literal occurrence redacted");
58
+ const after = fs.readFileSync(txt, "utf-8");
59
+ assert.ok(!after.includes(VAULT_VALUE), "vault value must be gone");
60
+ assert.ok(after.includes("[CLAUTH:test-svc_REDACTED]"), "labelled vault redaction present");
61
+ });
62
+
63
+ test("editable patterns file catches a custom secret with no clauth release", () => {
64
+ const cfg = path.join(os.tmpdir(), `clauth-scrub-patterns-${Date.now()}.json`);
65
+ fs.writeFileSync(cfg, JSON.stringify([{ pattern: "MYCORP-TOKEN-\\d+", replacement: "[MYCORP_REDACTED]" }]), "utf-8");
66
+ const extra = loadExtraPatterns(cfg);
67
+ assert.equal(extra.length, 1, "one extra pattern loaded");
68
+
69
+ const { txt } = seedProjects();
70
+ const n = scrubFile(txt, { force: true, patterns: extra, literals: [] });
71
+ assert.ok(n >= 1, "custom pattern matched");
72
+ const after = fs.readFileSync(txt, "utf-8");
73
+ assert.ok(!after.includes(CUSTOM_SECRET), "custom secret gone");
74
+ assert.ok(after.includes("[MYCORP_REDACTED]"), "custom replacement applied");
75
+ fs.rmSync(cfg, { force: true });
76
+ });
77
+
78
+ test("isSecretLike: accepts real tokens, rejects short/url/whitespace values", () => {
79
+ assert.ok(isSecretLike(GH_TOKEN), "long token is secret-like");
80
+ assert.ok(isSecretLike(VAULT_VALUE), "31-char no-space value is secret-like");
81
+ assert.ok(!isSecretLike("short"), "short value rejected");
82
+ assert.ok(!isSecretLike("https://research.regendevcorp.com/mcp"), "plain URL rejected");
83
+ assert.ok(!isSecretLike("has spaces in it value"), "whitespace value rejected");
84
+ assert.ok(!isSecretLike(""), "empty rejected");
85
+ });
86
+
87
+ test("sessionTargets returns ONLY the ending session's transcript + its sidecars", () => {
88
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sess-"));
89
+ const proj = path.join(root, "C--proj");
90
+ const sid = "11112222-3333-4444-5555-666677778888";
91
+ fs.mkdirSync(path.join(proj, sid, "tool-results"), { recursive: true });
92
+ const transcript = path.join(proj, `${sid}.jsonl`);
93
+ const sidecar = path.join(proj, sid, "tool-results", "toolu_x.txt");
94
+ const otherSession = path.join(proj, "99990000-aaaa-bbbb-cccc-ddddeeeeffff.jsonl");
95
+ fs.writeFileSync(transcript, "{}\n");
96
+ fs.writeFileSync(sidecar, "tool output\n");
97
+ fs.writeFileSync(otherSession, "{}\n"); // a DIFFERENT session — must NOT be included
98
+
99
+ const targets = sessionTargets({ transcript_path: transcript, session_id: sid });
100
+ assert.ok(targets.includes(transcript), "includes the session transcript");
101
+ assert.ok(targets.includes(sidecar), "includes the session's sidecar");
102
+ assert.ok(!targets.includes(otherSession), "does NOT include other sessions (session-only)");
103
+ assert.equal(targets.length, 2, "exactly the 2 session files");
104
+
105
+ assert.deepEqual(sessionTargets(null), [], "no hook input → no targets (caller falls back)");
106
+ assert.deepEqual(sessionTargets({ transcript_path: path.join(root, "nope.jsonl") }), [], "missing file → none");
107
+ });
108
+
109
+ test("loadExtraPatterns tolerates a missing/malformed file", () => {
110
+ assert.deepEqual(loadExtraPatterns(path.join(os.tmpdir(), "does-not-exist-xyz.json")), []);
111
+ const bad = path.join(os.tmpdir(), `clauth-bad-${Date.now()}.json`);
112
+ fs.writeFileSync(bad, "{ not json", "utf-8");
113
+ assert.deepEqual(loadExtraPatterns(bad), [], "malformed json → empty, never throws");
114
+ fs.rmSync(bad, { force: true });
115
+ });