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/README.md +145 -9
- package/package.json +1 -1
- package/src/cli.js +171 -5
- package/src/decode.js +416 -0
- package/src/integrity.js +55 -35
- package/src/patterns.js +19 -1
- package/src/report.js +136 -5
- package/src/rotation.js +848 -0
- package/src/scan.js +213 -17
- package/src/sources/agent-configs.js +197 -13
- package/src/sources/project-artifacts.js +355 -0
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
|
|
62
|
-
if (
|
|
181
|
+
const suppressedReason = suppressionReason(m[0], before);
|
|
182
|
+
if (suppressedReason && !includeSuppressed) {
|
|
63
183
|
suppressedCount++;
|
|
64
184
|
} else {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* project
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
100
|
-
* would double-report every finding.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|