residoo 0.2.0 → 0.3.1

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.
package/src/scan.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const path = require("path");
4
4
  const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
5
+ const { findDecodedMatches, findBoundaryMatches, contentProjection } = require("./decode");
5
6
 
6
7
  /**
7
8
  * Text immediately before a match that strongly suggests "this is an example
@@ -16,6 +17,84 @@ const { PATTERNS, NOISY_PATTERNS, redact } = require("./patterns");
16
17
  const SUPPRESS_CONTEXT_RE = /(placeholder|example|sample|dummy|<REDACTED>|xxxxxxxx|your[_-]?(api[_-]?)?key|EXAMPLE)/i;
17
18
  const CONTEXT_WINDOW = 40;
18
19
 
20
+ /**
21
+ * Exact literals that vendors publish in their own documentation as example
22
+ * credentials. These pass every shape check by construction (they ARE the
23
+ * documented shape), and the context heuristic above can't be relied on to
24
+ * catch them: it only looks at the 40 characters BEFORE a match, so "the
25
+ * docs show AKIAIOSFODNN7EXAMPLE as the placeholder" sails straight through.
26
+ * The value itself is the signal here. Same policy as the context heuristic:
27
+ * suppressed by default, counted, re-includable with --include-suppressed.
28
+ * gitleaks and other production scanners filter the AWS pair the same way.
29
+ *
30
+ * Every literal below was verified against the vendor's own published docs
31
+ * (2026-09), not copied from another scanner's allowlist:
32
+ * - AWS's two documented example access key ids, used across the IAM and
33
+ * STS docs (e.g. the GetAccessKeyInfo API reference).
34
+ * - GitHub's documented example tokens from docs.github.com: the REST API
35
+ * getting-started guide's PAT, and the OAuth-apps guide's access +
36
+ * refresh token pair (the same body appears under ghp_ and gho_).
37
+ * - jwt.io's default demo token (header {"alg":"HS256","typ":"JWT"},
38
+ * payload sub 1234567890 / John Doe), the canonical example JWT quoted
39
+ * in tutorials everywhere.
40
+ */
41
+ /**
42
+ * A trailing run of 12+ identical characters inside a matched value. No
43
+ * vendor issues credentials with a repeated-character body — key material is
44
+ * random, and 12 identical characters in a row in a real random body is a
45
+ * ~62^-11 event — but placeholder keys built as prefix + XXXX.../0000... are
46
+ * everywhere in docs and templates, and they match the shape rules by
47
+ * construction. This is a property of the VALUE, so unlike the context
48
+ * heuristic it also works where no surrounding text exists: a placeholder
49
+ * that arrives base64-encoded or split across lines is still zero-entropy
50
+ * after decoding/joining. gitleaks ships equivalent repeated-character
51
+ * allowlists. Anchored to the END of the value on purpose: an INTERIOR run
52
+ * can occur inside a real token (base64 of a zero-byte run is a run of
53
+ * "A"s, so a genuine JWT payload can contain one), but real key material
54
+ * never ends in one, and prefix+XXXX placeholders always do. Same policy
55
+ * as every suppression: counted, re-includable with --include-suppressed,
56
+ * never silently dropped.
57
+ *
58
+ * Implemented as a fixed 12-character look at the END of the value, not as
59
+ * the equivalent anchored-backreference regex /(.)\1{11,}$/ — that regex is
60
+ * O(n^2) on a matched value containing a long INTERIOR identical-character
61
+ * run (the greedy backreference re-tests the anchor at every start
62
+ * position), and such values are reachable: base64 of zero-heavy bytes is a
63
+ * long run of "A"s inside a prefix-matched value. Checking only the last 12
64
+ * code units is exactly equivalent to "ends in 12 or more identical
65
+ * characters" and O(1) whatever the value looks like.
66
+ */
67
+ function zeroEntropyTail(value) {
68
+ if (value.length < 12) return false;
69
+ const last = value.charCodeAt(value.length - 1);
70
+ for (let i = value.length - 12; i < value.length - 1; i++) {
71
+ if (value.charCodeAt(i) !== last) return false;
72
+ }
73
+ return true;
74
+ }
75
+
76
+ const VENDOR_EXAMPLE_VALUES = new Set([
77
+ "AKIAIOSFODNN7EXAMPLE",
78
+ "AKIAI44QH8DHBEXAMPLE",
79
+ "ghp_16C7e42F292c6912E7710c838347Ae178B4a",
80
+ "gho_16C7e42F292c6912E7710c838347Ae178B4a",
81
+ "ghr_1B4a2e77838347a7E420ce178F2E7c6912E169246c34E1ccbF66C46812d16D5B1A9Dc86A1498",
82
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
83
+ // Stripe's two published sample test keys, verified against Stripe's own
84
+ // material (2026-09): the API reference authentication page
85
+ // (docs.stripe.com/api/authentication) embeds the first in its curl
86
+ // example under "A sample test API key is included in all the examples
87
+ // here"; the second is Stripe's long-running docs sample key, present
88
+ // verbatim in Stripe's own repositories (stripe/stripe-java and
89
+ // stripe/stripe-dotnet test suites) and echoed by virtually every Stripe
90
+ // tutorial a transcript might read. Both match stripe_test_key by
91
+ // construction, so without this entry each is reported at high confidence.
92
+ // Written split (prefix + body) so the faithful example literals do not
93
+ // trip GitHub push protection; the Set still holds the whole values.
94
+ "sk_test_" + "BQokikJOvBiI2HlWgH4olfQ2",
95
+ "sk_test_" + "4eC39HqLyjWDarjtT1zdp7dc",
96
+ ]);
97
+
19
98
  /** Matches every finding's own `relFile` convention — never the full path. See SECURITY.md. */
