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.
- package/README.md +225 -46
- package/SECURITY.md +29 -22
- package/package.json +1 -1
- package/src/cli.js +82 -16
- package/src/integrity.js +669 -0
- package/src/patterns.js +78 -5
- package/src/report.js +74 -7
- package/src/sources/agent-configs.js +308 -0
- package/src/sources/aider.js +361 -0
- package/src/sources/amazon-q.js +199 -0
- package/src/sources/antigravity-cli.js +155 -0
- package/src/sources/cline.js +208 -0
- package/src/sources/codebuff.js +295 -0
- package/src/sources/codex-cli.js +258 -0
- package/src/sources/cody.js +325 -0
- package/src/sources/continue.js +408 -0
- package/src/sources/copilot-chat.js +272 -0
- package/src/sources/copilot-cli.js +300 -0
- package/src/sources/crush.js +364 -0
- package/src/sources/cursor.js +374 -0
- package/src/sources/devin-cli.js +241 -0
- package/src/sources/factory-droid.js +153 -0
- package/src/sources/fx.js +136 -0
- package/src/sources/gemini-cli.js +242 -0
- package/src/sources/goose.js +366 -0
- package/src/sources/grok-cli.js +267 -0
- package/src/sources/hermes.js +282 -0
- package/src/sources/index.js +172 -8
- package/src/sources/jetbrains-ai-assistant.js +343 -0
- package/src/sources/jetbrains-junie.js +292 -0
- package/src/sources/kilo-code.js +430 -0
- package/src/sources/kimi-code.js +147 -0
- package/src/sources/kiro-cli.js +393 -0
- package/src/sources/kiro-ide.js +230 -0
- package/src/sources/llm.js +328 -0
- package/src/sources/mentat.js +143 -0
- package/src/sources/open-interpreter.js +224 -0
- package/src/sources/openclaw.js +218 -0
- package/src/sources/opencode.js +379 -0
- package/src/sources/openhands.js +181 -0
- package/src/sources/pearai.js +151 -0
- package/src/sources/pi-agent.js +130 -0
- package/src/sources/qodo-gen.js +189 -0
- package/src/sources/qwen-code.js +244 -0
- package/src/sources/roo-code.js +239 -0
- package/src/sources/trae.js +294 -0
- package/src/sources/void.js +273 -0
- package/src/sources/warp.js +395 -0
- package/src/sources/windsurf.js +256 -0
- package/src/sources/zed.js +374 -0
package/src/integrity.js
ADDED
|
@@ -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 };
|