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/patterns.js
CHANGED
|
@@ -28,12 +28,13 @@ const PATTERNS = [
|
|
|
28
28
|
re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
|
|
29
29
|
{ id: "stripe_key", label: "Stripe API key", confidence: "high",
|
|
30
30
|
re: /\b(sk|rk)_live_[A-Za-z0-9]{20,}\b/g },
|
|
31
|
-
// The negative lookahead keeps this rule
|
|
32
|
-
// without it, "sk-ant-..."
|
|
33
|
-
//
|
|
34
|
-
//
|
|
31
|
+
// The negative lookahead keeps this rule mutually exclusive with anthropic_key
|
|
32
|
+
// and openrouter_key below — without it, "sk-ant-..." or "sk-or-v1-..." match
|
|
33
|
+
// BOTH this pattern and the more specific one, and get reported twice under
|
|
34
|
+
// two different (one wrong) provider labels. Verified: all three regexes
|
|
35
|
+
// independently matched their overlapping synthetic keys before this fix.
|
|
35
36
|
{ id: "openai_key", label: "OpenAI API key", confidence: "high",
|
|
36
|
-
re: /\bsk-(?!ant-)(proj-)?[A-Za-z0-9_-]{20,}\b/g },
|
|
37
|
+
re: /\bsk-(?!ant-|or-)(proj-)?[A-Za-z0-9_-]{20,}\b/g },
|
|
37
38
|
{ id: "anthropic_key", label: "Anthropic API key", confidence: "high",
|
|
38
39
|
re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
|
|
39
40
|
{ id: "google_api_key", label: "Google / Firebase API key", confidence: "high",
|
|
@@ -54,6 +55,78 @@ const PATTERNS = [
|
|
|
54
55
|
re: /"refresh_token"\s*:\s*"[^"\s]{20,}"/gi },
|
|
55
56
|
{ id: "access_token_field", label: "access_token field", confidence: "medium",
|
|
56
57
|
re: /"access_token"\s*:\s*"[^"\s]{20,}"/gi },
|
|
58
|
+
|
|
59
|
+
// ── AI / LLM providers (added: competitive gap-close, see project history) ─
|
|
60
|
+
// Every regex body below was checked against a production, field-tested
|
|
61
|
+
// detector — trufflehog's (github.com/trufflesecurity/trufflehog,
|
|
62
|
+
// pkg/detectors/<vendor>) — not guessed from a blog post, as of 2026-09.
|
|
63
|
+
// Cohere, Mistral, Together AI, Fireworks and DeepSeek were researched too
|
|
64
|
+
// and deliberately left out: none has a trufflehog detector, official docs
|
|
65
|
+
// describe them as unprefixed/opaque tokens, and DeepSeek's "sk-" prefix is
|
|
66
|
+
// provably indistinguishable from OpenAI's (trufflehog's own DeepSeek
|
|
67
|
+
// detector only fires with a nearby "deepseek" keyword as extra context,
|
|
68
|
+
// which this flat-regex model doesn't have) — exactly the shaky-prefix case
|
|
69
|
+
// this file's own header comment says to leave out rather than force.
|
|
70
|
+
{ id: "groq_key", label: "Groq API key", confidence: "high",
|
|
71
|
+
re: /\bgsk_[a-zA-Z0-9]{52}\b/g },
|
|
72
|
+
{ id: "xai_key", label: "xAI (Grok) API key", confidence: "high",
|
|
73
|
+
re: /\bxai-[0-9a-zA-Z_]{80}\b/g },
|
|
74
|
+
{ id: "openrouter_key", label: "OpenRouter API key", confidence: "high",
|
|
75
|
+
re: /\bsk-or-v1-[0-9a-f]{64}\b/g },
|
|
76
|
+
{ id: "huggingface_token", label: "Hugging Face access token", confidence: "high",
|
|
77
|
+
re: /\b(?:hf_|api_org_)[a-zA-Z0-9]{34}\b/g },
|
|
78
|
+
{ id: "pinecone_key", label: "Pinecone API key", confidence: "high",
|
|
79
|
+
re: /\bpcsk_[A-Za-z0-9]{5,6}_[A-Za-z0-9]{63}\b/g },
|
|
80
|
+
// No trufflehog detector exists for this one, so it leans on a second,
|
|
81
|
+
// independent signal instead: Perplexity's own product is literally named
|
|
82
|
+
// "pplx-api" (see their "Introducing pplx-api" launch post), and every
|
|
83
|
+
// integration doc that shows a real key (liteLLM, apideck, etc.) agrees on
|
|
84
|
+
// "pplx-" + a >=40-char body — consistent across independent sources even
|
|
85
|
+
// without one canonical spec page.
|
|
86
|
+
{ id: "perplexity_key", label: "Perplexity API key", confidence: "high",
|
|
87
|
+
re: /\bpplx-[A-Za-z0-9]{40,}\b/g },
|
|
88
|
+
{ id: "replicate_token", label: "Replicate API token", confidence: "high",
|
|
89
|
+
re: /\br8_[0-9A-Za-z_-]{37}\b/g },
|
|
90
|
+
|
|
91
|
+
// ── Cloud / infra ──────────────────────────────────────────────────────
|
|
92
|
+
{ id: "digitalocean_token", label: "DigitalOcean access token", confidence: "high",
|
|
93
|
+
re: /\b(?:dop|doo|dor)_v1_[a-f0-9]{64}\b/g },
|
|
94
|
+
{ id: "supabase_token", label: "Supabase personal access token", confidence: "high",
|
|
95
|
+
re: /\bsbp_[a-z0-9]{40}\b/g },
|
|
96
|
+
{ id: "vault_token", label: "HashiCorp Vault service token", confidence: "high",
|
|
97
|
+
// Vault 1.10+ format only (hvs.<90-120 chars>). The pre-1.10 legacy
|
|
98
|
+
// format is a bare "s." + 18-40 chars — "s." is nowhere near specific
|
|
99
|
+
// enough to be a vendor prefix, so that older shape is deliberately left
|
|
100
|
+
// out rather than turned into a noisy 2-character trigger.
|
|
101
|
+
re: /\bhvs\.[A-Za-z0-9_-]{90,120}\b/g },
|
|
102
|
+
{ id: "onepassword_service_token", label: "1Password service account token", confidence: "high",
|
|
103
|
+
// Confirmed against 1Password's own developer docs (developer.1password.com
|
|
104
|
+
// -> 1password.dev/service-accounts/security): the token is "ops_" plus a
|
|
105
|
+
// base64-encoded JWT, so it always continues "eyJ" (base64 of `{"`).
|
|
106
|
+
re: /\bops_eyJ[A-Za-z0-9+/=_-]{40,}\b/g },
|
|
107
|
+
|
|
108
|
+
// ── Comms / SaaS ───────────────────────────────────────────────────────
|
|
109
|
+
{ id: "discord_webhook", label: "Discord webhook URL", confidence: "high",
|
|
110
|
+
re: /\bhttps:\/\/discord\.com\/api\/webhooks\/[0-9]{18,19}\/[0-9a-zA-Z_-]{68}\b/g },
|
|
111
|
+
{ id: "telegram_bot_token", label: "Telegram bot token", confidence: "high",
|
|
112
|
+
re: /\b[0-9]{8,10}:[a-zA-Z0-9_-]{35}\b/g },
|
|
113
|
+
{ id: "mailgun_key", label: "Mailgun API key", confidence: "high",
|
|
114
|
+
re: /\bkey-[a-z0-9]{32}\b/g },
|
|
115
|
+
// Notion's own docs explicitly warn against regex-matching its tokens,
|
|
116
|
+
// since the format has changed before and may again — noted, not ignored,
|
|
117
|
+
// and worth restating here rather than treating this as equally solid as
|
|
118
|
+
// the others. secret_ (legacy, exactly 43 chars) is trufflehog-verified;
|
|
119
|
+
// ntn_ (current format since 2024-09-25) is vendor-confirmed as a prefix
|
|
120
|
+
// but Notion has not published an exact body length for it, so its bound
|
|
121
|
+
// below is a floor, not a verified exact count.
|
|
122
|
+
{ id: "notion_token", label: "Notion integration token", confidence: "high",
|
|
123
|
+
re: /\b(?:secret_[A-Za-z0-9]{43}|ntn_[A-Za-z0-9]{20,})\b/g },
|
|
124
|
+
{ id: "linear_key", label: "Linear API key", confidence: "high",
|
|
125
|
+
re: /\blin_api_[0-9A-Za-z]{40}\b/g },
|
|
126
|
+
{ id: "sentry_token", label: "Sentry auth token", confidence: "high",
|
|
127
|
+
// Covers both current Sentry token shapes: org-scoped (sntrys_, base64
|
|
128
|
+
// JWT-like body) and user-scoped (sntryu_, hex body).
|
|
129
|
+
re: /\b(?:sntrys_eyJ[A-Za-z0-9+/=_]{100,}|sntryu_[a-f0-9]{64})\b/g },
|
|
57
130
|
];
|
|
58
131
|
|
|
59
132
|
/**
|
package/src/report.js
CHANGED
|
@@ -28,13 +28,60 @@ function ageDays(mtimeMs) {
|
|
|
28
28
|
return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86400000));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The integrity section — findings from src/integrity.js, rendered in the
|
|
33
|
+
* same visual language as the scan report. Severity drives everything:
|
|
34
|
+
* "warn" (a verified campaign signature, or a location that exists but
|
|
35
|
+
* couldn't be verified) paints red and counts toward --fail-on-find; "info"
|
|
36
|
+
* (a hook/rules file that runs automatically and merely deserves the user's
|
|
37
|
+
* confirmation) stays dim. Every finding is printed — integrity findings are
|
|
38
|
+
* few by construction (checkIntegrity caps its own noise), so unlike scan
|
|
39
|
+
* findings they are never truncated to a top-N.
|
|
40
|
+
*
|
|
41
|
+
* Exported separately because cli.js's "no transcript sources on this
|
|
42
|
+
* machine" path still runs the integrity checks — a planted repo-level hook
|
|
43
|
+
* is exactly as dangerous on a machine with no transcripts to scan.
|
|
44
|
+
*/
|
|
45
|
+
function renderIntegrity(integrity, { noColor = false } = {}) {
|
|
46
|
+
const paint = makePaint(noColor);
|
|
47
|
+
const lines = [];
|
|
48
|
+
const push = (s = "") => lines.push(s);
|
|
49
|
+
|
|
50
|
+
const warns = integrity.findings.filter((f) => f.severity === "warn");
|
|
51
|
+
const infos = integrity.findings.filter((f) => f.severity === "info");
|
|
52
|
+
const checked = integrity.filesChecked.filter((f) => f.status === "checked").length;
|
|
53
|
+
const absent = integrity.filesChecked.filter((f) => f.status === "absent").length;
|
|
54
|
+
|
|
55
|
+
if (integrity.findings.length === 0) {
|
|
56
|
+
push(paint(c.green + c.bold, "✓ Integrity: no planted hooks, droppers, or hidden instructions detected") +
|
|
57
|
+
`: ${checked} location${checked === 1 ? "" : "s"} checked, ${absent} absent.`);
|
|
58
|
+
} else if (warns.length > 0) {
|
|
59
|
+
push(paint(c.red + c.bold, `⚠ Integrity: ${warns.length} warning${warns.length === 1 ? "" : "s"}`) +
|
|
60
|
+
(infos.length > 0 ? ` + ${infos.length} item${infos.length === 1 ? "" : "s"} to review` : "") +
|
|
61
|
+
paint(c.dim, ` · ${checked} location${checked === 1 ? "" : "s"} checked`));
|
|
62
|
+
} else {
|
|
63
|
+
push(paint(c.bold, `Integrity: ${infos.length} item${infos.length === 1 ? "" : "s"} to review`) +
|
|
64
|
+
paint(c.dim, ` (nothing matching a known campaign signature) · ${checked} location${checked === 1 ? "" : "s"} checked`));
|
|
65
|
+
}
|
|
66
|
+
for (const f of warns) {
|
|
67
|
+
push(` ${paint(c.red, "warn")} ${paint(c.cyan, f.file)}`);
|
|
68
|
+
push(` ${f.detail}`);
|
|
69
|
+
}
|
|
70
|
+
for (const f of infos) {
|
|
71
|
+
push(` ${paint(c.dim, "info")} ${paint(c.cyan, f.file)}`);
|
|
72
|
+
push(paint(c.dim, ` ${f.detail}`));
|
|
73
|
+
}
|
|
74
|
+
push(paint(c.dim, ` ${integrity.scopeNote}`));
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppressedCount = 0, distinctCounts = {}, unreadableFiles = [] }, { noColor = false, integrity = null } = {}) {
|
|
32
79
|
const paint = makePaint(noColor);
|
|
33
80
|
const lines = [];
|
|
34
81
|
const push = (s = "") => lines.push(s);
|
|
35
82
|
|
|
36
83
|
const suppressedNote = suppressedCount > 0
|
|
37
|
-
? paint(c.dim, ` (${suppressedCount} more matched but looked like placeholder/example text
|
|
84
|
+
? paint(c.dim, ` (${suppressedCount} more matched but looked like placeholder/example text; see --include-suppressed)`)
|
|
38
85
|
: "";
|
|
39
86
|
// Surfaced, not silent: a file that couldn't be (fully) read was not fully
|
|
40
87
|
// scanned, and a report must not read as "checked and found nothing" for
|
|
@@ -43,14 +90,18 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
43
90
|
// project-name-derived directory slug, which is exactly the kind of thing
|
|
44
91
|
// every other line in this report is careful to redact down from.
|
|
45
92
|
const unreadableNote = unreadableFiles.length > 0
|
|
46
|
-
? paint(c.yellow, `⚠ ${unreadableFiles.length} file(s) not fully scanned
|
|
93
|
+
? paint(c.yellow, `⚠ ${unreadableFiles.length} file(s) not fully scanned. See --json for which and why.`)
|
|
47
94
|
: null;
|
|
48
95
|
|
|
49
96
|
if (findings.length === 0) {
|
|
50
97
|
push(paint(c.green + c.bold, "✓ No exposed secrets found") +
|
|
51
|
-
|
|
98
|
+
`: ${filesScanned} file${filesScanned === 1 ? "" : "s"} scanned across ${sourcesScanned.join(", ") || "no sources"}.` +
|
|
52
99
|
suppressedNote);
|
|
53
100
|
if (unreadableNote) push(unreadableNote);
|
|
101
|
+
if (integrity) {
|
|
102
|
+
push();
|
|
103
|
+
push(renderIntegrity(integrity, { noColor }));
|
|
104
|
+
}
|
|
54
105
|
return lines.join("\n");
|
|
55
106
|
}
|
|
56
107
|
|
|
@@ -91,14 +142,22 @@ function render({ findings, filesScanned, sourcesScanned, bytesScanned, suppress
|
|
|
91
142
|
}
|
|
92
143
|
if (byFile.size > fileRows.length) push(paint(c.dim, ` … and ${byFile.size - fileRows.length} more file(s)`));
|
|
93
144
|
|
|
145
|
+
if (integrity) {
|
|
146
|
+
push();
|
|
147
|
+
push(renderIntegrity(integrity, { noColor }));
|
|
148
|
+
}
|
|
149
|
+
|
|
94
150
|
push();
|
|
95
|
-
push(paint(c.dim, "Values are redacted in this report
|
|
151
|
+
push(paint(c.dim, "Values are redacted in this report (first/last 4 characters only). Nothing scanned"));
|
|
96
152
|
push(paint(c.dim, "here left your machine; residoo makes no network calls. Run with --json for full detail."));
|
|
97
153
|
|
|
98
154
|
return lines.join("\n");
|
|
99
155
|
}
|
|
100
156
|
|
|
101
|
-
|
|
157
|
+
// `integrity` is the checkIntegrity() result, or null when --no-integrity
|
|
158
|
+
// skipped it — the key is always present so a --json consumer can tell
|
|
159
|
+
// "checked, clean" apart from "never checked" without guessing from absence.
|
|
160
|
+
function renderJson(result, integrity = null) {
|
|
102
161
|
return JSON.stringify(
|
|
103
162
|
{
|
|
104
163
|
summary: {
|
|
@@ -114,10 +173,18 @@ function renderJson(result) {
|
|
|
114
173
|
rule: f.ruleId, label: f.label, confidence: f.confidence,
|
|
115
174
|
source: f.source, file: f.relFile, line: f.line, preview: f.preview,
|
|
116
175
|
})),
|
|
176
|
+
integrity: integrity
|
|
177
|
+
? {
|
|
178
|
+
warningCount: integrity.findings.filter((f) => f.severity === "warn").length,
|
|
179
|
+
findings: integrity.findings,
|
|
180
|
+
filesChecked: integrity.filesChecked,
|
|
181
|
+
scopeNote: integrity.scopeNote,
|
|
182
|
+
}
|
|
183
|
+
: null,
|
|
117
184
|
},
|
|
118
185
|
null,
|
|
119
186
|
2
|
|
120
187
|
);
|
|
121
188
|
}
|
|
122
189
|
|
|
123
|
-
module.exports = { render, renderJson };
|
|
190
|
+
module.exports = { render, renderIntegrity, renderJson };
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const { createInterface } = require("readline/promises");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Agent CONFIG and STATE files — the first source in this project that is
|
|
10
|
+
* not a transcript store. Configs earned their own source because they are
|
|
11
|
+
* the best-MEASURED plaintext secret sink in the 2026 evidence base:
|
|
12
|
+
* GitGuardian counted 24,008 secrets inside MCP config files on public
|
|
13
|
+
* GitHub (2,117 still valid); Lakera found live credentials inside
|
|
14
|
+
* `.claude/settings.local.json` files shipped in ~30 npm packages because
|
|
15
|
+
* Claude Code's approved-command cache accumulates tokens and no packaging
|
|
16
|
+
* tool ignores `.claude/` by default; and the year's supply-chain campaigns
|
|
17
|
+
* (Mini Shai-Hulud, Miasma, ChainDrop) both PLANT persistence in and STEAL
|
|
18
|
+
* from exactly these files. Published stealer target lists (JFrog's
|
|
19
|
+
* Bitwarden-CLI-hijack write-up, StepSecurity's Nx Console analysis, the
|
|
20
|
+
* keyv/Shai-Hulud reports) name several of the paths below verbatim.
|
|
21
|
+
*
|
|
22
|
+
* SCOPE — home-level only, and that limitation is real, not rhetorical:
|
|
23
|
+
* project-level configs (`.mcp.json`, `.claude/settings.json`,
|
|
24
|
+
* `.cursor/rules/`, `.vscode/tasks.json`, per-repo CLAUDE.md/AGENTS.md —
|
|
25
|
+
* the files Miasma actually planted in cloned repos) live inside arbitrary
|
|
26
|
+
* repositories this tool has no way to enumerate from a home directory.
|
|
27
|
+
* A clean report from this source therefore says nothing about any
|
|
28
|
+
* project's own config files. v1 ships the home-level set because those
|
|
29
|
+
* paths are fixed and verifiable; the project-level gap is stated here
|
|
30
|
+
* rather than papered over.
|
|
31
|
+
*
|
|
32
|
+
* PER-PATH VERIFICATION (per CONTRIBUTING.md's no-guessed-paths rule —
|
|
33
|
+
* "real install" below means the populated machine this source was built
|
|
34
|
+
* on, checked read-only; "digest" means the 2026-09-02 research digest's
|
|
35
|
+
* stealer target lists and campaign write-ups, which name exact paths):
|
|
36
|
+
*
|
|
37
|
+
* - `~/.claude.json` — real install (present, live content) + named
|
|
38
|
+
* verbatim in JFrog's Bitwarden-CLI-hijack target list + Claude Code's
|
|
39
|
+
* own MCP docs (user-scoped MCP servers, env blocks included, are
|
|
40
|
+
* stored here).
|
|
41
|
+
* - `~/.claude.json.backup` — real install (present; Claude Code's own
|
|
42
|
+
* rewrite backup of the file above — same content, same secrets, and a
|
|
43
|
+
* scanner that reads the original but not its sibling copy would
|
|
44
|
+
* under-report).
|
|
45
|
+
* - `~/.claude/settings.json` — real install + the file Mini Shai-Hulud
|
|
46
|
+
* and Miasma planted `SessionStart` hooks into + StepSecurity's Nx
|
|
47
|
+
* Console write-up names it as a harvest target.
|
|
48
|
+
* - `~/.claude/settings.local.json` — real install + Lakera's ~30
|
|
49
|
+
* leaking npm packages are this exact filename. This file is the
|
|
50
|
+
* reason a config source exists at all: it is NOT supposed to hold
|
|
51
|
+
* secrets, and measurably does.
|
|
52
|
+
* - `~/.claude/mcp.json` — the one deliberate exception to the rule that
|
|
53
|
+
* a path must be vendor-documented or locally present: it is NEITHER
|
|
54
|
+
* (Claude Code stores user-scope MCP config inside `~/.claude.json`,
|
|
55
|
+
* and it does not exist on the real install verified against). It is
|
|
56
|
+
* included anyway because published stealer target lists hunt this
|
|
57
|
+
* exact name (JFrog's Bitwarden-CLI list: `~/.claude.json`,
|
|
58
|
+
* `.claude/mcp.json`, `~/.kiro/settings/mcp.json`) — where the file
|
|
59
|
+
* does exist (hand-written, third-party tooling, older forks), it is
|
|
60
|
+
* precisely what an attacker grabs, and when absent it yields nothing
|
|
61
|
+
* and costs one lstat.
|
|
62
|
+
* - `~/.claude/CLAUDE.md` — Claude Code's own memory docs (user memory
|
|
63
|
+
* file) + the TrapDoor campaign hid zero-width-Unicode exfiltration
|
|
64
|
+
* instructions in CLAUDE.md files + the digest's stealer roadmap names
|
|
65
|
+
* "memory files (MEMORY.md/CLAUDE.md)". Absent on the real install
|
|
66
|
+
* (the `~/.claude` root is present); scanned when it exists because
|
|
67
|
+
* memory files are where users paste the things they want remembered.
|
|
68
|
+
* - Claude Desktop `claude_desktop_config.json` — macOS
|
|
69
|
+
* `~/Library/Application Support/Claude/`: real install (present) +
|
|
70
|
+
* the official MCP docs (modelcontextprotocol.io, "Connect to local
|
|
71
|
+
* MCP servers") document it per-OS. Windows `%APPDATA%\Claude\`: same
|
|
72
|
+
* official MCP docs + multiple independent setup guides agree. Linux
|
|
73
|
+
* is deliberately NOT covered: there is no official Linux build, and
|
|
74
|
+
* the unofficial ports disagree with each other on the config location
|
|
75
|
+
* (`~/.config/Claude/` vs `~/.config/claude-desktop/`) — either pick
|
|
76
|
+
* would be a guessed path.
|
|
77
|
+
* - `~/.cursor/mcp.json` — Cursor's official MCP docs (the global,
|
|
78
|
+
* all-projects config; distinct from the per-profile storage
|
|
79
|
+
* cursor.js reads) + independent Snyk/liblab/TrueFoundry guides + the
|
|
80
|
+
* digest's ~/.cursor deep-dive. Not installed on the build machine.
|
|
81
|
+
* - `~/.gemini/settings.json` — Gemini CLI's official settings docs
|
|
82
|
+
* (user settings file) + Miasma planted `.gemini/settings.json` (the
|
|
83
|
+
* digest names the filename verbatim). Root resolution honors
|
|
84
|
+
* GEMINI_CLI_HOME exactly as gemini-cli.js does — that override was
|
|
85
|
+
* verified from the project's own source during that adapter's
|
|
86
|
+
* research, not guessed here.
|
|
87
|
+
* - `~/.codex/config.toml` — OpenAI's official docs (CODEX_HOME "sets
|
|
88
|
+
* the root directory for Codex state, including config...", and the
|
|
89
|
+
* Codex MCP docs document `mcp_servers` sections in config.toml with
|
|
90
|
+
* `env` tables — the documented way to hand an MCP server an API key)
|
|
91
|
+
* + multiple independent setup guides showing exactly that. Root
|
|
92
|
+
* resolution honors CODEX_HOME exactly as codex-cli.js does.
|
|
93
|
+
* - `~/.kiro/settings/mcp.json` — Kiro's official MCP configuration docs
|
|
94
|
+
* (global config; their own security page recommends `chmod 600` on
|
|
95
|
+
* it, a vendor admission it holds secrets) + named verbatim in JFrog's
|
|
96
|
+
* Bitwarden-CLI target list.
|
|
97
|
+
*
|
|
98
|
+
* DELIBERATELY NOT READ, and why:
|
|
99
|
+
* - `~/.claude/projects/**` — claude-code.js's territory. Overlapping it
|
|
100
|
+
* would double-report every finding. (Named side effect: a
|
|
101
|
+
* `projects/<slug>/memory/MEMORY.md` is covered by NEITHER source
|
|
102
|
+
* today — a real gap that belongs to the transcript source's scope
|
|
103
|
+
* discussion, recorded here so it isn't mistaken for covered.)
|
|
104
|
+
* - `~/.claude/history.jsonl`, paste-cache, file-history, session-env —
|
|
105
|
+
* transcript-adjacent conversation state, not configuration; adding
|
|
106
|
+
* them belongs in a transcript source where dedup against session
|
|
107
|
+
* files can be reasoned about.
|
|
108
|
+
* - `~/.codex/auth.json`, `~/.gemini/oauth_creds.json`, `~/.gemini/.env`,
|
|
109
|
+
* `~/.claude/.credentials.json` — credential VAULTS: files whose whole
|
|
110
|
+
* documented job is holding the user's own keys/tokens, following the
|
|
111
|
+
* precedent opencode.js set for its auth.json. Flagging those re-reports
|
|
112
|
+
* what the user put there on purpose. The line drawn: a file that holds
|
|
113
|
+
* secrets BY DESIGN is excluded; a file that accumulates secrets by
|
|
114
|
+
* accident (settings.local.json's approved-command cache — Lakera's
|
|
115
|
+
* finding) is exactly what this source is for.
|
|
116
|
+
* - `~/.gemini/GEMINI.md`, `~/.codex/AGENTS.md` — vendor-documented
|
|
117
|
+
* memory files, but the research digest never names either exact
|
|
118
|
+
* home-level path, leaving them one source short of this project's
|
|
119
|
+
* verification bar. Add-with-citation candidates, not omissions by
|
|
120
|
+
* oversight.
|
|
121
|
+
* - Windsurf/OpenClaw/other "equivalents" — the digest gestures at them
|
|
122
|
+
* without naming an exact home-level path; no path, no scan.
|
|
123
|
+
*
|
|
124
|
+
* If any of these tools is installed on your machine, the most useful
|
|
125
|
+
* thing you can do is run `residoo scan` and confirm the per-source file
|
|
126
|
+
* counts match what you know is on disk, then report back either way —
|
|
127
|
+
* see CONTRIBUTING.md.
|
|
128
|
+
*/
|
|
129
|
+
function claudeDesktopConfig() {
|
|
130
|
+
const home = os.homedir();
|
|
131
|
+
if (process.platform === "darwin") {
|
|
132
|
+
return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
133
|
+
}
|
|
134
|
+
if (process.platform === "win32") {
|
|
135
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
136
|
+
return path.join(appData, "Claude", "claude_desktop_config.json");
|
|
137
|
+
}
|
|
138
|
+
return null; // Linux: no official build, unofficial ports disagree — see header
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function geminiDir() {
|
|
142
|
+
// GEMINI_CLI_HOME is the tool's own documented override (the CLI creates
|
|
143
|
+
// a `.gemini` folder INSIDE it) — same resolution gemini-cli.js verified
|
|
144
|
+
// from the project's source, duplicated per the one-file-per-source rule.
|
|
145
|
+
if (process.env.GEMINI_CLI_HOME) return path.join(process.env.GEMINI_CLI_HOME, ".gemini");
|
|
146
|
+
return path.join(os.homedir(), ".gemini");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function codexHome() {
|
|
150
|
+
// CODEX_HOME per official docs covers "config", not just sessions —
|
|
151
|
+
// same resolution codex-cli.js uses.
|
|
152
|
+
if (process.env.CODEX_HOME) return process.env.CODEX_HOME;
|
|
153
|
+
return path.join(os.homedir(), ".codex");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const HOME = os.homedir();
|
|
157
|
+
const CLAUDE_DIR = path.join(HOME, ".claude");
|
|
158
|
+
const DESKTOP_CONFIG = claudeDesktopConfig();
|
|
159
|
+
const GEMINI_DIR = geminiDir();
|
|
160
|
+
const CODEX_HOME = codexHome();
|
|
161
|
+
const CURSOR_DIR = path.join(HOME, ".cursor");
|
|
162
|
+
const KIRO_DIR = path.join(HOME, ".kiro");
|
|
163
|
+
|
|
164
|
+
// Every candidate is a single fixed file path (see header for what verified
|
|
165
|
+
// each). Absence is normal and yields nothing — most machines have a few of
|
|
166
|
+
// these tools at most; only a path that LOOKS present but can't be resolved
|
|
167
|
+
// is reported broken.
|
|
168
|
+
const CANDIDATES = [
|
|
169
|
+
path.join(HOME, ".claude.json"),
|
|
170
|
+
path.join(HOME, ".claude.json.backup"),
|
|
171
|
+
path.join(CLAUDE_DIR, "settings.json"),
|
|
172
|
+
path.join(CLAUDE_DIR, "settings.local.json"),
|
|
173
|
+
path.join(CLAUDE_DIR, "mcp.json"),
|
|
174
|
+
path.join(CLAUDE_DIR, "CLAUDE.md"),
|
|
175
|
+
...(DESKTOP_CONFIG ? [DESKTOP_CONFIG] : []),
|
|
176
|
+
path.join(CURSOR_DIR, "mcp.json"),
|
|
177
|
+
path.join(GEMINI_DIR, "settings.json"),
|
|
178
|
+
path.join(CODEX_HOME, "config.toml"),
|
|
179
|
+
path.join(KIRO_DIR, "settings", "mcp.json"),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
// Configs are KB-scale in every real observation this source's research
|
|
183
|
+
// produced (the largest, a live ~/.claude.json accumulating per-project
|
|
184
|
+
// state, was tens of KB; community bloat reports for that file reach tens
|
|
185
|
+
// of MB). 64MB is a corrupted-or-pathological-file backstop, not a bound
|
|
186
|
+
// derived from a real file — same caveat cursor.js states for MAX_DB_BYTES.
|
|
187
|
+
// A file over it is surfaced as "too-large", never silently skipped.
|
|
188
|
+
const MAX_BYTES = 64 * 1024 * 1024;
|
|
189
|
+
const READ_TIMEOUT_MS = 60_000;
|
|
190
|
+
|
|
191
|
+
function id() { return "agent-configs"; }
|
|
192
|
+
function label() { return "Agent config files"; }
|
|
193
|
+
|
|
194
|
+
function dirExists(p) {
|
|
195
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
196
|
+
}
|
|
197
|
+
function fileExists(p) {
|
|
198
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Available when any of the config ROOTS exists — not just when a candidate
|
|
203
|
+
* file does. A machine with an empty `~/.cursor` should still show this
|
|
204
|
+
* source as checked (finding nothing is a result), while a machine with
|
|
205
|
+
* none of these tools shouldn't list it at all.
|
|
206
|
+
*/
|
|
207
|
+
function available() {
|
|
208
|
+
return (
|
|
209
|
+
fileExists(path.join(HOME, ".claude.json")) ||
|
|
210
|
+
dirExists(CLAUDE_DIR) ||
|
|
211
|
+
(DESKTOP_CONFIG !== null && dirExists(path.dirname(DESKTOP_CONFIG))) ||
|
|
212
|
+
dirExists(CURSOR_DIR) ||
|
|
213
|
+
dirExists(GEMINI_DIR) ||
|
|
214
|
+
dirExists(CODEX_HOME) ||
|
|
215
|
+
dirExists(KIRO_DIR)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve one fixed candidate path into zero or one files() entries — the
|
|
221
|
+
* same lstat-then-follow shape as cursor.js's statIfPresent, duplicated per
|
|
222
|
+
* the one-file-per-source convention. These paths are constructed, not
|
|
223
|
+
* discovered by a directory listing, so there is no Dirent to reuse:
|
|
224
|
+
* absence yields nothing (normal — see CANDIDATES), a dangling symlink
|
|
225
|
+
* yields broken (a dotfiles manager symlinking `~/.claude/settings.json`
|
|
226
|
+
* at a moved target is the realistic case, and silently skipping it is the
|
|
227
|
+
* exact bug claude-code.js's files() docstring exists to prevent), and
|
|
228
|
+
* something that is neither file nor symlink at the path is out of scope.
|
|
229
|
+
*/
|
|
230
|
+
function* statIfPresent(p) {
|
|
231
|
+
let lst;
|
|
232
|
+
try { lst = fs.lstatSync(p); }
|
|
233
|
+
catch (err) {
|
|
234
|
+
// ENOENT/ENOTDIR is the normal not-installed case and yields nothing.
|
|
235
|
+
// Any other lstat failure (EACCES on `~/.claude` itself, ELOOP) means a
|
|
236
|
+
// candidate may exist but can't be examined — that's a broken entry,
|
|
237
|
+
// not absence: available() can still say true for the root, and a
|
|
238
|
+
// silently empty files() would be the exact silent-exclusion bug the
|
|
239
|
+
// yield-broken convention exists to prevent.
|
|
240
|
+
if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return;
|
|
241
|
+
yield { file: p, broken: true };
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (lst.isSymbolicLink()) {
|
|
246
|
+
try {
|
|
247
|
+
const st = fs.statSync(p); // follow the link
|
|
248
|
+
if (!st.isFile()) { yield { file: p, broken: true }; return; }
|
|
249
|
+
yield { file: p, mtimeMs: st.mtimeMs, sizeBytes: st.size, broken: false };
|
|
250
|
+
} catch {
|
|
251
|
+
yield { file: p, broken: true }; // dangling symlink
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!lst.isFile()) return;
|
|
257
|
+
yield { file: p, mtimeMs: lst.mtimeMs, sizeBytes: lst.size, broken: false };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Yield { file, mtimeMs, sizeBytes, broken } for every candidate present. */
|
|
261
|
+
function* files() {
|
|
262
|
+
for (const p of CANDIDATES) yield* statIfPresent(p);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Read one config file as raw text lines — the same streaming reader as
|
|
267
|
+
* claude-code.js's readLines (see that docstring for the timeout rationale:
|
|
268
|
+
* a symlink retargeted between stat and open can block open() forever, and
|
|
269
|
+
* destroying the stream is the only way out). Configs are JSON, TOML, or
|
|
270
|
+
* Markdown rather than JSONL, which changes nothing for the caller: scan.js
|
|
271
|
+
* matches raw text lines, and a token inside a pretty-printed `"env"` block
|
|
272
|
+
* or a TOML `env` table sits on its own line just like a JSONL record does.
|
|
273
|
+
* A single-line minified JSON config arrives as one long line — still
|
|
274
|
+
* within the streaming reader's per-line limits at this source's size cap.
|
|
275
|
+
*
|
|
276
|
+
* Same status contract as every source: "complete", "partial" (some lines
|
|
277
|
+
* were read before a failure — scanned, and flagged), "too-large", "failed".
|
|
278
|
+
*/
|
|
279
|
+
async function readLines(file) {
|
|
280
|
+
let stat;
|
|
281
|
+
try { stat = fs.statSync(file); }
|
|
282
|
+
catch { return { lines: [], status: "failed", bytesRead: 0 }; }
|
|
283
|
+
if (stat.size > MAX_BYTES) return { lines: [], status: "too-large", bytesRead: 0 };
|
|
284
|
+
|
|
285
|
+
const lines = [];
|
|
286
|
+
let bytesRead = 0;
|
|
287
|
+
const stream = fs.createReadStream(file, { encoding: "utf-8" });
|
|
288
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
289
|
+
const timer = setTimeout(() => stream.destroy(new Error("read timed out")), READ_TIMEOUT_MS);
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
for await (const line of rl) {
|
|
293
|
+
lines.push(line);
|
|
294
|
+
bytesRead += Buffer.byteLength(line, "utf-8") + 1; // +1 for the stripped newline
|
|
295
|
+
}
|
|
296
|
+
return { lines, status: "complete", bytesRead };
|
|
297
|
+
} catch {
|
|
298
|
+
// Lines read before the failure are real content and may hold a real
|
|
299
|
+
// secret — an honest "partial" beats a silent false negative.
|
|
300
|
+
return { lines, status: lines.length > 0 ? "partial" : "failed", bytesRead };
|
|
301
|
+
} finally {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
rl.close();
|
|
304
|
+
stream.destroy();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = { id, label, available, files, readLines };
|