20
99
  function safeName(file) { return path.basename(file); }
21
100
 
@@ -39,6 +118,11 @@ function safeName(file) { return path.basename(file); }
39
118
  */
40
119
  async function scan({ sources, includeNoisy = false, includeSuppressed = false, onProgress = null } = {}) {
41
120
  const rules = includeNoisy ? PATTERNS.concat(NOISY_PATTERNS) : PATTERNS;
121
+ // The decode pass (see decode.js) only applies high-confidence, vendor-
122
+ // prefixed rules to decoded bytes: random binary that decodes to printable
123
+ // text can shape-match a generic rule, but not a vendor prefix. NOISY rules
124
+ // are low confidence and never qualify.
125
+ const highRules = rules.filter((r) => r.confidence === "high");
42
126
  const findings = [];
43
127
  let suppressedCount = 0;
44
128
  let filesScanned = 0;
@@ -52,35 +136,105 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
52
136
  // never leaves this function.
53
137
  const distinctByRule = new Map();
54
138
 
139
+ // One place raw matched text turns into a recorded finding: counts the
140
+ // distinct value and pushes the redacted record. `extra` carries the
141
+ // encoding / split markers for the decode and boundary passes; the raw pass
142
+ // passes none.
143
+ const record = (rule, value, relFile, file, lineNo, mtimeMs, confidence, suppressedReason, extra) => {
144
+ if (!distinctByRule.has(rule.id)) distinctByRule.set(rule.id, new Set());
145
+ distinctByRule.get(rule.id).add(value);
146
+ findings.push({
147
+ ruleId: rule.id,
148
+ label: rule.label,
149
+ confidence,
150
+ suppressedReason: suppressedReason || null,
151
+ source: relFile.source,
152
+ file, relFile: relFile.name,
153
+ line: lineNo,
154
+ preview: redact(value),
155
+ fileMTimeMs: mtimeMs,
156
+ ...(extra || {}),
157
+ });
158
+ };
159
+
160
+ // One suppression policy for all three passes (raw, decoded, boundary).
161
+ // The value-based checks run first: they are exact properties of the match
162
+ // itself, so they apply identically to a value found raw, decoded out of
163
+ // base64, or reconstructed across a line boundary — a decoded vendor
164
+ // example is the same non-secret as a plain one. The context heuristic is
165
+ // last and only where surrounding text exists (`before` is null for the
166
+ // decode and boundary passes, whose transforms have no stable "40 chars
167
+ // before" in the original line).
168
+ const suppressionReason = (value, before) => {
169
+ if (VENDOR_EXAMPLE_VALUES.has(value)) return "vendor-documented example value";
170
+ if (zeroEntropyTail(value)) return "zero-entropy body";
171
+ if (before !== null && SUPPRESS_CONTEXT_RE.test(before)) return "placeholder-like context";
172
+ return null;
173
+ };
174
+
55
175
  const matchLine = (line, file, relFile, lineNo, mtimeMs) => {
56
176
  for (const rule of rules) {
57
177
  rule.re.lastIndex = 0; // rules are reused across files; reset global regex state
58
178
  let m;
59
179
  while ((m = rule.re.exec(line)) !== null) {
60
180
  const before = line.slice(Math.max(0, m.index - CONTEXT_WINDOW), m.index);
61
- const looksLikePlaceholder = SUPPRESS_CONTEXT_RE.test(before);
62
- if (looksLikePlaceholder && !includeSuppressed) {
181
+ const suppressedReason = suppressionReason(m[0], before);
182
+ if (suppressedReason && !includeSuppressed) {
63
183
  suppressedCount++;
64
184
  } else {
65
- if (!distinctByRule.has(rule.id)) distinctByRule.set(rule.id, new Set());
66
- distinctByRule.get(rule.id).add(m[0]);
67
- findings.push({
68
- ruleId: rule.id,
69
- label: rule.label,
70
- confidence: looksLikePlaceholder ? "low" : rule.confidence,
71
- suppressedReason: looksLikePlaceholder ? "placeholder-like context" : null,
72
- source: relFile.source,
73
- file, relFile: relFile.name,
74
- line: lineNo,
75
- preview: redact(m[0]),
76
- fileMTimeMs: mtimeMs,
77
- });
185
+ record(rule, m[0], relFile, file, lineNo,
186
+ mtimeMs,
187
+ suppressedReason ? "low" : rule.confidence,
188
+ suppressedReason);
78
189
  }
79
190
  if (m.index === rule.re.lastIndex) rule.re.lastIndex++; // guard zero-width matches
80
191
  }
81
192
  }
82
193
  };
83
194
 
195
+ // Feature 1: base64 decode-then-rescan. A finding here means a credential
196
+ // was present only encoded on this line. It redacts from the DECODED value
197
+ // (the encoded run is treated as secret material and never appears in the
198
+ // preview), and carries an `encoding` marker the report renders as
199
+ // "base64-wrapped". Returns true when the per-line candidate cap left
200
+ // encoded runs on this line unchecked, so the caller can flag the file as
201
+ // only partially checked instead of staying silent about the gap.
202
+ const decodeLine = (line, file, relFile, lineNo, mtimeMs) => {
203
+ const { matches, truncated } = findDecodedMatches(line, highRules);
204
+ for (const d of matches) {
205
+ const suppressedReason = suppressionReason(d.value, null);
206
+ if (suppressedReason && !includeSuppressed) {
207
+ suppressedCount++;
208
+ continue;
209
+ }
210
+ record({ id: d.ruleId, label: d.label }, d.value, relFile, file, lineNo,
211
+ mtimeMs, suppressedReason ? "low" : "high", suppressedReason, { encoding: d.encoding });
212
+ }
213
+ return truncated;
214
+ };
215
+
216
+ // Feature 2: split-line boundary join. A finding here means one credential
217
+ // was split across this line and the next and is contiguous on neither. It
218
+ // is recorded against BOTH contributing lines (each holds a fragment of the
219
+ // exposed secret) and carries a `spanLines` marker. `contentA`/`contentB`
220
+ // are the two lines' content projections, computed once per line by the
221
+ // caller and reused across both of a line's pairs.
222
+ const boundaryPair = (contentA, contentB, file, relFile, lineNoA, mtimeMs) => {
223
+ for (const b of findBoundaryMatches(contentA, contentB, rules)) {
224
+ const suppressedReason = suppressionReason(b.value, null);
225
+ if (suppressedReason && !includeSuppressed) {
226
+ // One straddling match is one suppressed match, even though an
227
+ // unsuppressed one records against both contributing lines.
228
+ suppressedCount++;
229
+ continue;
230
+ }
231
+ const span = [lineNoA, lineNoA + 1];
232
+ const conf = suppressedReason ? "low" : b.confidence;
233
+ record({ id: b.ruleId, label: b.label }, b.value, relFile, file, lineNoA, mtimeMs, conf, suppressedReason, { spanLines: span });
234
+ record({ id: b.ruleId, label: b.label }, b.value, relFile, file, lineNoA + 1, mtimeMs, conf, suppressedReason, { spanLines: span });
235
+ }
236
+ };
237
+
84
238
  for (const source of sources) {
85
239
  let sourceScannedAnything = false;
86
240
 
@@ -134,8 +288,47 @@ async function scan({ sources, includeNoisy = false, includeSuppressed = false,
134
288
  bytesScanned += bytesRead || sizeBytes || 0;
135
289
 
136
290
  const relFile = { name: safeName(file), source: source.id() };
291
+ // Content projection of the PREVIOUS line, kept so each line is
292
+ // projected once and reused for both pairs it belongs to.
293
+ let prevContent = null;
294
+ // Per-file degradation flags, each surfaced at most once so a
295
+ // pathological file produces one visible entry, not thousands.
296
+ let lineMatchFailed = false;
297
+ let decodeTruncated = false;
137
298
  for (let i = 0; i < lines.length; i++) {
138
- if (lines[i]) matchLine(lines[i], file, relFile, i + 1, mtimeMs);
299
+ const line = lines[i];
300
+ if (line) {
301
+ // A rule regex itself can throw on adversarial input: V8's
302
+ // backtrack stack overflows (RangeError) when an open-ended
303
+ // quantifier meets a prefix followed by a multi-megabyte
304
+ // same-charset run — real transcripts contain such lines. One
305
+ // unmatched line must degrade to a visible per-file flag, never
306
+ // abort the scan and discard every finding already collected
307
+ // (same contract as the readLines catch above).
308
+ try {
309
+ matchLine(line, file, relFile, i + 1, mtimeMs);
310
+ decodeTruncated = decodeLine(line, file, relFile, i + 1, mtimeMs) || decodeTruncated;
311
+ const content = contentProjection(line);
312
+ // Boundary join with the previous line (2-way splits only; see
313
+ // decode.js). Both lines must be non-empty so a blank separator
314
+ // never forms a spurious pair.
315
+ if (prevContent !== null) {
316
+ boundaryPair(prevContent, content, file, relFile, i, mtimeMs);
317
+ }
318
+ prevContent = content;
319
+ } catch (err) {
320
+ if (!lineMatchFailed) {
321
+ lineMatchFailed = true;
322
+ unreadableFiles.push({ file: safeName(file), reason: "some lines could not be matched" });
323
+ }
324
+ prevContent = null;
325
+ }
326
+ } else {
327
+ prevContent = null;
328
+ }
329
+ }
330
+ if (decodeTruncated) {
331
+ unreadableFiles.push({ file: safeName(file), reason: "some lines held more encoded runs than the per-line bound; checked partially" });
139
332
  }
140
333
  }
141
334
 
@@ -160,4 +353,7 @@ function emptyResult() {
160
353
  };
161
354
  }
162
355
 
163
- module.exports = { scan, emptyResult };
356
+ // VENDOR_EXAMPLE_VALUES is exported for the smoke tests, which assert every
357
+ // literal in it is still matched IN FULL by some detection rule — a literal
358
+ // no rule can produce as a whole match is dead weight that suppresses nothing.
359
+ module.exports = { scan, emptyResult, VENDOR_EXAMPLE_VALUES };
@@ -19,15 +19,28 @@ const { createInterface } = require("readline/promises");
19
19
  * Bitwarden-CLI-hijack write-up, StepSecurity's Nx Console analysis, the
20
20
  * keyv/Shai-Hulud reports) name several of the paths below verbatim.
21
21
  *
22
- * SCOPE — home-level only, and that limitation is real, not rhetorical:
23
- * project-level configs (`.mcp.json`, `.claude/settings.json`,
24
- * `.cursor/rules/`, `.vscode/tasks.json`, per-repo CLAUDE.md/AGENTS.md
25
- * the files Miasma actually planted in cloned repos) live inside arbitrary
26
- * repositories this tool has no way to enumerate from a home directory.
27
- * A clean report from this source therefore says nothing about any
28
- * project's own config files. v1 ships the home-level set because those
29
- * paths are fixed and verifiable; the project-level gap is stated here
30
- * rather than papered over.
22
+ * SCOPE — fixed home-level paths, plus project-level Claude Code configs
23
+ * reachable through the agent's own breadcrumbs. Project configs live
24
+ * inside arbitrary repositories that no home-level scanner can enumerate
25
+ * by walking a directory tree but the agent itself records every project
26
+ * root it has been used in, at home level: `~/.claude.json` keeps a
27
+ * top-level `projects` map keyed by absolute project path (vendor-
28
+ * documented per-project state), and every transcript under
29
+ * `~/.claude/projects/<slug>/` carries the project's absolute path in its
30
+ * records' `cwd` field (vendor JSONL schema). Following those recorded
31
+ * roots to the vendor-FIXED per-project config filenames (`.mcp.json`,
32
+ * `.claude/settings.json`, `.claude/settings.local.json` — the exact
33
+ * files GitGuardian measured 24k secrets in and Lakera found shipped in
34
+ * npm packages) is a general mechanism, not path guessing: nothing is
35
+ * discovered by scanning repositories, only by resolving what the agent
36
+ * already wrote down. Limits, stated: a machine whose agent state was
37
+ * wiped yields no roots (discovery degrades to absence, the same
38
+ * information a human reading the state would have); other agents'
39
+ * project-level configs (`.cursor/rules/`, `.vscode/tasks.json`, per-repo
40
+ * CLAUDE.md/AGENTS.md — the files Miasma actually planted) stay
41
+ * uncovered until their home-level state formats are verified to the same
42
+ * bar; and a clean report still says nothing about repositories the agent
43
+ * was never pointed at.
31
44
  *
32
45
  * PER-PATH VERIFICATION (per CONTRIBUTING.md's no-guessed-paths rule —
33
46
  * "real install" below means the populated machine this source was built
@@ -96,8 +109,12 @@ const { createInterface } = require("readline/promises");
96
109
  * Bitwarden-CLI target list.
97
110
  *
98
111
  * DELIBERATELY NOT READ, and why:
99
- * - `~/.claude/projects/**` — claude-code.js's territory. Overlapping it
100
- * would double-report every finding. (Named side effect: a
112
+ * - `~/.claude/projects/**` AS SCAN CONTENT — claude-code.js's territory;
113
+ * overlapping it would double-report every finding. The project-root
114
+ * discovery below does open transcripts, but only to read the `cwd`
115
+ * field out of the first records (a bounded probe, nothing from the
116
+ * content is ever reported), which indexes projects without scanning
117
+ * a single transcript line here. (Named side effect: a
101
118
  * `projects/<slug>/memory/MEMORY.md` is covered by NEITHER source
102
119
  * today — a real gap that belongs to the transcript source's scope
103
120
  * discussion, recorded here so it isn't mistaken for covered.)
@@ -257,9 +274,176 @@ function* statIfPresent(p) {
257
274
  yield { file: p, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
258
275
  }
259
276
 
260
- /** Yield { file, mtimeMs, sizeBytes, broken } for every candidate present. */
277
+ // ── project-level config discovery ──────────────────────────────────────────
278
+ //
279
+ // The per-project config filenames are vendor-fixed (Claude Code's own docs:
280
+ // project-scope MCP servers in `.mcp.json`, shared settings in
281
+ // `.claude/settings.json`, local settings in `.claude/settings.local.json`);
282
+ // what varies per machine is only WHERE the project roots are, and the agent
283
+ // records exactly that in home-level state. Two independent record sources,
284
+ // both vendor artifacts, both read best-effort (a failure to discover a root
285
+ // is absence, never an error — see the SCOPE limits in the header):
286
+ //
287
+ // 1. `~/.claude.json`'s top-level `projects` object, keyed by absolute
288
+ // project path. Already a scan candidate above; parsed here a second
289
+ // time only for its keys.
290
+ // 2. The `cwd` field in transcript records under
291
+ // `~/.claude/projects/<slug>/`. The slug itself also encodes the path,
292
+ // but lossily (path separators and dashes collapse into the same
293
+ // character), so the record field is the reliable form. Only the first
294
+ // few records of the first few files per slug directory are probed,
295
+ // bounded by bytes and line count: every session in a slug directory
296
+ // shares one project root by construction.
297
+ //
298
+ // A recorded root that no longer exists usually means the project was
299
+ // deleted — except when the WHOLE home tree has been relocated (a mounted
300
+ // backup, a copied disk image, HOME pinned at a snapshot for auditing), in
301
+ // which case every recorded absolute path is stale by the same prefix. That
302
+ // case is detected generally: a missing root whose prefix is shaped like a
303
+ // home directory (macOS /Users/<u>, Linux /home/<u>, Windows
304
+ // <drive>:\Users\<u>) is retried at the same home-relative path under the
305
+ // CURRENT home, and used only if that directory actually exists. Non-default
306
+ // home locations (e.g. /srv/data/<u>) defeat the re-rooting and are a stated
307
+ // limit, not a silent one.
308
+ //
309
+ // Recorded roots are data read from transcripts and state files, which a
310
+ // hostile transcript can influence — so nothing is ever globbed or walked
311
+ // beneath them: only the three fixed basenames are lstat'd, reads stay
312
+ // read-only through the same readLines every candidate gets, and anything
313
+ // matched is redacted by the report layer like every other finding.
314
+
315
+ const CLAUDE_STATE_JSON = path.join(HOME, ".claude.json");
316
+ const CLAUDE_PROJECTS_DIR = path.join(CLAUDE_DIR, "projects");
317
+
318
+ const PROJECT_CONFIG_RELPATHS = [
319
+ ".mcp.json",
320
+ path.join(".claude", "settings.json"),
321
+ path.join(".claude", "settings.local.json"),
322
+ ];
323
+
324
+ // Probe bounds: transcript first-records are KB-scale; 256KB and 20 lines is
325
+ // headroom, not a tuned fit. One resolving file per slug directory suffices.
326
+ const CWD_PROBE_BYTES = 256 * 1024;
327
+ const CWD_PROBE_LINES = 20;
328
+ const CWD_PROBE_FILES_PER_DIR = 3;
329
+
330
+ function rootsFromClaudeState() {
331
+ let stat;
332
+ try { stat = fs.statSync(CLAUDE_STATE_JSON); } catch { return []; }
333
+ if (!stat.isFile() || stat.size > MAX_BYTES) return [];
334
+ try {
335
+ const doc = JSON.parse(fs.readFileSync(CLAUDE_STATE_JSON, "utf-8"));
336
+ if (doc && typeof doc === "object" && doc.projects &&
337
+ typeof doc.projects === "object" && !Array.isArray(doc.projects)) {
338
+ return Object.keys(doc.projects).filter((k) => typeof k === "string" && k.length > 0);
339
+ }
340
+ } catch {
341
+ // Unparseable state: discovery loses this index, but the file itself is
342
+ // still scanned line-by-line as a fixed candidate above, so no content
343
+ // goes unexamined because of a parse failure here.
344
+ }
345
+ return [];
346
+ }
347
+
348
+ /** First string `cwd` in the leading records of one transcript, or null. */
349
+ function firstCwdIn(file) {
350
+ let fd = null;
351
+ try {
352
+ fd = fs.openSync(file, "r");
353
+ const buf = Buffer.alloc(CWD_PROBE_BYTES);
354
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
355
+ // A line truncated at the probe boundary simply fails JSON.parse and is
356
+ // skipped — the bound costs recall on that one record, never a crash.
357
+ const lines = buf.toString("utf-8", 0, n).split("\n", CWD_PROBE_LINES);
358
+ for (const line of lines) {
359
+ try {
360
+ const rec = JSON.parse(line);
361
+ if (rec && typeof rec === "object" && typeof rec.cwd === "string" && rec.cwd) return rec.cwd;
362
+ } catch { /* meta/summary records and non-JSON lines: keep looking */ }
363
+ }
364
+ } catch {
365
+ // Unreadable transcript: claude-code.js owns surfacing that; a root
366
+ // index probe must not duplicate its error reporting.
367
+ } finally {
368
+ if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
369
+ }
370
+ return null;
371
+ }
372
+
373
+ function rootsFromTranscriptDirs() {
374
+ let dirs;
375
+ try { dirs = fs.readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true }); }
376
+ catch { return []; }
377
+ const roots = [];
378
+ for (const d of dirs) {
379
+ if (!d.isDirectory()) continue;
380
+ const dir = path.join(CLAUDE_PROJECTS_DIR, d.name);
381
+ let entries;
382
+ // An unlistable slug directory is not silently swallowed overall:
383
+ // claude-code.js's own files() walk reports that same directory as
384
+ // broken; this index probe just loses one root.
385
+ try { entries = fs.readdirSync(dir); } catch { continue; }
386
+ const sessions = entries.filter((n) => n.endsWith(".jsonl")).sort();
387
+ for (const name of sessions.slice(0, CWD_PROBE_FILES_PER_DIR)) {
388
+ const cwd = firstCwdIn(path.join(dir, name));
389
+ if (cwd) { roots.push(cwd); break; }
390
+ }
391
+ }
392
+ return roots;
393
+ }
394
+
395
+ // Default home-directory shapes for the three platforms Claude Code ships
396
+ // on. Anchored and existence-checked, never a rewrite of arbitrary paths.
397
+ const HOME_SHAPED_PREFIX = /^(?:\/(?:Users|home)\/[^/]+|[A-Za-z]:[\\/]Users[\\/][^\\/]+)(?=[\\/]|$)/;
398
+
399
+ /** Resolve one recorded project root to an existing directory, or null. */
400
+ function resolveRecordedRoot(recorded) {
401
+ try { if (fs.statSync(recorded).isDirectory()) return recorded; } catch { /* fall through to re-rooting */ }
402
+ const m = HOME_SHAPED_PREFIX.exec(recorded);
403
+ if (!m) return null;
404
+ const rest = recorded.slice(m[0].length).split(/[\\/]+/).filter(Boolean);
405
+ const rehomed = rest.length === 0 ? HOME : path.join(HOME, ...rest);
406
+ try { if (fs.statSync(rehomed).isDirectory()) return rehomed; } catch { /* relocated copy absent too */ }
407
+ return null;
408
+ }
409
+
410
+ /**
411
+ * Yield candidate entries for every discovered project root's fixed config
412
+ * filenames, deduplicated against paths already yielded (a recorded root
413
+ * that resolves to the home directory itself would otherwise re-yield
414
+ * `~/.claude/settings.json`). Sorted for deterministic output across runs —
415
+ * readdir order is not.
416
+ */
417
+ function* projectConfigCandidates(seenResolved) {
418
+ const recorded = new Set([...rootsFromClaudeState(), ...rootsFromTranscriptDirs()]);
419
+ const resolved = new Set();
420
+ for (const r of recorded) {
421
+ const root = resolveRecordedRoot(r);
422
+ if (root) resolved.add(path.resolve(root));
423
+ }
424
+ for (const root of [...resolved].sort()) {
425
+ for (const rel of PROJECT_CONFIG_RELPATHS) {
426
+ const p = path.join(root, rel);
427
+ const key = path.resolve(p);
428
+ if (seenResolved.has(key)) continue;
429
+ seenResolved.add(key);
430
+ yield* statIfPresent(p);
431
+ }
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Yield { file, mtimeMs, sizeBytes, broken } for every candidate present:
437
+ * the fixed home-level paths first, then per-project configs at the roots
438
+ * the agent's own state records (see the discovery block above).
439
+ */
261
440
  function* files() {
262
- for (const p of CANDIDATES) yield* statIfPresent(p);
441
+ const seen = new Set();
442
+ for (const p of CANDIDATES) {
443
+ seen.add(path.resolve(p));
444
+ yield* statIfPresent(p);
445
+ }
446
+ yield* projectConfigCandidates(seen);
263
447
  }
264
448
 
265
449
  /**