residoo 0.1.0 → 0.2.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.
Files changed (50) hide show
  1. package/README.md +225 -46
  2. package/SECURITY.md +29 -22
  3. package/package.json +1 -1
  4. package/src/cli.js +82 -16
  5. package/src/integrity.js +669 -0
  6. package/src/patterns.js +78 -5
  7. package/src/report.js +74 -7
  8. package/src/sources/agent-configs.js +308 -0
  9. package/src/sources/aider.js +361 -0
  10. package/src/sources/amazon-q.js +199 -0
  11. package/src/sources/antigravity-cli.js +155 -0
  12. package/src/sources/cline.js +208 -0
  13. package/src/sources/codebuff.js +295 -0
  14. package/src/sources/codex-cli.js +258 -0
  15. package/src/sources/cody.js +325 -0
  16. package/src/sources/continue.js +408 -0
  17. package/src/sources/copilot-chat.js +272 -0
  18. package/src/sources/copilot-cli.js +300 -0
  19. package/src/sources/crush.js +364 -0
  20. package/src/sources/cursor.js +374 -0
  21. package/src/sources/devin-cli.js +241 -0
  22. package/src/sources/factory-droid.js +153 -0
  23. package/src/sources/fx.js +136 -0
  24. package/src/sources/gemini-cli.js +242 -0
  25. package/src/sources/goose.js +366 -0
  26. package/src/sources/grok-cli.js +267 -0
  27. package/src/sources/hermes.js +282 -0
  28. package/src/sources/index.js +172 -8
  29. package/src/sources/jetbrains-ai-assistant.js +343 -0
  30. package/src/sources/jetbrains-junie.js +292 -0
  31. package/src/sources/kilo-code.js +430 -0
  32. package/src/sources/kimi-code.js +147 -0
  33. package/src/sources/kiro-cli.js +393 -0
  34. package/src/sources/kiro-ide.js +230 -0
  35. package/src/sources/llm.js +328 -0
  36. package/src/sources/mentat.js +143 -0
  37. package/src/sources/open-interpreter.js +224 -0
  38. package/src/sources/openclaw.js +218 -0
  39. package/src/sources/opencode.js +379 -0
  40. package/src/sources/openhands.js +181 -0
  41. package/src/sources/pearai.js +151 -0
  42. package/src/sources/pi-agent.js +130 -0
  43. package/src/sources/qodo-gen.js +189 -0
  44. package/src/sources/qwen-code.js +244 -0
  45. package/src/sources/roo-code.js +239 -0
  46. package/src/sources/trae.js +294 -0
  47. package/src/sources/void.js +273 -0
  48. package/src/sources/warp.js +395 -0
  49. package/src/sources/windsurf.js +256 -0
  50. package/src/sources/zed.js +374 -0
