residoo 0.2.0 → 0.3.0
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 +111 -2
- package/package.json +1 -1
- package/src/cli.js +171 -5
- package/src/integrity.js +55 -35
- package/src/report.js +117 -4
- package/src/rotation.js +834 -0
- package/src/sources/project-artifacts.js +355 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { createInterface } = require("readline/promises");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Committed agent artifacts inside a PROJECT directory (a repo checkout).
|
|
9
|
+
*
|
|
10
|
+
* Every other source scans the machine's home-level stores: what the agent
|
|
11
|
+
* wrote for itself. This one scans what a repo is about to ship: transcripts,
|
|
12
|
+
* agent configs, and .env files sitting inside a checkout, where `git add .`
|
|
13
|
+
* and `npm publish` will carry them to everyone. The evidence that this is
|
|
14
|
+
* where the bodies are buried is the strongest in the whole research base:
|
|
15
|
+
* GitGuardian measured Claude Code-assisted commits leaking at 3.2% vs the
|
|
16
|
+
* 1.5% GitHub baseline; Lakera found live credentials inside
|
|
17
|
+
* `.claude/settings.local.json` files shipped in ~30 published npm packages
|
|
18
|
+
* precisely because no packaging tool ignores `.claude/` by default; and the
|
|
19
|
+
* Miasma campaign infected on repo OPEN via planted `.claude/settings.json`,
|
|
20
|
+
* `.gemini/settings.json`, `.cursor/rules/setup.mdc`, and `.vscode/tasks.json`
|
|
21
|
+
* (see the research digest, 2026-09-02, and integrity.js's campaign headers).
|
|
22
|
+
*
|
|
23
|
+
* OPT-IN BY CONSTRUCTION, never part of the default scan. The registry in
|
|
24
|
+
* index.js holds one singleton per source and filters with available(); a
|
|
25
|
+
* project scan needs a PARAMETER (which directory), and a singleton cannot
|
|
26
|
+
* carry one honestly. Two designs were considered:
|
|
27
|
+
*
|
|
28
|
+
* - setRoot() mutating this module's singleton: rejected. The registry
|
|
29
|
+
* object is shared process-wide, so one scan's --project argument would
|
|
30
|
+
* leak into any later scan in the same process, and "available() is
|
|
31
|
+
* false unless configured" would silently stop being true in a way no
|
|
32
|
+
* local reading of this file could reveal.
|
|
33
|
+
* - withRoot(root) factory: chosen. The module's default export still
|
|
34
|
+
* satisfies the full { id, label, available, files, readLines } contract
|
|
35
|
+
* (so registering it in index.js is harmless: available() is always
|
|
36
|
+
* false and files() yields nothing), while the CLI's --project handling
|
|
37
|
+
* constructs a configured instance and passes it straight into
|
|
38
|
+
* scan({ sources: [...] }). No shared mutable state, no registry change.
|
|
39
|
+
*
|
|
40
|
+
* WHAT IS SCANNED, with the verification trail per CONTRIBUTING.md's
|
|
41
|
+
* no-guessed-paths rule ("real install" means this project's own build
|
|
42
|
+
* machine, checked read-only):
|
|
43
|
+
*
|
|
44
|
+
* (a) Committed agent transcripts:
|
|
45
|
+
* - `*.jsonl` under any `.claude/` path component. Claude Code's own
|
|
46
|
+
* transcript layout is `<root>/projects/<slug>/<session>.jsonl` (real
|
|
47
|
+
* install, and claude-code.js's territory at home level); a copy of any
|
|
48
|
+
* part of that tree committed into a repo keeps the `.claude` component.
|
|
49
|
+
* - `*.jsonl` inside a directory whose name starts with "-": the
|
|
50
|
+
* project-slug shape Claude Code uses (the absolute project path with
|
|
51
|
+
* separators replaced by "-", e.g. `-Users-.../<uuid>.jsonl`; verified
|
|
52
|
+
* against the real install's ~/.claude/projects). A slug directory
|
|
53
|
+
* copied into a repo WITHOUT its `.claude` parent still matches this.
|
|
54
|
+
* - `rollout-*.jsonl` at any depth: Codex CLI's per-session file naming,
|
|
55
|
+
* corroborated in codex-cli.js's header (openai/codex issues #21660 and
|
|
56
|
+
* the archived-sessions issue both name `rollout-*.jsonl` verbatim).
|
|
57
|
+
* - any file under a `.specstory/` path component: SpecStory saves
|
|
58
|
+
* Cursor/Copilot chat history as Markdown into `.specstory/history/`
|
|
59
|
+
* inside the project, and its own docs describe committing that
|
|
60
|
+
* directory to share reasoning in PRs (docs.specstory.com/integrations/
|
|
61
|
+
* cursor; github.com/specstoryai/getspecstory). This is the one
|
|
62
|
+
* Cursor-export shape with a stable, citable on-disk location.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately NOT matched, and why:
|
|
65
|
+
* - Cursor's built-in "export chat" output: the exported Markdown carries
|
|
66
|
+
* no stable name (community exporters observed during research use
|
|
67
|
+
* "{chat title}_{session id}.md", bare timestamps, and other schemes
|
|
68
|
+
* that disagree with each other). Any filename matcher here would be a
|
|
69
|
+
* guessed path; matching all `*.md` would scan every doc in the repo.
|
|
70
|
+
* A clean run therefore says nothing about hand-exported chat files.
|
|
71
|
+
* - generic `*.jsonl` anywhere: repos legitimately hold JSONL datasets
|
|
72
|
+
* and fixtures far larger than any transcript; scanning them all would
|
|
73
|
+
* drown the honest signal. The three transcript shapes above are the
|
|
74
|
+
* ones with citable naming.
|
|
75
|
+
*
|
|
76
|
+
* (b) Agent config/rules files at ANY depth (monorepos nest them):
|
|
77
|
+
* - `.claude/settings*.json` (settings.json, settings.local.json: the
|
|
78
|
+
* Lakera leak vector and the Mini Shai-Hulud/Miasma plant site)
|
|
79
|
+
* - `.mcp.json` (project-scope MCP config, Claude Code's own docs; the
|
|
80
|
+
* GitGuardian 24,008-secrets-in-MCP-configs category)
|
|
81
|
+
* - `.cursor/rules/*` (Miasma's setup.mdc plant site)
|
|
82
|
+
* - `.cursorrules` (TrapDoor's zero-width carrier)
|
|
83
|
+
* - `CLAUDE.md` and `CLAUDE.local.md` (Claude Code memory files, per its
|
|
84
|
+
* own memory docs; the other TrapDoor carrier)
|
|
85
|
+
* - `AGENTS.md` (the cross-vendor agent-instructions convention Codex and
|
|
86
|
+
* others load; codex-cli.js's research trail covers it)
|
|
87
|
+
* - `.gemini/settings.json` (Miasma plant site)
|
|
88
|
+
* - `.vscode/tasks.json` (the "runOn": "folderOpen" persistence surface)
|
|
89
|
+
*
|
|
90
|
+
* (c) `.env` files at the ROOT only (`.env`, `.env.local`, `.env.production`,
|
|
91
|
+
* `.env.example`, any `.env.*`). The root .env is the classic accidental
|
|
92
|
+
* commit. Deeper .env files are very often fixtures, scaffold templates,
|
|
93
|
+
* and per-package samples; a monorepo's `packages/x/.env` is therefore a
|
|
94
|
+
* NAMED exclusion with a real false-negative risk, not an oversight.
|
|
95
|
+
* Revisit with evidence if deeper .envs prove to leak in practice.
|
|
96
|
+
* `.env.example` at the root IS included on purpose: a real key pasted
|
|
97
|
+
* into an example file gets committed by design, and scan.js's
|
|
98
|
+
* placeholder suppression already keeps template content quiet.
|
|
99
|
+
*
|
|
100
|
+
* NOT walked at all: the CONTENTS of `node_modules/` and `.git/`.
|
|
101
|
+
* node_modules is other people's published code (a scan of it is an audit of
|
|
102
|
+
* the npm registry, not of this repo, and it blows any node budget on every
|
|
103
|
+
* real project); .git holds zlib-compressed objects the line engine cannot
|
|
104
|
+
* read meaningfully. Both skips are unconditional and silent because the
|
|
105
|
+
* directories are expected on virtually every repo; a skipped EXPECTED
|
|
106
|
+
* directory is not a truncation. Every UNEXPECTED cut (depth cap, node cap,
|
|
107
|
+
* unreadable directory) is surfaced as a broken entry instead, because a
|
|
108
|
+
* bounded walk that ends quietly is a false all-clear (CONTRIBUTING.md
|
|
109
|
+
* rule 5).
|
|
110
|
+
*
|
|
111
|
+
* Symlinks are followed like claude-code.js (see its isKindFollowingSymlink
|
|
112
|
+
* docstring for the lstat-vs-stat reasoning) but CONTAINED to the project
|
|
113
|
+
* root by realpath: a directory or candidate file that resolves outside the
|
|
114
|
+
* root is never walked or read, and is surfaced as a broken (not fully
|
|
115
|
+
* scanned) entry instead of silently skipped. Without containment a
|
|
116
|
+
* committed symlink ("vendored -> ../../somewhere") would pull the invoking
|
|
117
|
+
* machine's own files into the repo verdict, which is precisely the
|
|
118
|
+
* wrong-thing claim project mode exists to prevent: this scan's verdict is
|
|
119
|
+
* about the checkout, never about the machine around it. Directory symlink
|
|
120
|
+
* loops are cut with a realpath visited-set rather than left to the node
|
|
121
|
+
* cap, so a loop cannot eat the whole node budget before legitimate files
|
|
122
|
+
* are reached.
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
const MAX_DEPTH = 12; // deep enough for any real monorepo layout; a
|
|
126
|
+
// deeper tree gets a broken entry, not silence
|
|
127
|
+
const MAX_NODES = 20_000; // directory entries examined, not files yielded
|
|
128
|
+
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
129
|
+
|
|
130
|
+
// Committed transcripts are the same artifact class claude-code.js reads at
|
|
131
|
+
// home level, so the same bound applies: generous headroom over the largest
|
|
132
|
+
// real transcript this project has been tested against (818MB).
|
|
133
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
134
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
135
|
+
|
|
136
|
+
const ID = "project-artifacts";
|
|
137
|
+
const LABEL = "Project artifacts";
|
|
138
|
+
|
|
139
|
+
// Same shape as claude-code.js; duplicated per the one-file-per-source
|
|
140
|
+
// convention (each source stays auditable on its own).
|
|
141
|
+
function isKindFollowingSymlink(fullPath, dirent, checkFn) {
|
|
142
|
+
if (checkFn(dirent)) return true;
|
|
143
|
+
if (!dirent.isSymbolicLink()) return false;
|
|
144
|
+
try { return checkFn(fs.statSync(fullPath)); } catch { return false; }
|
|
145
|
+
}
|
|
146
|
+
const isDirFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isDirectory());
|
|
147
|
+
const isFileFollowingSymlink = (p, d) => isKindFollowingSymlink(p, d, (x) => x.isFile());
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decide whether one regular file is a candidate, given its path segments
|
|
151
|
+
* relative to the root (segs includes the basename; depth 0 means the file
|
|
152
|
+
* sits directly in the root). Pure function, no filesystem access, so the
|
|
153
|
+
* whole inclusion policy is testable in one place.
|
|
154
|
+
*/
|
|
155
|
+
function isCandidate(segs) {
|
|
156
|
+
const name = segs[segs.length - 1];
|
|
157
|
+
const parent = segs.length >= 2 ? segs[segs.length - 2] : null;
|
|
158
|
+
const inClaudeDir = segs.slice(0, -1).includes(".claude");
|
|
159
|
+
const inSpecstoryDir = segs.slice(0, -1).includes(".specstory");
|
|
160
|
+
const inCursorRules = segs.slice(0, -1).some(
|
|
161
|
+
(s, i) => s === ".cursor" && segs[i + 1] === "rules" && i + 1 < segs.length - 1
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// (a) transcripts
|
|
165
|
+
if (name.endsWith(".jsonl")) {
|
|
166
|
+
if (inClaudeDir) return true;
|
|
167
|
+
if (parent && parent.startsWith("-")) return true; // claude-projects slug shape
|
|
168
|
+
if (/^rollout-.*\.jsonl$/.test(name)) return true; // Codex session naming
|
|
169
|
+
}
|
|
170
|
+
if (inSpecstoryDir) return true;
|
|
171
|
+
|
|
172
|
+
// (b) configs, any depth
|
|
173
|
+
if (parent === ".claude" && /^settings.*\.json$/.test(name)) return true;
|
|
174
|
+
if (name === ".mcp.json") return true;
|
|
175
|
+
if (inCursorRules) return true;
|
|
176
|
+
if (name === ".cursorrules") return true;
|
|
177
|
+
if (name === "CLAUDE.md" || name === "CLAUDE.local.md") return true;
|
|
178
|
+
if (name === "AGENTS.md") return true;
|
|
179
|
+
if (parent === ".gemini" && name === "settings.json") return true;
|
|
180
|
+
if (parent === ".vscode" && name === "tasks.json") return true;
|
|
181
|
+
|
|
182
|
+
// (c) root-level .env family only; see the header for why depth matters
|
|
183
|
+
if (segs.length === 1 && /^\.env(\..+)?$/.test(name)) return true;
|
|
184
|
+
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Same streaming reader as claude-code.js and agent-configs.js (see the
|
|
190
|
+
* former for the timeout rationale: an open() on a retargeted symlink can
|
|
191
|
+
* block forever, and destroying the stream is the only way out). Standalone
|
|
192
|
+
* so both the disabled default export and every withRoot() instance share
|
|
193
|
+
* one implementation.
|
|
194
|
+
*/
|
|
195
|
+
async function readLines(file) {
|
|
196
|
+
let stat;
|
|
197
|
+
try { stat = fs.statSync(file); }
|
|
198
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
199
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
200
|
+
|
|
201
|
+
const lines = [];
|
|
202
|
+
let bytesRead = 0;
|
|
203
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
204
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
205
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
for await (const line of rl) {
|
|
209
|
+
lines.push(line);
|
|
210
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
|
|
211
|
+
}
|
|
212
|
+
return { lines, status: "complete", bytesRead };
|
|
213
|
+
} catch {
|
|
214
|
+
// Lines read before the failure are real content and may hold a real
|
|
215
|
+
// secret; an honest "partial" beats a silent false negative.
|
|
216
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
217
|
+
} finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
rl.close();
|
|
220
|
+
stream.destroy();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build a configured source instance for one project root. Returns a fresh
|
|
226
|
+
* object satisfying the full { id, label, available, files, readLines }
|
|
227
|
+
* contract, ready to be passed to scan({ sources: [...] }).
|
|
228
|
+
*
|
|
229
|
+
* label() deliberately does NOT embed the root path: source labels reach the
|
|
230
|
+
* report, and an absolute path can carry a username or project name the rest
|
|
231
|
+
* of the report is careful never to print (the same reasoning scan.js gives
|
|
232
|
+
* for basenames in unreadableFiles).
|
|
233
|
+
*/
|
|
234
|
+
function withRoot(root = process.cwd()) {
|
|
235
|
+
const ROOT = path.resolve(root);
|
|
236
|
+
|
|
237
|
+
function available() {
|
|
238
|
+
try { return fs.statSync(ROOT).isDirectory(); } catch { return false; }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Iterative depth-first walk yielding { file, mtimeMs, sizeBytes, broken }
|
|
243
|
+
* for every candidate. Truncation policy, restated from the header because
|
|
244
|
+
* it is the load-bearing part: SKIP_DIRS vanish silently (expected on
|
|
245
|
+
* every repo, not a truncation); a directory cut by MAX_DEPTH, an
|
|
246
|
+
* unreadable directory, and a walk stopped by MAX_NODES each yield a
|
|
247
|
+
* broken entry, so scan.js surfaces them in unreadableFiles instead of
|
|
248
|
+
* folding the cut into a clean report.
|
|
249
|
+
*/
|
|
250
|
+
function* files() {
|
|
251
|
+
let nodesSeen = 0;
|
|
252
|
+
const visitedDirs = new Set(); // realpaths, symlink-loop cut
|
|
253
|
+
// The containment anchor: everything walked or read must resolve to
|
|
254
|
+
// rootReal or below. If the root itself cannot be realpath'd the walk
|
|
255
|
+
// still runs bounded, but containment cannot be enforced; that is the
|
|
256
|
+
// caller's own unreadable-root situation, not an attacker-created one.
|
|
257
|
+
let rootReal = null;
|
|
258
|
+
try { rootReal = fs.realpathSync(ROOT); visitedDirs.add(rootReal); } catch { /* walk still bounded without it */ }
|
|
259
|
+
const inRoot = (real) =>
|
|
260
|
+
rootReal === null || real === rootReal || real.startsWith(rootReal + path.sep);
|
|
261
|
+
|
|
262
|
+
const stack = [{ dir: ROOT, segs: [] }];
|
|
263
|
+
while (stack.length > 0) {
|
|
264
|
+
const { dir, segs } = stack.pop();
|
|
265
|
+
|
|
266
|
+
let entries;
|
|
267
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
268
|
+
catch { yield { file: dir, broken: true }; continue; }
|
|
269
|
+
|
|
270
|
+
for (const e of entries) {
|
|
271
|
+
if (++nodesSeen > MAX_NODES) {
|
|
272
|
+
// The walk is stopping with work left. Reported against the
|
|
273
|
+
// directory being read because that is the most precise location
|
|
274
|
+
// the files() contract can carry.
|
|
275
|
+
yield { file: dir, broken: true };
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const full = path.join(dir, e.name);
|
|
279
|
+
const childSegs = segs.concat(e.name);
|
|
280
|
+
|
|
281
|
+
if (isDirFollowingSymlink(full, e)) {
|
|
282
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
283
|
+
if (childSegs.length >= MAX_DEPTH) { yield { file: full, broken: true }; continue; }
|
|
284
|
+
// Every directory is deduped by realpath, not only symlinks: a
|
|
285
|
+
// symlinked route and the real directory reached later would
|
|
286
|
+
// otherwise both be walked, and one secret would be reported
|
|
287
|
+
// twice under two paths.
|
|
288
|
+
let real = null;
|
|
289
|
+
try { real = fs.realpathSync(full); }
|
|
290
|
+
catch {
|
|
291
|
+
// A symlink whose target cannot be resolved is a reportable
|
|
292
|
+
// failure; a plain directory failing realpath is unusual, and
|
|
293
|
+
// the readdir above will surface it loudly if it is unreadable.
|
|
294
|
+
if (e.isSymbolicLink()) { yield { file: full, broken: true }; continue; }
|
|
295
|
+
}
|
|
296
|
+
if (real !== null) {
|
|
297
|
+
if (!inRoot(real)) {
|
|
298
|
+
// A directory that resolves OUTSIDE the project root (a
|
|
299
|
+
// committed symlink to the invoking machine's own tree) is
|
|
300
|
+
// never walked: whatever lives there is not part of this
|
|
301
|
+
// checkout, and pulling it in would make a repo verdict about
|
|
302
|
+
// someone's home directory. Surfaced as broken, not skipped
|
|
303
|
+
// silently, so the report says this subtree went unexamined.
|
|
304
|
+
yield { file: full, broken: true };
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (visitedDirs.has(real)) continue; // loop or duplicate route, already covered
|
|
308
|
+
visitedDirs.add(real);
|
|
309
|
+
}
|
|
310
|
+
stack.push({ dir: full, segs: childSegs });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!isFileFollowingSymlink(full, e)) {
|
|
315
|
+
// A dangling symlink with a candidate name is exactly the entry
|
|
316
|
+
// the broken convention exists for; any other non-file oddity is
|
|
317
|
+
// out of scope, same as claude-code.js.
|
|
318
|
+
if (e.isSymbolicLink() && isCandidate(childSegs)) yield { file: full, broken: true };
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!isCandidate(childSegs)) continue;
|
|
323
|
+
if (e.isSymbolicLink()) {
|
|
324
|
+
// Same containment as directories: a candidate-named symlink whose
|
|
325
|
+
// target resolves outside the root is disclosed, never read. Only
|
|
326
|
+
// targets inside the checkout are the checkout's content.
|
|
327
|
+
let realf = null;
|
|
328
|
+
try { realf = fs.realpathSync(full); }
|
|
329
|
+
catch { yield { file: full, broken: true }; continue; }
|
|
330
|
+
if (!inRoot(realf)) { yield { file: full, broken: true }; continue; }
|
|
331
|
+
}
|
|
332
|
+
let stat;
|
|
333
|
+
try { stat = fs.statSync(full); }
|
|
334
|
+
catch { yield { file: full, broken: true }; continue; }
|
|
335
|
+
yield { file: full, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, broken: false };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { id: () => ID, label: () => LABEL, available, files, readLines };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Default export: the registry-safe DISABLED form. index.js may register it
|
|
344
|
+
// like any other singleton; available() is unconditionally false, so the
|
|
345
|
+
// default home scan never includes it, and files() yielding nothing is a
|
|
346
|
+
// harmless backstop should anything iterate it anyway. A project scan only
|
|
347
|
+
// ever happens through withRoot().
|
|
348
|
+
module.exports = {
|
|
349
|
+
id: () => ID,
|
|
350
|
+
label: () => LABEL,
|
|
351
|
+
available: () => false,
|
|
352
|
+
files: function* () {},
|
|
353
|
+
readLines,
|
|
354
|
+
withRoot,
|
|
355
|
+
};
|