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.
@@ -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
+ };