@@ -0,0 +1,669 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
6
+ const { PATTERNS, redact } = require("./patterns");
7
+
8
+ /**
9
+ * Integrity checks for agent config directories.
10
+ *
11
+ * Every other module in residoo asks "is a secret leaking out of these
12
+ * files?" This one asks the other half of the question the 2026 supply-chain
13
+ * campaigns forced: "has something hostile been planted where my agent will
14
+ * auto-execute it?" The campaigns planted persistence in exactly the
15
+ * locations checked here — each check below cites its evidence:
16
+ *
17
+ * - Mini Shai-Hulud (Apr–May 2026, Sonar: "the first supply chain attack to
18
+ * persist through AI coding agent sessions"): SessionStart hook in
19
+ * .claude/settings.json + .vscode/tasks.json "runOn": "folderOpen".
20
+ * https://www.sonarsource.com/blog/your-secrets-are-leaking-to-ai-coding-agents/
21
+ * - Miasma (June 2026, 73 Microsoft repos disabled): auto-exec configs
22
+ * planted IN CLONED REPOS — .claude/settings.json SessionStart hook,
23
+ * .gemini/settings.json hook, .cursor/rules/setup.mdc prompt injection,
24
+ * .vscode/tasks.json folderOpen task — so merely opening the repo in an
25
+ * AI tool detonated the harvester.
26
+ * https://thehackernews.com/2026/06/miasma-worm-hits-73-microsoft-github.html
27
+ * - keyv/ChainDrop (Aug 2026, 400+ packages): dropped a script literally
28
+ * named setup.mjs into .claude/ (SHA1 686aa40d…) and .vscode/
29
+ * (f525d52c…) and wired hooks/tasks to run it.
30
+ * https://www.wiz.io/blog/keyv-and-cacheable-npm-supply-chain-attack
31
+ * - TrapDoor (Phoenix Security, 2026): zero-width Unicode instructions
32
+ * hidden in CLAUDE.md / .cursorrules that made assistants exfiltrate
33
+ * local secrets — invisible in an editor, fully visible to the agent.
34
+ * https://phoenix.security/accelerating-supply-chain-attacks-npm-pypi-vsx-ai-enabled-2026/
35
+ *
36
+ * Design constraint that shapes every severity decision: legitimate hooks
37
+ * exist. A user's own formatter hook must come out of this as a reviewable
38
+ * "info" line, not an alarm — a tool that cries wolf on the user's own
39
+ * config gets uninstalled, and then catches nothing. So EVERY hook is
40
+ * reported (hooks execute automatically; the user should be able to vouch
41
+ * for each one), but only commands matching a published campaign IOC or a
42
+ * campaign-shaped behavior escalate to "warn".
43
+ *
44
+ * Read-only throughout. Nothing here modifies, quarantines, or deletes —
45
+ * see CONTRIBUTING.md rule 3. Findings carry redacted previews only: a hook
46
+ * command can itself embed a token (Lakera found live credentials in
47
+ * settings.local.json in ~30 published npm packages), so command previews
48
+ * are run through the same PATTERNS + redact() pipeline as scan findings.
49
+ *
50
+ * Returns { findings, filesChecked, scopeNote } — pure data, no printing;
51
+ * the report layer renders. `filesChecked` lists every location examined
52
+ * with an honest status ("checked" | "absent" | "unreadable" | "too-large")
53
+ * so a renderer can never imply a file was verified when it wasn't. For the
54
+ * name-only probes (loose scripts in .claude/, dropper filenames) "checked"
55
+ * attests the NAME was checked against the published IOC names — arbitrary
56
+ * script content is not analyzed, and every such file is also emitted as a
57
+ * finding telling the user to review it, so nothing rides on the status
58
+ * alone.
59
+ */
60
+
61
+ // Configs are hand-written files measured in KB. The one campaign artifact
62
+ // with real bulk was the Miasma payload runner at 4.3MB — a "settings.json"
63
+ // at that size is not a settings file. Oversized configs are reported, not
64
+ // parsed: refusing to slurp an attacker-sized file into memory is also the
65
+ // robustness-safe choice.
66
+ const MAX_CONFIG_BYTES = 5 * 1024 * 1024;
67
+
68
+ // Mirrors stripControlChars in patterns.js (not exported there; adding an
69
+ // export would touch a shared file, which this change deliberately doesn't).
70
+ // Same rationale: C0 controls + DEL are where ANSI escapes live, and a hook
71
+ // command string is attacker-controllable text headed for a terminal.
72
+ function stripControlChars(s) { return s.replace(/[\x00-\x1f\x7f]/g, ""); }
73
+
74
+ // The same invisible code points scanZeroWidth flags, made visible as
75
+ // \u{XXXX} escapes. Re-emitting them raw would put TrapDoor's invisible
76
+ // carrier right back into the terminal this report protects — and into
77
+ // anything the user copy-pastes out of it to "inspect the command".
78
+ const INVISIBLES_RE = /[\u200b\u200c\u200d\u2060\ufeff\u{e0000}-\u{e007f}]/gu;
79
+ function escapeInvisibles(s) {
80
+ return s.replace(INVISIBLES_RE, (ch) => "\\u{" + ch.codePointAt(0).toString(16).toUpperCase() + "}");
81
+ }
82
+
83
+ /**
84
+ * A displayable, bounded preview of an attacker-controllable string from a
85
+ * config file (a command, a matcher, an event name — anything that came out
86
+ * of a parsed config). Control bytes stripped (terminal-injection
87
+ * discipline), any embedded secret redacted (a hook command with
88
+ * --token=sk-... must not put the token into this tool's own output),
89
+ * invisible code points escaped to visible \u{XXXX} form, truncated by code
90
+ * point (an astral character straddling a blind .slice() cut renders as a
91
+ * broken glyph — same reasoning as redact() in patterns.js).
92
+ */
93
+ function safePreview(command, maxCps = 160) {
94
+ let s = stripControlChars(String(command));
95
+ for (const rule of PATTERNS) {
96
+ rule.re.lastIndex = 0;
97
+ s = s.replace(rule.re, (m) => redact(m));
98
+ }
99
+ s = escapeInvisibles(s);
100
+ const cps = Array.from(s);
101
+ return cps.length > maxCps ? cps.slice(0, maxCps).join("") + "…" : s;
102
+ }
103
+
104
+ /**
105
+ * High-suspicion hook-command signatures. Each one is tied to a published
106
+ * campaign behavior, not guessed — see the module header for URLs. First
107
+ * match wins; order is most-specific first.
108
+ */
109
+ const HOOK_SUSPICION = [
110
+ {
111
+ id: "dropper-name",
112
+ // The literal filename the Aug-2026 keyv/ChainDrop wave dropped into
113
+ // .claude/ and .vscode/ (Wiz IOC list), and the artifact the Mini
114
+ // Shai-Hulud/Miasma SessionStart hooks execute.
115
+ re: /\bsetup\.mjs\b/i,
116
+ reason: "references setup.mjs — the dropper filename the Aug-2026 keyv/ChainDrop wave planted in .claude/ and .vscode/ (Wiz IOC)",
117
+ },
118
+ {
119
+ id: "curl-pipe-sh",
120
+ // Bounded gap ([^|;&\n]{0,200}) instead of .* — keeps the regex
121
+ // linear-time on adversarial input (see CONTRIBUTING.md on ReDoS) and
122
+ // stops a curl in one shell statement matching a pipe in the next.
123
+ re: /\b(?:curl|wget)\b[^|;&\n]{0,200}\|\s*(?:ba|z|da|fi)?sh\b/i,
124
+ reason: "downloads from the network and pipes straight into a shell — a hook doing this re-fetches its payload on every session",
125
+ },
126
+ {
127
+ id: "base64-decode",
128
+ re: /\bbase64\b\s+(?:-d|-D|--decode)\b/,
129
+ reason: "decodes base64 before executing — the obfuscation step the 2026 npm campaigns used to hide payloads from exactly this kind of review",
130
+ },
131
+ {
132
+ id: "script-in-dot-dir",
133
+ // node/bun/deno running a .js-family file that lives inside a
134
+ // dot-directory (".claude/x.mjs", "~/.config/y/z.js"). The leading
135
+ // class requires a real name character after the dot, so an ordinary
136
+ // relative "./scripts/build.js" does NOT match. This one CAN hit a
137
+ // user's own legitimately hand-rolled hook — the warn wording says
138
+ // "confirm you wrote it", not "malware".
139
+ re: /(?:^|[\s"'/=])\.[A-Za-z0-9_][A-Za-z0-9_.-]*\/[^\s"']*\.(?:mjs|cjs|js)\b/,
140
+ requireRunner: /\b(?:node|bun|deno)\b/,
141
+ reason: "runs a script that lives inside a dot-directory — the persistence shape of the Mini Shai-Hulud/Miasma SessionStart plants; confirm you wrote that script",
142
+ // Claude Code's own hooks docs suggest keeping hook scripts at
143
+ // ~/.claude/hooks/<script>.js — warning on the vendor-documented layout
144
+ // (and failing --fail-on-find CI on it, every scan) is the cry-wolf →
145
+ // --no-integrity spiral the module header warns about. A path anchored
146
+ // at the user's home directory is that layout and rates info; a bare
147
+ // repo-relative ".claude/x.mjs" — the literal campaign plant shape,
148
+ // which resolves inside whatever repo the agent happens to run from —
149
+ // stays warn. (setup.mjs never reaches here: dropper-name matches
150
+ // first.)
151
+ demoteIfHomeAnchored: true,
152
+ reasonInfo: "runs a script from a dot-directory under your home directory — the vendor-documented hook layout; confirm you wrote that script",
153
+ },
154
+ ];
155
+
156
+ /**
157
+ * Returns { reason, severity } for the first matching signature, or null.
158
+ * `home` feeds the home-anchored demotion above; severity is "warn" unless
159
+ * a signature explicitly demotes.
160
+ */
161
+ function suspicionReason(command, home) {
162
+ for (const sig of HOOK_SUSPICION) {
163
+ sig.re.lastIndex = 0;
164
+ if (sig.requireRunner) {
165
+ sig.requireRunner.lastIndex = 0;
166
+ if (!sig.requireRunner.test(command)) continue;
167
+ }
168
+ if (!sig.re.test(command)) continue;
169
+ if (sig.demoteIfHomeAnchored && home) {
170
+ // Strip every home-anchored occurrence ("~/.dir/x.js" or the literal
171
+ // home path) and re-test: only a remaining bare-relative hit keeps
172
+ // the warn.
173
+ const esc = String(home).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
174
+ const anchored = new RegExp("(?:~|" + esc + ")/\\.[A-Za-z0-9_][A-Za-z0-9_.-]*/[^\\s\"']*\\.(?:mjs|cjs|js)\\b", "g");
175
+ const bare = command.replace(anchored, "");
176
+ sig.re.lastIndex = 0;
177
+ if (!sig.re.test(bare)) return { reason: sig.reasonInfo, severity: "info" };
178
+ }
179
+ return { reason: sig.reason, severity: "warn" };
180
+ }
181
+ return null;
182
+ }
183
+
184
+ // ── zero-width / invisible Unicode (TrapDoor's technique) ─────────────────
185
+
186
+ /**
187
+ * Scan text for characters that render as nothing but reach the agent as
188
+ * content. Three tiers, because blanket-flagging would false-alarm on real
189
+ * files:
190
+ * - always-suspicious: U+200B, U+2060, the Unicode tag block U+E0000–E007F
191
+ * (tag characters have no legitimate use in a text file at all — they are
192
+ * TrapDoor's carrier), and U+FEFF anywhere but offset 0 (offset 0 is an
193
+ * ordinary byte-order mark).
194
+ * - context-dependent: U+200C/U+200D between ASCII characters is invisible
195
+ * splicing (suspicious); the same characters adjacent to non-ASCII are
196
+ * how emoji sequences and joining scripts legitimately work, so those
197
+ * only rate an informational mention.
198
+ * Hits are reported as U+XXXX hex names + line numbers only — never the
199
+ * raw characters, which would put the invisible payload right back into
200
+ * the terminal this report is trying to protect.
201
+ */
202
+ function scanZeroWidth(text) {
203
+ const cps = Array.from(text);
204
+ const hits = [];
205
+ let line = 1;
206
+ for (let i = 0; i < cps.length; i++) {
207
+ const cp = cps[i].codePointAt(0);
208
+ if (cp === 0x0a) { line++; continue; }
209
+ let suspicious = null;
210
+ if (cp >= 0xe0000 && cp <= 0xe007f) suspicious = true;
211
+ else if (cp === 0x200b || cp === 0x2060) suspicious = true;
212
+ else if (cp === 0xfeff) suspicious = i === 0 ? null : true;
213
+ else if (cp === 0x200c || cp === 0x200d) {
214
+ const prev = i > 0 ? cps[i - 1].codePointAt(0) : 0;
215
+ const next = i + 1 < cps.length ? cps[i + 1].codePointAt(0) : 0;
216
+ suspicious = !(prev > 0x7f || next > 0x7f);
217
+ }
218
+ if (suspicious !== null) hits.push({ cp, line, suspicious });
219
+ }
220
+ return hits;
221
+ }
222
+
223
+ function summarizeZeroWidth(hits) {
224
+ // Group by code point; show up to 5 line numbers per group so one laced
225
+ // file can't flood the report.
226
+ const byCp = new Map();
227
+ for (const h of hits) {
228
+ if (!byCp.has(h.cp)) byCp.set(h.cp, []);
229
+ byCp.get(h.cp).push(h.line);
230
+ }
231
+ const parts = [];
232
+ for (const [cp, lns] of byCp) {
233
+ const name = "U+" + cp.toString(16).toUpperCase().padStart(4, "0");
234
+ // ×N counts every hit; the line list is deduped ("lines 2, 2" for two
235
+ // hits on one line reads like a bug).
236
+ const uniq = [...new Set(lns)];
237
+ const shown = uniq.slice(0, 5).join(", ") + (uniq.length > 5 ? ", …" : "");
238
+ parts.push(`${name} ×${lns.length} (line${uniq.length === 1 ? "" : "s"} ${shown})`);
239
+ }
240
+ return parts.join("; ");
241
+ }
242
+
243
+ // ── tolerant config reading ───────────────────────────────────────────────
244
+
245
+ function readSmallFile(file) {
246
+ let stat;
247
+ try { stat = fs.statSync(file); }
248
+ catch (err) {
249
+ // ENOENT/ENOTDIR is genuine absence. Anything else (EACCES on a parent,
250
+ // ELOOP) means something may well BE at the path but couldn't be
251
+ // examined — calling that "absent" would be a false all-clear on an
252
+ // unverified auto-execution location.
253
+ return err && (err.code === "ENOENT" || err.code === "ENOTDIR")
254
+ ? { status: "absent" }
255
+ : { status: "unreadable" };
256
+ }
257
+ if (!stat.isFile()) return { status: "absent" };
258
+ if (stat.size > MAX_CONFIG_BYTES) return { status: "too-large" };
259
+ try { return { status: "ok", text: fs.readFileSync(file, "utf-8") }; }
260
+ catch { return { status: "unreadable" }; }
261
+ }
262
+
263
+ /**
264
+ * VS Code's tasks.json is JSONC — comments and trailing commas are valid
265
+ * there and common in real files. Plain JSON.parse would reject legitimate
266
+ * configs and this module would then cry "unparseable" at innocent users.
267
+ * A single character-walk strip (string-aware, so "// not a comment" inside
268
+ * a string survives) covers what real tasks.json files actually contain.
269
+ */
270
+ function stripJsonc(text) {
271
+ let out = "";
272
+ let inStr = false, inLine = false, inBlock = false;
273
+ for (let i = 0; i < text.length; i++) {
274
+ const ch = text[i], next = text[i + 1];
275
+ if (inLine) { if (ch === "\n") { inLine = false; out += ch; } continue; }
276
+ if (inBlock) { if (ch === "*" && next === "/") { inBlock = false; i++; } continue; }
277
+ if (inStr) {
278
+ out += ch;
279
+ if (ch === "\\" && next !== undefined) { out += next; i++; }
280
+ else if (ch === '"') inStr = false;
281
+ continue;
282
+ }
283
+ if (ch === '"') { inStr = true; out += ch; continue; }
284
+ if (ch === "/" && next === "/") { inLine = true; continue; }
285
+ if (ch === "/" && next === "*") { inBlock = true; i++; continue; }
286
+ out += ch;
287
+ }
288
+ // Trailing commas go in a second string-aware pass — a global regex here
289
+ // also rewrote ",}"/",]" sequences INSIDE string values, corrupting task
290
+ // labels/commands later shown in previews. A structural comma is buffered
291
+ // with any following whitespace and dropped only when the next significant
292
+ // character closes the container; single pass, no lookahead rescanning.
293
+ let res = "";
294
+ let inStr2 = false;
295
+ let pending = "";
296
+ for (let i = 0; i < out.length; i++) {
297
+ const ch = out[i];
298
+ if (inStr2) {
299
+ res += ch;
300
+ if (ch === "\\" && i + 1 < out.length) { res += out[i + 1]; i++; }
301
+ else if (ch === '"') inStr2 = false;
302
+ continue;
303
+ }
304
+ if (pending) {
305
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { pending += ch; continue; }
306
+ if (ch === "]" || ch === "}") pending = pending.slice(1); // trailing: drop the comma, keep its whitespace
307
+ res += pending;
308
+ pending = "";
309
+ }
310
+ if (ch === '"') { inStr2 = true; res += ch; continue; }
311
+ if (ch === ",") { pending = ","; continue; }
312
+ res += ch;
313
+ }
314
+ return res + pending;
315
+ }
316
+
317
+ // Cap for the generic JSON walks below. A hand-written config holds dozens
318
+ // of nodes; JSON.parse happily survives 50k-deep nesting, and a recursive
319
+ // walk over it blows the call stack — which would kill the whole run AFTER
320
+ // the secrets were found but BEFORE anything printed. An attacker who can
321
+ // plant configs (this feature's threat model) must not be able to suppress
322
+ // the report that way, so the walks are iterative and a structure over the
323
+ // cap degrades to a loud "unwalkable" warning, never a crash or a silent
324
+ // skip.
325
+ const MAX_WALK_NODES = 10_000;
326
+
327
+ /**
328
+ * Pull every hook command out of a parsed settings object without
329
+ * hard-coding one vendor's exact nesting. Claude Code uses
330
+ * hooks.<Event>[].hooks[].command (with `matcher` on the PARENT of the
331
+ * hooks array, so the nearest ancestor's matcher is carried down); Gemini
332
+ * CLI's settings.json is event-keyed the same way but has drifted before.
333
+ * Walking the "hooks" subtree for string-valued "command" properties means
334
+ * schema drift degrades to a hook tagged "unknown event" — still reported —
335
+ * instead of a silent miss, which for an integrity checker is the failure
336
+ * mode that matters. A `command` that exists but isn't a string lands in
337
+ * `unrecognized` for the same reason: not extracted is fine, not reported
338
+ * is not.
339
+ *
340
+ * Returns { hooks, unrecognized, hadHooksKey, truncated, sawLeaf } —
341
+ * `sawLeaf` says whether ANY primitive value exists under hooks, so the
342
+ * caller can tell a legitimately empty `{"SessionStart":[]}` block from a
343
+ * populated shape the walk couldn't decode.
344
+ */
345
+ function extractHooks(parsed) {
346
+ const hooks = [];
347
+ const unrecognized = [];
348
+ const hooksRoot = parsed && typeof parsed === "object" ? parsed.hooks : undefined;
349
+ if (!hooksRoot || typeof hooksRoot !== "object") {
350
+ return { hooks, unrecognized, hadHooksKey: hooksRoot !== undefined, truncated: false, sawLeaf: false };
351
+ }
352
+ let truncated = false;
353
+ let sawLeaf = false;
354
+ let visited = 0;
355
+ const stack = [];
356
+ for (const [event, v] of Object.entries(hooksRoot)) stack.push({ node: v, event, matcher: null });
357
+ while (stack.length > 0) {
358
+ if (++visited > MAX_WALK_NODES) { truncated = true; break; }
359
+ const { node, event, matcher } = stack.pop();
360
+ if (Array.isArray(node)) {
361
+ for (const v of node) stack.push({ node: v, event, matcher });
362
+ continue;
363
+ }
364
+ if (!node || typeof node !== "object") {
365
+ if (node !== null && node !== undefined) sawLeaf = true;
366
+ continue;
367
+ }
368
+ const m = typeof node.matcher === "string" ? node.matcher : matcher;
369
+ if (typeof node.command === "string") {
370
+ sawLeaf = true;
371
+ hooks.push({ event, matcher: m, command: node.command });
372
+ } else if ("command" in node) {
373
+ unrecognized.push({ event });
374
+ }
375
+ for (const [k, v] of Object.entries(node)) {
376
+ if (k === "command") continue;
377
+ stack.push({ node: v, event, matcher: m });
378
+ }
379
+ }
380
+ return { hooks, unrecognized, hadHooksKey: true, truncated, sawLeaf };
381
+ }
382
+
383
+ // ── the checker ───────────────────────────────────────────────────────────
384
+
385
+ /**
386
+ * `home` and `cwd` are overridable for tests only (a synthetic planted HOME
387
+ * beats mutating process.env mid-process); production callers pass nothing.
388
+ */
389
+ function checkIntegrity({ home = os.homedir(), cwd = process.cwd() } = {}) {
390
+ const findings = [];
391
+ const filesChecked = [];
392
+ // Home checks and project checks can name the same file (running residoo
393
+ // from inside ~ is legal); resolve-and-dedupe keeps each file to one
394
+ // verdict.
395
+ const seen = new Set();
396
+
397
+ // Findings show "~/..." or "./..." — precise enough to act on, without
398
+ // printing an absolute path that carries the username (the same reasoning
399
+ // scan.js gives for reporting basenames). cwd is tried first so project
400
+ // files inside the home directory render as "./..." not a long "~/...".
401
+ const rHome = path.resolve(home), rCwd = path.resolve(cwd);
402
+ const display = (p) => {
403
+ const r = path.resolve(p);
404
+ let d;
405
+ if (r === rCwd || r.startsWith(rCwd + path.sep)) d = "." + r.slice(rCwd.length);
406
+ else if (r === rHome || r.startsWith(rHome + path.sep)) d = "~" + r.slice(rHome.length);
407
+ else d = path.basename(r);
408
+ // File names come out of readdirSync on attacker-writable directories —
409
+ // the same terminal-injection discipline as command previews: control
410
+ // bytes stripped, invisible code points made visible (a zero-width
411
+ // filename would otherwise render as nothing at all).
412
+ return escapeInvisibles(stripControlChars(d));
413
+ };
414
+
415
+ const mark = (file, status) => { filesChecked.push({ file: display(file), status }); };
416
+ const add = (severity, kind, file, detail) => { findings.push({ severity, kind, file: display(file), detail }); };
417
+
418
+ // A config that exists in an auto-execution location but can't be read or
419
+ // parsed is UNVERIFIED, not clean — reporting it is CONTRIBUTING.md rule 5
420
+ // applied to integrity. Returns the text on success, null otherwise.
421
+ const readOrReport = (file) => {
422
+ const r = path.resolve(file);
423
+ if (seen.has(r)) return null;
424
+ seen.add(r);
425
+ const res = readSmallFile(file);
426
+ mark(file, res.status === "ok" ? "checked" : res.status);
427
+ if (res.status === "unreadable") {
428
+ add("warn", "unreadable-config", file, "exists in an auto-execution location but could not be read — its contents are unverified, not clean");
429
+ } else if (res.status === "too-large") {
430
+ add("warn", "oversized-config", file, `larger than ${MAX_CONFIG_BYTES / 1024 / 1024}MB — far beyond any hand-written config (the Miasma payload runner was 4.3MB); not parsed, review it directly`);
431
+ }
432
+ return res.status === "ok" ? res.text : null;
433
+ };
434
+
435
+ // ---- 1. hooks in agent settings files ----------------------------------
436
+ // Home-level: the user's own standing config. Project-level (cwd): the
437
+ // exact files Miasma planted in cloned repos — a repo-local
438
+ // .claude/settings.json the user never wrote is the campaign's signature.
439
+ // GEMINI_CLI_HOME is Gemini CLI's own documented root override (the CLI
440
+ // creates `.gemini` INSIDE it) — the same resolution agent-configs.js and
441
+ // gemini-cli.js use. Hard-coding ~/.gemini here made the two modules
442
+ // disagree about the same file within one run: agent-configs would find a
443
+ // secret in the real settings file while integrity called that location
444
+ // absent and missed a planted hook in it.
445
+ const geminiHome = process.env.GEMINI_CLI_HOME
446
+ ? path.join(process.env.GEMINI_CLI_HOME, ".gemini")
447
+ : path.join(home, ".gemini");
448
+ const hookFiles = [
449
+ path.join(home, ".claude", "settings.json"),
450
+ path.join(home, ".claude", "settings.local.json"),
451
+ path.join(geminiHome, "settings.json"),
452
+ path.join(cwd, ".claude", "settings.json"),
453
+ path.join(cwd, ".claude", "settings.local.json"),
454
+ path.join(cwd, ".gemini", "settings.json"),
455
+ ];
456
+
457
+ for (const file of hookFiles) {
458
+ const text = readOrReport(file);
459
+ if (text === null) continue;
460
+
461
+ let parsed;
462
+ try { parsed = JSON.parse(text); } catch {
463
+ add("warn", "unparseable-config", file, "exists in an auto-execution location but is not valid JSON — the agent's own loader would also choke on it, so corruption or tampering is worth a look; raw text was signature-checked instead");
464
+ // The parse failing must not mean the campaign signatures go
465
+ // unchecked — grep the raw text for the same shapes.
466
+ const hit = suspicionReason(text, home);
467
+ if (hit) add(hit.severity, "hook", file, `suspicious signature in raw text: ${hit.reason}`);
468
+ continue;
469
+ }
470
+
471
+ const { hooks, unrecognized, hadHooksKey, truncated, sawLeaf } = extractHooks(parsed);
472
+ if (truncated) {
473
+ // No hand-written config is 10k nodes deep/wide — this shape exists
474
+ // to exhaust a walker. The hooks in it are unverified, said loudly;
475
+ // the raw text still gets signature-checked below.
476
+ add("warn", "unwalkable-config", file, `hooks section exceeds ${MAX_WALK_NODES} nodes — far beyond any hand-written config; its hooks are unverified, not clean, review the file directly`);
477
+ const hit = suspicionReason(text, home);
478
+ if (hit) add(hit.severity, "hook", file, `suspicious signature in raw text: ${hit.reason}`);
479
+ }
480
+ for (const u of unrecognized) {
481
+ // A `command` that isn't a string (e.g. ["node", "x.js"]) is not
482
+ // extracted or signature-checked — schema drift must degrade to
483
+ // still-reported, never a silent miss.
484
+ add("info", "hook-unrecognized", file, `${u.event ? safePreview(u.event, 40) : "unknown event"} hook entry whose "command" is not a string — not extracted or signature-checked; review it manually`);
485
+ }
486
+ if (!truncated && hadHooksKey && hooks.length === 0 && unrecognized.length === 0 && sawLeaf) {
487
+ // A populated hooks block this walker couldn't pull a command out of
488
+ // is a coverage gap, and coverage gaps get said out loud. sawLeaf
489
+ // keeps a legitimately empty {"SessionStart": []} block from tripping
490
+ // this.
491
+ add("info", "hook-unrecognized", file, "has a hooks section whose shape this check doesn't recognize — no commands extracted; review it manually");
492
+ continue;
493
+ }
494
+ for (const h of hooks) {
495
+ const where = `${h.event ? safePreview(h.event, 40) : "unknown event"}${h.matcher ? ` (matcher: ${safePreview(h.matcher, 40)})` : ""}`;
496
+ const hit = suspicionReason(h.command, home);
497
+ if (hit) {
498
+ add(hit.severity, "hook", file, `${where} hook ${hit.reason}: "${safePreview(h.command)}"`);
499
+ } else {
500
+ add("info", "hook", file, `${where} hook runs automatically: "${safePreview(h.command)}" — confirm you added this one`);
501
+ }
502
+ }
503
+ }
504
+
505
+ // ---- 2. dropper files in .claude / .vscode -----------------------------
506
+ // Only setup.mjs gets the "known campaign artifact name" wording — it is
507
+ // the one filename actually published in the Wiz IOC list. Any other
508
+ // loose script at the top of an agent dot-directory is merely "a script
509
+ // sitting where hooks execute things from"; that's a review item, not an
510
+ // accusation.
511
+ const scriptDirs = [path.join(home, ".claude"), path.join(cwd, ".claude")];
512
+ for (const dir of scriptDirs) {
513
+ const rDir = path.resolve(dir) + "\0scripts"; // dedupe key distinct from file reads
514
+ if (seen.has(rDir)) continue;
515
+ seen.add(rDir);
516
+ let entries;
517
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
518
+ catch (err) {
519
+ // Same absent-vs-unreadable split as readSmallFile: a directory that
520
+ // exists but can't be listed hides whatever is in it.
521
+ if (!(err && (err.code === "ENOENT" || err.code === "ENOTDIR"))) {
522
+ mark(dir, "unreadable");
523
+ add("warn", "unreadable-config", dir, "directory exists but could not be listed — any loose scripts in it are unverified, not clean");
524
+ }
525
+ continue;
526
+ }
527
+ for (const e of entries) {
528
+ if (!e.isFile() || !/\.(?:mjs|cjs|js)$/i.test(e.name)) continue;
529
+ const file = path.join(dir, e.name);
530
+ // "checked" here = the NAME was checked against the IOC list; the
531
+ // finding below always accompanies it (see the module header).
532
+ mark(file, "checked");
533
+ if (/^setup\.mjs$/i.test(e.name)) {
534
+ add("warn", "dropper-name", file, "matches the exact dropper filename the Aug-2026 keyv/ChainDrop wave planted in .claude/ (Wiz IOC) — if you did not create this file, do not run the agent from here until you've read it");
535
+ } else {
536
+ add("info", "loose-script", file, "script at the top level of an agent config directory — an auto-execution-adjacent location; review it");
537
+ }
538
+ }
539
+ }
540
+ {
541
+ // .vscode/setup.mjs carries its own Wiz IOC hash (f525d52c…); checked by
542
+ // exact name, not a directory sweep — .vscode legitimately holds no
543
+ // loose scripts to enumerate, only this planted one.
544
+ const file = path.join(cwd, ".vscode", "setup.mjs");
545
+ if (!seen.has(path.resolve(file))) {
546
+ seen.add(path.resolve(file));
547
+ let isFile = false, statFailed = false;
548
+ try { isFile = fs.statSync(file).isFile(); }
549
+ catch (err) { statFailed = !(err && (err.code === "ENOENT" || err.code === "ENOTDIR")); }
550
+ // Absent gets recorded too — filesChecked must read the same way for
551
+ // every candidate location, or a renderer summarizing it lies by
552
+ // omission about what this probe covered. A stat failure that is NOT
553
+ // absence (EACCES, ELOOP) is unverified, not clean.
554
+ mark(file, isFile ? "checked" : statFailed ? "unreadable" : "absent");
555
+ if (statFailed) {
556
+ add("warn", "unreadable-config", file, "location could not be examined (stat failed) — unverified, not clean");
557
+ } else if (isFile) {
558
+ add("warn", "dropper-name", file, "matches the exact dropper filename the Aug-2026 keyv/ChainDrop wave planted in .vscode/ (Wiz IOC) — if you did not create this file, review it before opening this folder in VS Code");
559
+ }
560
+ }
561
+ }
562
+
563
+ // ---- 3. zero-width Unicode in agent-instruction files ------------------
564
+ const zwFiles = [
565
+ path.join(home, ".claude", "CLAUDE.md"),
566
+ path.join(cwd, "CLAUDE.md"),
567
+ path.join(cwd, ".cursorrules"),
568
+ ];
569
+ const zwCheck = (file, text) => {
570
+ const hits = scanZeroWidth(text);
571
+ if (hits.length === 0) return;
572
+ const bad = hits.filter((h) => h.suspicious);
573
+ const joiners = hits.filter((h) => !h.suspicious);
574
+ if (bad.length > 0) {
575
+ add("warn", "zero-width", file, `${bad.length} invisible character${bad.length === 1 ? "" : "s"}: ${summarizeZeroWidth(bad)} — zero-width Unicode carried hidden agent instructions in the TrapDoor campaign; inspect with a hex viewer before trusting this file`);
576
+ }
577
+ if (joiners.length > 0) {
578
+ add("info", "zero-width", file, `${summarizeZeroWidth(joiners)} adjacent to non-ASCII text — usually legitimate emoji/script joiners; listed so the count above can't quietly absorb them`);
579
+ }
580
+ };
581
+ for (const file of zwFiles) {
582
+ const text = readOrReport(file);
583
+ if (text !== null) zwCheck(file, text);
584
+ }
585
+
586
+ // ---- 4. project-level auto-run surfaces (CWD only) ---------------------
587
+ // .cursor/rules/* — every file here is loaded as agent instructions on
588
+ // its own; Miasma's plant was named setup.mdc specifically.
589
+ {
590
+ const rulesDir = path.join(cwd, ".cursor", "rules");
591
+ let entries = null;
592
+ try { entries = fs.readdirSync(rulesDir, { withFileTypes: true }); }
593
+ catch (err) {
594
+ if (!(err && (err.code === "ENOENT" || err.code === "ENOTDIR"))) {
595
+ mark(rulesDir, "unreadable");
596
+ add("warn", "unreadable-config", rulesDir, "rules directory exists but could not be listed — its contents are unverified, not clean");
597
+ }
598
+ }
599
+ if (entries) {
600
+ for (const e of entries) {
601
+ if (!e.isFile()) continue;
602
+ const file = path.join(rulesDir, e.name);
603
+ if (seen.has(path.resolve(file))) continue;
604
+ // readOrReport owns the status/warn accounting: a rules file that
605
+ // can't be read must come out "unreadable" + warn — never "checked"
606
+ // with the zero-width scan silently skipped, which would let
607
+ // filesChecked lie about verification depth.
608
+ const text = readOrReport(file);
609
+ if (/^setup\.mdc$/i.test(e.name)) {
610
+ add("warn", "dropper-name", file, "matches the rules filename Miasma planted (.cursor/rules/setup.mdc) to prompt-inject Cursor on repo open — confirm you created it");
611
+ } else {
612
+ add("info", "cursor-rule", file, "Cursor loads rules files as agent instructions automatically — confirm you added this one");
613
+ }
614
+ if (text !== null) zwCheck(file, text);
615
+ }
616
+ }
617
+ }
618
+
619
+ // .vscode/tasks.json "runOn": "folderOpen" — code that executes on merely
620
+ // opening the folder; the Mini Shai-Hulud and Miasma persistence task.
621
+ {
622
+ const file = path.join(cwd, ".vscode", "tasks.json");
623
+ const text = readOrReport(file);
624
+ if (text !== null) {
625
+ let parsed = null;
626
+ try { parsed = JSON.parse(stripJsonc(text)); } catch {
627
+ add("warn", "unparseable-config", file, "not valid JSON even after comment/trailing-comma stripping — unverified, not clean; raw text was signature-checked instead");
628
+ if (/"runOn"\s*:\s*"folderOpen"/.test(text)) {
629
+ add("warn", "autorun-task", file, 'raw text contains "runOn": "folderOpen" — a task that executes on folder open (Mini Shai-Hulud/Miasma persistence); review it');
630
+ }
631
+ }
632
+ if (parsed) {
633
+ // Walk generically rather than assuming tasks[] — a folderOpen
634
+ // buried under a nonstandard nesting still executes. Iterative with
635
+ // the same node cap as extractHooks: a stack-overflow crash here
636
+ // would suppress the whole report (see MAX_WALK_NODES).
637
+ let visited = 0, truncated = false;
638
+ const stack = [parsed];
639
+ while (stack.length > 0) {
640
+ if (++visited > MAX_WALK_NODES) { truncated = true; break; }
641
+ const node = stack.pop();
642
+ if (Array.isArray(node)) { for (const v of node) stack.push(v); continue; }
643
+ if (!node || typeof node !== "object") continue;
644
+ if (node.runOptions && node.runOptions.runOn === "folderOpen") {
645
+ const what = node.label || node.command || node.script || "(unnamed task)";
646
+ add("warn", "autorun-task", file, `task "${safePreview(what, 60)}" runs on folder open ("runOn": "folderOpen") — the Mini Shai-Hulud/Miasma persistence mechanism; confirm you added it`);
647
+ }
648
+ for (const v of Object.values(node)) stack.push(v);
649
+ }
650
+ if (truncated) {
651
+ add("warn", "unwalkable-config", file, `structure exceeds ${MAX_WALK_NODES} nodes — far beyond any hand-written tasks.json; auto-run tasks in it are unverified, not clean, review the file directly`);
652
+ if (/"runOn"\s*:\s*"folderOpen"/.test(text)) {
653
+ add("warn", "autorun-task", file, 'raw text contains "runOn": "folderOpen" — a task that executes on folder open (Mini Shai-Hulud/Miasma persistence); review it');
654
+ }
655
+ }
656
+ }
657
+ }
658
+ }
659
+
660
+ return {
661
+ findings,
662
+ filesChecked,
663
+ // Stated because it is a real limit, not a disclaimer: nothing here
664
+ // walks other checkouts, and a clean run says nothing about them.
665
+ scopeNote: "Project-level checks (CLAUDE.md, .cursorrules, .cursor/, .vscode/, repo-local .claude/ and .gemini/) cover the current working directory only.",
666
+ };
667
+ }
668
+
669
+ module.exports = { checkIntegrity };