@shomra/agent 0.3.16 → 0.3.18

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 (156) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +57 -57
  3. package/package.json +3 -9
  4. package/shomra.mjs +9 -7168
  5. package/src/agents/hook-command.mjs +19 -0
  6. package/src/agents/hook-files.mjs +41 -0
  7. package/src/agents/installers.mjs +203 -0
  8. package/src/artifacts/matchers.mjs +59 -0
  9. package/src/artifacts/report.mjs +50 -0
  10. package/src/cli/flags.mjs +68 -0
  11. package/src/cli/help-sections.mjs +309 -0
  12. package/src/cli/help.mjs +27 -0
  13. package/src/cli/main.mjs +55 -0
  14. package/src/cli/registry.mjs +80 -0
  15. package/src/cli/suggestions.mjs +33 -0
  16. package/src/commands/add.mjs +149 -0
  17. package/src/commands/agent-identity.mjs +46 -0
  18. package/src/commands/check.mjs +194 -0
  19. package/src/commands/corpus.mjs +126 -0
  20. package/src/commands/design.mjs +168 -0
  21. package/src/commands/doctor.mjs +209 -0
  22. package/src/commands/fix.mjs +115 -0
  23. package/src/commands/gate.mjs +154 -0
  24. package/src/commands/git-hooks.mjs +163 -0
  25. package/src/commands/init.mjs +36 -0
  26. package/src/commands/install-hook.mjs +51 -0
  27. package/src/commands/llm-proxy.mjs +153 -0
  28. package/src/commands/mcp-add.mjs +185 -0
  29. package/src/commands/mcp.mjs +143 -0
  30. package/src/commands/memory-scan.mjs +181 -0
  31. package/src/commands/model-scan.mjs +99 -0
  32. package/src/commands/models.mjs +145 -0
  33. package/src/commands/new.mjs +64 -0
  34. package/src/commands/plan.mjs +87 -0
  35. package/src/commands/pr.mjs +249 -0
  36. package/src/commands/protect.mjs +38 -0
  37. package/src/commands/provenance.mjs +91 -0
  38. package/src/commands/redteam.mjs +166 -0
  39. package/src/commands/rules.mjs +220 -0
  40. package/src/commands/run.mjs +128 -0
  41. package/src/commands/scan-zip.mjs +118 -0
  42. package/src/commands/scan.mjs +102 -0
  43. package/src/commands/secrets.mjs +99 -0
  44. package/src/commands/status.mjs +50 -0
  45. package/src/commands/why.mjs +88 -0
  46. package/src/core/api-client.mjs +66 -0
  47. package/src/core/api-key.mjs +6 -0
  48. package/src/core/circuit-breaker.mjs +42 -0
  49. package/src/core/config.mjs +37 -0
  50. package/src/core/exit-codes.mjs +9 -0
  51. package/src/core/json-file.mjs +13 -0
  52. package/src/core/numbers.mjs +4 -0
  53. package/src/core/package-root.mjs +10 -0
  54. package/src/core/terminal.mjs +16 -0
  55. package/src/core/version.mjs +14 -0
  56. package/src/core/wire-limits.mjs +53 -0
  57. package/src/corpus/screening.mjs +127 -0
  58. package/{ai-usage.mjs → src/detect/ai-usage.mjs} +0 -27
  59. package/src/detect/code-sast.mjs +2 -0
  60. package/{design.mjs → src/detect/design.mjs} +18 -107
  61. package/src/detect/guard-signals.mjs +18 -0
  62. package/{model-refs.mjs → src/detect/model-refs.mjs} +18 -77
  63. package/src/detect/sast/chains.mjs +30 -0
  64. package/src/detect/sast/path-expressions.mjs +76 -0
  65. package/src/detect/sast/rules-chains.mjs +33 -0
  66. package/src/detect/sast/rules-config.mjs +51 -0
  67. package/src/detect/sast/rules-javascript.mjs +109 -0
  68. package/src/detect/sast/rules-python.mjs +292 -0
  69. package/src/detect/sast/scanner.mjs +104 -0
  70. package/src/detect/sast/source-lines.mjs +115 -0
  71. package/src/detect/sast/taint.mjs +71 -0
  72. package/src/detect/signals/artifacts.mjs +113 -0
  73. package/src/detect/signals/autonomy.mjs +55 -0
  74. package/src/detect/signals/config-markers.mjs +28 -0
  75. package/src/detect/signals/credential-harvest.mjs +64 -0
  76. package/src/detect/signals/durable-claims.mjs +73 -0
  77. package/src/detect/signals/egress.mjs +56 -0
  78. package/src/detect/signals/execution-hijack.mjs +128 -0
  79. package/src/detect/signals/gate.mjs +91 -0
  80. package/src/detect/signals/injection.mjs +55 -0
  81. package/src/detect/signals/lines.mjs +42 -0
  82. package/src/detect/signals/masking.mjs +99 -0
  83. package/src/detect/signals/memory.mjs +357 -0
  84. package/src/detect/signals/packages.mjs +45 -0
  85. package/src/detect/signals/propagation.mjs +86 -0
  86. package/src/detect/signals/prose-context.mjs +82 -0
  87. package/src/detect/signals/scan.mjs +91 -0
  88. package/src/detect/signals/secrets.mjs +85 -0
  89. package/src/detect/signals/sensitive.mjs +9 -0
  90. package/src/detect/signals/severity.mjs +10 -0
  91. package/src/detect/signals/shell.mjs +96 -0
  92. package/src/detect/signals/staged-fetch.mjs +66 -0
  93. package/src/detect/signals/text-match.mjs +35 -0
  94. package/src/gate/batch.mjs +157 -0
  95. package/src/gate/environment.mjs +122 -0
  96. package/src/gate/repo-policy.mjs +65 -0
  97. package/src/gate/result.mjs +53 -0
  98. package/src/gate/sarif.mjs +33 -0
  99. package/src/gate/sast.mjs +64 -0
  100. package/src/gate/suppressions.mjs +0 -0
  101. package/src/guard/classify.mjs +50 -0
  102. package/src/guard/emit.mjs +51 -0
  103. package/src/guard/ignore.mjs +24 -0
  104. package/src/guard/ledger.mjs +112 -0
  105. package/src/guard/model-load.mjs +50 -0
  106. package/src/guard/normalize.mjs +77 -0
  107. package/src/guard/options.mjs +10 -0
  108. package/src/guard/prompt-guard.mjs +184 -0
  109. package/src/guard/report.mjs +35 -0
  110. package/src/guard/result-guard.mjs +140 -0
  111. package/src/guard/tool-guard.mjs +166 -0
  112. package/src/inventory/agent-artifacts.mjs +5 -0
  113. package/src/inventory/agent-posture.mjs +249 -0
  114. package/src/inventory/artifacts/classify.mjs +27 -0
  115. package/src/inventory/artifacts/discover.mjs +187 -0
  116. package/src/inventory/artifacts/file-read.mjs +42 -0
  117. package/src/inventory/artifacts/hooks.mjs +14 -0
  118. package/src/inventory/artifacts/limits.mjs +37 -0
  119. package/src/inventory/artifacts/marketplaces.mjs +45 -0
  120. package/src/inventory/artifacts/roots.mjs +20 -0
  121. package/src/inventory/artifacts/walk.mjs +36 -0
  122. package/src/inventory/discovery/ai-dependencies.mjs +161 -0
  123. package/src/inventory/discovery/ai-tools.mjs +23 -0
  124. package/src/inventory/discovery/all.mjs +40 -0
  125. package/src/inventory/discovery/coding-agents.mjs +77 -0
  126. package/src/inventory/discovery/fs-read.mjs +36 -0
  127. package/src/inventory/discovery/local-runtimes.mjs +53 -0
  128. package/src/inventory/discovery/mcp-clients.mjs +67 -0
  129. package/src/inventory/discovery/mcp-servers.mjs +78 -0
  130. package/src/inventory/discovery/model-keys.mjs +97 -0
  131. package/src/inventory/discovery/platform.mjs +16 -0
  132. package/src/inventory/discovery/rules-files.mjs +25 -0
  133. package/src/inventory/discovery/vector-stores.mjs +176 -0
  134. package/src/inventory/discovery/workspace.mjs +124 -0
  135. package/src/inventory/discovery.mjs +10 -0
  136. package/src/mcp/child-process.mjs +50 -0
  137. package/src/mcp/config-wrapping.mjs +75 -0
  138. package/src/mcp/connect-gate.mjs +45 -0
  139. package/src/mcp/hosts.mjs +16 -0
  140. package/src/mcp/jsonrpc.mjs +48 -0
  141. package/src/mcp/lookup.mjs +50 -0
  142. package/src/mcp/screening.mjs +103 -0
  143. package/src/mcp/server-tools.mjs +97 -0
  144. package/src/mcp/server.mjs +102 -0
  145. package/src/mcp/shim.mjs +205 -0
  146. package/src/models/lookup.mjs +79 -0
  147. package/src/models/references.mjs +103 -0
  148. package/src/rules/context.mjs +98 -0
  149. package/src/rules/generate.mjs +103 -0
  150. package/src/rules/sections.mjs +145 -0
  151. package/src/scaffold/agent-project.mjs +185 -0
  152. package/src/scaffold/artifact-templates.mjs +35 -0
  153. package/code-sast.mjs +0 -1063
  154. package/discovery.mjs +0 -977
  155. package/guard-ledger.mjs +0 -239
  156. package/guard-signals.mjs +0 -1268
package/discovery.mjs DELETED
@@ -1,977 +0,0 @@
1
- /**
2
- * Cross-platform discovery of AI tooling on a developer machine. Pure Node
3
- * built-ins. Each discoverer is best-effort and isolated — a missing or
4
- * malformed file, a blocked process listing, or a slow walk never aborts the
5
- * scan. Returns a flat list of assets in the shape the Shomra backend's
6
- * /agent/report endpoint expects (types: MCP_SERVER | AI_TOOL | AI_RULES |
7
- * MODEL_KEY | AI_AGENT).
8
- *
9
- * Detection layers:
10
- * 1. Fixed global paths for known AI clients / coding agents / runtimes.
11
- * 2. A bounded walk of the developer's real workspace — cwd plus the common
12
- * project-parent dirs under $HOME (Desktop, repos, source, projects, …) —
13
- * that finds project-local MCP configs, AI rules files, AI-SDK
14
- * dependencies in manifests, and API keys sitting in .env files.
15
- * 3. Local model runtimes (Ollama / LM Studio / Jan / GPT4All / HF cache)
16
- * by directory AND by running process.
17
- * 4. Model-provider API keys in the environment.
18
- */
19
- import fs from 'node:fs';
20
- import path from 'node:path';
21
- import os from 'node:os';
22
- import { execFileSync } from 'node:child_process';
23
- import { scanAiUsage, rollupAiUsage, isAiUsageScannable, AI_USAGE_CATEGORY_LABEL } from './ai-usage.mjs';
24
- import { clampAsset } from './wire-limits.mjs';
25
- import { readVendorPosture, canonicalGrant } from './agent-posture.mjs';
26
-
27
- const HOME = os.homedir();
28
- const APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
29
- const LOCALAPPDATA = process.env.LOCALAPPDATA || path.join(HOME, 'AppData', 'Local');
30
- const PLAT = process.platform;
31
-
32
- /** VS Code (and forks) per-user dir, where extensions keep global state. */
33
- function vscodeUserDir(variant = 'Code') {
34
- if (PLAT === 'win32') return path.join(APPDATA, variant, 'User');
35
- if (PLAT === 'darwin') return path.join(HOME, 'Library', 'Application Support', variant, 'User');
36
- return path.join(HOME, '.config', variant, 'User');
37
- }
38
-
39
- function readJson(file) {
40
- try {
41
- return JSON.parse(stripJsonComments(fs.readFileSync(file, 'utf8')));
42
- } catch {
43
- return null;
44
- }
45
- }
46
- /** VS Code / Cursor settings are JSONC — tolerate // and /* *​/ comments. */
47
- function stripJsonComments(s) {
48
- return String(s)
49
- .replace(/\/\*[\s\S]*?\*\//g, '')
50
- .replace(/(^|[^:])\/\/.*$/gm, '$1');
51
- }
52
- function readText(file, cap = 200_000) {
53
- try {
54
- const b = fs.readFileSync(file, 'utf8');
55
- return b.length > cap ? b.slice(0, cap) : b;
56
- } catch {
57
- return null;
58
- }
59
- }
60
- function exists(p) {
61
- try {
62
- return fs.existsSync(p);
63
- } catch {
64
- return false;
65
- }
66
- }
67
- function firstExisting(paths) {
68
- return paths.find((p) => p && exists(p)) || null;
69
- }
70
-
71
- // ── workspace root discovery ─────────────────────────────────────
72
- // Real AI assets live scattered across a developer's project folders, so
73
- // discovery walks those folders rather than assuming the CLI was launched from
74
- // inside one.
75
-
76
- const IGNORE_DIRS = new Set([
77
- 'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt',
78
- '.cache', '.venv', 'venv', 'env', '__pycache__', '.tox', 'target', 'vendor',
79
- 'bin', 'obj', '.gradle', '.idea', 'coverage', '.pytest_cache', '.mypy_cache',
80
- 'Pods', '.terraform', '.expo', 'tmp', 'temp', '.turbo', '.parcel-cache',
81
- '.svelte-kit', 'bower_components', '.pnpm-store', 'site-packages', '.yarn',
82
- ]);
83
-
84
- /** Common parent dirs under $HOME where people keep code checkouts. */
85
- function workspaceParents() {
86
- const names = [
87
- 'Desktop', 'Documents', 'source', 'source/repos', 'repos', 'Repos',
88
- 'projects', 'Projects', 'dev', 'Dev', 'Developer', 'git', 'Git', 'code',
89
- 'Code', 'workspace', 'Workspace', 'work', 'src', 'go/src', 'ghq',
90
- 'OneDrive/Desktop', 'OneDrive/Documents',
91
- ];
92
- return names.map((n) => path.join(HOME, n)).filter(exists);
93
- }
94
-
95
- /**
96
- * Expand the caller's roots into the set of project directories to scan.
97
- * When autoExpand is on, add the immediate subdirectories of the common
98
- * workspace parents (depth 1) as candidate roots — capped so a machine with
99
- * hundreds of repos stays fast.
100
- */
101
- function resolveRoots(roots, autoExpand) {
102
- const out = new Set();
103
- for (const r of roots || []) if (r) out.add(path.resolve(r));
104
- if (autoExpand) {
105
- out.add(HOME); // catch dotfile configs / .env at the home root (shallow — see maxDepth)
106
- for (const parent of workspaceParents()) {
107
- out.add(parent);
108
- try {
109
- for (const e of fs.readdirSync(parent, { withFileTypes: true })) {
110
- if (e.isDirectory() && !e.name.startsWith('.') && !IGNORE_DIRS.has(e.name)) {
111
- out.add(path.join(parent, e.name));
112
- }
113
- }
114
- } catch {
115
- /* unreadable parent */
116
- }
117
- }
118
- }
119
- return [...out].slice(0, 500);
120
- }
121
-
122
- const RULE_NAMES = new Set([
123
- '.cursorrules', '.windsurfrules', '.clinerules', '.roorules', '.aider.conf.yml',
124
- '.aider.conf.yaml', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md', 'copilot-instructions.md',
125
- ]);
126
- const MANIFEST_NAMES = new Set([
127
- 'package.json', 'requirements.txt', 'requirements-dev.txt', 'pyproject.toml',
128
- 'Pipfile', 'environment.yml', 'environment.yaml',
129
- ]);
130
- const isEnvFile = (base) =>
131
- /^\.env(\..+)?$/.test(base) && !/(example|sample|template|dist)/i.test(base);
132
-
133
- // Persisted on-disk vector-store / embedding-index artifacts. `index.pkl` is
134
- // LangChain FAISS.save_local's pickle sidecar — a code-execution surface on
135
- // load — so we track it, but only mint a store when its `index.faiss` sibling
136
- // is present (see discoverVectorStores) to avoid flagging unrelated pickles.
137
- const VECTOR_INDEX_BASENAMES = new Set([
138
- 'chroma.sqlite3', // Chroma persistent client (sqlite backend)
139
- 'index.faiss', 'index.pkl', // LangChain FAISS.save_local pair
140
- 'docstore.json', 'default__vector_store.json', // LlamaIndex persist dir
141
- 'chroma-embeddings.parquet', 'chroma-collections.parquet', // legacy Chroma (duckdb+parquet)
142
- ]);
143
- const VECTOR_INDEX_EXTS = new Set(['faiss', 'lance', 'usearch']);
144
- const isVectorIndex = (base) =>
145
- VECTOR_INDEX_BASENAMES.has(base.toLowerCase()) ||
146
- VECTOR_INDEX_EXTS.has((base.slice(base.lastIndexOf('.') + 1) || '').toLowerCase());
147
-
148
- /** Cap on SOURCE files collected for AI-usage-in-code scanning (bounded so a big
149
- * monorepo can't turn the sweep into a full read of every .py/.ts on disk). */
150
- const MAX_SOURCE_FILES = 600;
151
-
152
- /**
153
- * One bounded breadth-first walk per root that collects every file of interest.
154
- * Returns { mcp:[], rules:[], manifests:[], env:[], vector:[], source:[] } lists.
155
- * Depth- and count-limited so it never turns into a full-disk crawl.
156
- */
157
- function walkWorkspace(roots) {
158
- const found = { mcp: [], rules: [], manifests: [], env: [], vector: [], source: [] };
159
- const seenDir = new Set();
160
- let budget = 40_000; // total directories visited across all roots
161
- const maxDepth = 6;
162
-
163
- const consider = (base, full, parentBase) => {
164
- if (base === '.mcp.json' || base === 'mcp.json') found.mcp.push({ file: full, parentBase });
165
- else if (base === 'settings.json' && (parentBase === '.gemini' || parentBase === '.zed'))
166
- found.mcp.push({ file: full, parentBase });
167
- else if (RULE_NAMES.has(base)) {
168
- if (base === 'copilot-instructions.md' && parentBase !== '.github') return;
169
- found.rules.push({ file: full, parentBase });
170
- } else if (MANIFEST_NAMES.has(base)) found.manifests.push({ file: full, parentBase });
171
- else if (isEnvFile(base)) found.env.push({ file: full, parentBase });
172
- else if (isVectorIndex(base)) found.vector.push({ file: full, parentBase });
173
- // Application source — collected (capped) so discoverAiUsageInCode can find
174
- // the LLM/AI providers this code actually calls, not just what a manifest declares.
175
- else if (found.source.length < MAX_SOURCE_FILES && isAiUsageScannable(base)) found.source.push({ file: full, parentBase });
176
- };
177
-
178
- for (const root of roots) {
179
- const queue = [{ dir: root, depth: 0 }];
180
- while (queue.length && budget > 0) {
181
- const { dir, depth } = queue.shift();
182
- let real;
183
- try {
184
- real = fs.realpathSync(dir);
185
- } catch {
186
- continue;
187
- }
188
- if (seenDir.has(real)) continue; // dedup shared roots / symlink loops
189
- seenDir.add(real);
190
- budget--;
191
- let entries;
192
- try {
193
- entries = fs.readdirSync(dir, { withFileTypes: true });
194
- } catch {
195
- continue;
196
- }
197
- for (const e of entries) {
198
- const full = path.join(dir, e.name);
199
- if (e.isDirectory()) {
200
- if (depth < maxDepth && !IGNORE_DIRS.has(e.name)) queue.push({ dir: full, depth: depth + 1 });
201
- } else if (e.isFile()) {
202
- consider(e.name, full, path.basename(dir));
203
- }
204
- }
205
- }
206
- }
207
- return found;
208
- }
209
-
210
- function vendorFromPath(file) {
211
- if (/[\\/]\.cursor[\\/]/.test(file)) return 'cursor';
212
- if (/[\\/]\.vscode[\\/]/.test(file)) return 'vscode';
213
- if (/[\\/]\.gemini[\\/]/.test(file)) return 'gemini';
214
- if (/[\\/]\.zed[\\/]/.test(file)) return 'zed';
215
- return 'project';
216
- }
217
-
218
- // ── MCP servers ──────────────────────────────────────────────────
219
-
220
- /** Global MCP config files across the major AI clients, per-platform. */
221
- function globalMcpCandidates() {
222
- const c = [];
223
- if (PLAT === 'win32') c.push({ vendor: 'claude', file: path.join(APPDATA, 'Claude', 'claude_desktop_config.json') });
224
- else if (PLAT === 'darwin') c.push({ vendor: 'claude', file: path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') });
225
- else c.push({ vendor: 'claude', file: path.join(HOME, '.config', 'Claude', 'claude_desktop_config.json') });
226
- c.push({ vendor: 'cursor', file: path.join(HOME, '.cursor', 'mcp.json') });
227
- c.push({ vendor: 'windsurf', file: path.join(HOME, '.codeium', 'windsurf', 'mcp_config.json') });
228
- c.push({ vendor: 'continue', file: path.join(HOME, '.continue', 'config.json') });
229
- c.push({ vendor: 'claude-code', file: path.join(HOME, '.claude.json') });
230
- c.push({ vendor: 'gemini', file: path.join(HOME, '.gemini', 'settings.json') });
231
- c.push({ vendor: 'cline', file: path.join(vscodeUserDir(), 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json') });
232
- c.push({ vendor: 'roo', file: path.join(vscodeUserDir(), 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json') });
233
- // VS Code / Cursor native MCP + Zed context servers.
234
- c.push({ vendor: 'vscode', file: path.join(vscodeUserDir(), 'mcp.json') });
235
- c.push({ vendor: 'vscode', file: path.join(vscodeUserDir(), 'settings.json') });
236
- c.push({ vendor: 'cursor', file: path.join(vscodeUserDir('Cursor'), 'settings.json') });
237
- c.push({ vendor: 'zed', file: PLAT === 'darwin' ? path.join(HOME, 'Library', 'Application Support', 'Zed', 'settings.json') : path.join(HOME, '.config', 'zed', 'settings.json') });
238
- return c;
239
- }
240
-
241
- /** Pull the server map out of the many shapes these configs use. */
242
- function extractServers(json) {
243
- if (!json || typeof json !== 'object') return {};
244
- return (
245
- json.mcpServers ||
246
- json.servers ||
247
- json['mcp.servers'] ||
248
- json.mcp?.servers ||
249
- json.context_servers || // Zed
250
- {}
251
- );
252
- }
253
-
254
- export function discoverMcpServers(roots = [process.cwd()], files = null) {
255
- const walk = files || walkWorkspace(roots);
256
- const candidates = [
257
- ...globalMcpCandidates(),
258
- ...walk.mcp.map(({ file }) => ({ vendor: vendorFromPath(file), file })),
259
- ];
260
- const assets = [];
261
- const seen = new Set();
262
- for (const { vendor, file } of candidates) {
263
- const json = readJson(file);
264
- if (!json) continue;
265
- const servers = extractServers(json);
266
- for (const [name, cfg] of Object.entries(servers)) {
267
- if (!cfg || typeof cfg !== 'object') continue;
268
- const command = [cfg.command, ...(Array.isArray(cfg.args) ? cfg.args : [])].filter(Boolean).join(' ');
269
- const identifier = cfg.url || cfg.serverUrl || command || name;
270
- const key = `${name}:${identifier}`;
271
- if (seen.has(key)) continue;
272
- seen.add(key);
273
- assets.push({
274
- type: 'MCP_SERVER',
275
- name,
276
- identifier,
277
- vendor,
278
- metadata: { command, url: cfg.url || cfg.serverUrl || null, configFile: file, env: redactEnv(cfg.env) },
279
- // Content the backend statically analyzes (command + env values + url).
280
- content: JSON.stringify({ command, url: cfg.url || cfg.serverUrl, env: cfg.env || {} }),
281
- });
282
- }
283
- }
284
- return assets;
285
- }
286
-
287
- // ── AI rules / instruction files ─────────────────────────────────
288
-
289
- /** Known AI rules / instruction files an agent treats as trusted input. */
290
- export function discoverRulesFiles(roots = [process.cwd()], files = null) {
291
- const walk = files || walkWorkspace(roots);
292
- const assets = [];
293
- const seen = new Set();
294
- for (const { file } of walk.rules) {
295
- if (seen.has(file)) continue;
296
- seen.add(file);
297
- const content = readText(file, 50_000);
298
- if (content == null) continue;
299
- assets.push({
300
- type: 'AI_RULES',
301
- name: path.basename(file),
302
- identifier: file,
303
- vendor: vendorFromPath(file) === 'project' ? 'rules' : vendorFromPath(file),
304
- metadata: { bytes: content.length, dir: path.dirname(file) },
305
- content: content.slice(0, 50_000),
306
- });
307
- }
308
- return assets;
309
- }
310
-
311
- // ── AI-SDK dependencies in code ──────────────────────────────────
312
- // A repo that imports openai / anthropic / langchain IS an AI asset even with
313
- // no MCP config. We surface each AI library once per machine (with the sample
314
- // manifests that pull it in) so shadow AI usage in code becomes visible.
315
-
316
- const NPM_AI = new Set([
317
- 'openai', 'ai', 'langchain', 'llamaindex', 'ollama', 'replicate', 'cohere-ai',
318
- 'groq-sdk', 'together-ai', 'openrouter', 'mistralai', 'chromadb',
319
- ]);
320
- const NPM_AI_PREFIX = ['@anthropic-ai/', '@google/generative-ai', '@google/genai', '@ai-sdk/', '@langchain/', '@llamaindex/', '@mistralai/', '@huggingface/', '@pinecone-database/', '@qdrant/'];
321
- const PY_AI = [
322
- 'openai', 'anthropic', 'google-generativeai', 'google-genai', 'langchain',
323
- 'langchain-openai', 'langchain-anthropic', 'langchain-community', 'llama-index',
324
- 'llama_index', 'transformers', 'sentence-transformers', 'mistralai', 'cohere',
325
- 'groq', 'huggingface-hub', 'huggingface_hub', 'ollama', 'litellm', 'guidance',
326
- 'vllm', 'crewai', 'autogen', 'pyautogen', 'haystack-ai', 'instructor', 'dspy',
327
- 'dspy-ai', 'semantic-kernel', 'replicate', 'together', 'chromadb', 'qdrant-client',
328
- 'pinecone-client', 'pinecone', 'faiss-cpu', 'faiss-gpu', 'tiktoken',
329
- ];
330
-
331
- // Vector-store / embedding-index client libraries. Each gets its own
332
- // VECTOR_STORE asset (discoverVectorStores) instead of a generic AI_TOOL, so
333
- // they're excluded from the AI-SDK dependency roll-up above. `hosted` marks a
334
- // managed/cloud store — embedded data leaves the environment to a third party.
335
- const VECTOR_LIBS = {
336
- chromadb: { engine: 'chroma', hosted: false },
337
- 'faiss-cpu': { engine: 'faiss', hosted: false },
338
- 'faiss-gpu': { engine: 'faiss', hosted: false },
339
- lancedb: { engine: 'lancedb', hosted: false },
340
- pgvector: { engine: 'pgvector', hosted: false },
341
- 'qdrant-client': { engine: 'qdrant', hosted: true },
342
- 'pinecone-client': { engine: 'pinecone', hosted: true },
343
- pinecone: { engine: 'pinecone', hosted: true },
344
- 'weaviate-client': { engine: 'weaviate', hosted: true },
345
- 'weaviate-ts-client': { engine: 'weaviate', hosted: true },
346
- pymilvus: { engine: 'milvus', hosted: true },
347
- };
348
- /** Resolve a package name (incl. scoped npm prefixes) to its vector engine. */
349
- function vectorLibInfo(pkg) {
350
- if (VECTOR_LIBS[pkg]) return VECTOR_LIBS[pkg];
351
- if (pkg.startsWith('@pinecone-database/')) return { engine: 'pinecone', hosted: true };
352
- if (pkg.startsWith('@qdrant/')) return { engine: 'qdrant', hosted: true };
353
- return null;
354
- }
355
- const isVectorLib = (pkg) => !!vectorLibInfo(pkg);
356
- // Python vector-store packages, matched by import/require name in text manifests.
357
- const PY_VECTOR = [
358
- 'chromadb', 'faiss-cpu', 'faiss-gpu', 'lancedb', 'pgvector', 'qdrant-client',
359
- 'pinecone-client', 'pinecone', 'weaviate-client', 'pymilvus',
360
- ];
361
- // .env variable names that configure a managed/cloud vector store. `kind`
362
- // distinguishes an endpoint (egress target) from a credential.
363
- const VECTOR_ENV = {
364
- PINECONE_API_KEY: { engine: 'pinecone', kind: 'key' },
365
- PINECONE_ENVIRONMENT: { engine: 'pinecone', kind: 'endpoint' },
366
- PINECONE_HOST: { engine: 'pinecone', kind: 'endpoint' },
367
- PINECONE_INDEX: { engine: 'pinecone', kind: 'endpoint' },
368
- PINECONE_INDEX_NAME: { engine: 'pinecone', kind: 'endpoint' },
369
- WEAVIATE_URL: { engine: 'weaviate', kind: 'endpoint' },
370
- WEAVIATE_HOST: { engine: 'weaviate', kind: 'endpoint' },
371
- WEAVIATE_API_KEY: { engine: 'weaviate', kind: 'key' },
372
- QDRANT_URL: { engine: 'qdrant', kind: 'endpoint' },
373
- QDRANT_HOST: { engine: 'qdrant', kind: 'endpoint' },
374
- QDRANT_API_KEY: { engine: 'qdrant', kind: 'key' },
375
- MILVUS_URI: { engine: 'milvus', kind: 'endpoint' },
376
- MILVUS_HOST: { engine: 'milvus', kind: 'endpoint' },
377
- ZILLIZ_CLOUD_URI: { engine: 'milvus', kind: 'endpoint' },
378
- CHROMA_SERVER_HOST: { engine: 'chroma', kind: 'endpoint' },
379
- CHROMA_HOST: { engine: 'chroma', kind: 'endpoint' },
380
- };
381
-
382
- function npmAiDeps(pkg) {
383
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}), ...(pkg.optionalDependencies || {}) };
384
- const hits = [];
385
- for (const name of Object.keys(deps)) {
386
- if (NPM_AI.has(name) || NPM_AI_PREFIX.some((p) => name.startsWith(p))) hits.push(name);
387
- }
388
- return hits;
389
- }
390
- function pyAiDeps(text) {
391
- const hits = [];
392
- for (const pkg of PY_AI) {
393
- const re = new RegExp(`(^|[^a-z0-9_.-])${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_.-]|$)`, 'im');
394
- if (re.test(text)) hits.push(pkg);
395
- }
396
- return hits;
397
- }
398
-
399
- export function discoverAiDependencies(roots = [process.cwd()], files = null) {
400
- const walk = files || walkWorkspace(roots);
401
- const byPkg = new Map(); // `${eco}:${pkg}` -> { pkg, eco, manifests:Set }
402
- const add = (eco, pkg, manifest) => {
403
- const key = `${eco}:${pkg}`;
404
- if (!byPkg.has(key)) byPkg.set(key, { pkg, eco, manifests: new Set() });
405
- byPkg.get(key).manifests.add(manifest);
406
- };
407
- for (const { file } of walk.manifests) {
408
- const base = path.basename(file);
409
- if (base === 'package.json') {
410
- const json = readJson(file);
411
- if (!json) continue;
412
- // Vector-store libs are surfaced as VECTOR_STORE assets, not AI tools.
413
- for (const pkg of npmAiDeps(json)) if (!isVectorLib(pkg)) add('npm', pkg, file);
414
- } else {
415
- const text = readText(file, 100_000);
416
- if (text == null) continue;
417
- for (const pkg of pyAiDeps(text)) if (!isVectorLib(pkg)) add('pip', pkg, file);
418
- }
419
- }
420
- const assets = [];
421
- for (const { pkg, eco, manifests } of byPkg.values()) {
422
- const list = [...manifests];
423
- assets.push({
424
- type: 'AI_TOOL',
425
- name: `${pkg} (${eco})`,
426
- identifier: `dep:${eco}:${pkg}`,
427
- vendor: 'ai-sdk',
428
- metadata: {
429
- category: 'dependency',
430
- ecosystem: eco,
431
- package: pkg,
432
- usedInProjects: list.length,
433
- manifests: list.slice(0, 10),
434
- },
435
- });
436
- }
437
- return assets;
438
- }
439
-
440
- // ── AI usage in code (SDK imports + provider call sites) ─────────
441
- // The dependency scan above sees which AI SDKs a manifest DECLARES; this sees
442
- // which LLM/AI providers the code actually CALLS (an `openai` import + a
443
- // `chat.completions.create`, a LangChain chain, `ollama.chat`) — the shadow-AI
444
- // usage a manifest can miss (a transitive dep, a vendored client) and can't
445
- // localize (which file, which model). Surfaced regardless of whether it is
446
- // vulnerable. One AI_TOOL asset per provider, category 'code-usage'.
447
- export function discoverAiUsageInCode(roots = [process.cwd()], files = null) {
448
- const walk = files || walkWorkspace(roots);
449
- const usages = [];
450
- for (const { file } of walk.source || []) {
451
- if (!isAiUsageScannable(path.basename(file))) continue;
452
- const text = readText(file, 300_000);
453
- if (text == null) continue;
454
- for (const u of scanAiUsage(text, file)) usages.push(u);
455
- }
456
- const assets = [];
457
- for (const row of rollupAiUsage(usages)) {
458
- const site = row.firstSite;
459
- assets.push({
460
- type: 'AI_TOOL',
461
- name: `${row.label} (in code)`,
462
- identifier: `ai-usage:${row.provider}`,
463
- vendor: 'ai-sdk',
464
- metadata: {
465
- category: 'code-usage',
466
- provider: row.provider,
467
- aiCategory: row.category,
468
- aiCategoryLabel: AI_USAGE_CATEGORY_LABEL[row.category],
469
- fileCount: row.files.length,
470
- files: row.files.slice(0, 10),
471
- models: row.models.slice(0, 10),
472
- callSites: row.sightings,
473
- hasCallSite: row.hasCallSite,
474
- firstSite: site ? { file: site.file, line: site.line } : null,
475
- },
476
- });
477
- }
478
- return assets;
479
- }
480
-
481
- // ── MCP client / host SDK usage in code ──────────────────────────
482
- // A repo that depends on the MCP SDK acts as an MCP HOST: it connects out to MCP
483
- // servers and feeds their (untrusted) tool output back into a model — the
484
- // toxic-flow / lethal-trifecta ingress. This is distinct from the MCP SERVERS a
485
- // machine is CONFIGURED to launch (discoverMcpServers, keyed as servers) and from
486
- // generic AI SDKs (discoverAiDependencies): here the code itself is the client.
487
- // Manifest-level detection can't prove which SDK surface is used, so the on-disk
488
- // SAST rules (js.mcp_client / python.mcp_client) confirm the client role from
489
- // actual imports; this lens surfaces the dependency so shadow MCP hosts are seen.
490
- const NPM_MCP_CLIENT = new Set(['mcp-use', 'mcp-client']);
491
- const NPM_MCP_CLIENT_PREFIX = ['@modelcontextprotocol/', '@mastra/mcp', '@langchain/mcp'];
492
- const PY_MCP_CLIENT = ['mcp', 'fastmcp', 'mcp-use', 'mcpadapt', 'langchain-mcp-adapters'];
493
-
494
- function npmMcpClientDeps(pkg) {
495
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}), ...(pkg.optionalDependencies || {}) };
496
- const hits = [];
497
- for (const name of Object.keys(deps)) {
498
- if (NPM_MCP_CLIENT.has(name) || NPM_MCP_CLIENT_PREFIX.some((p) => name.startsWith(p))) hits.push(name);
499
- }
500
- return hits;
501
- }
502
- function pyMcpClientDeps(text) {
503
- const hits = [];
504
- for (const pkg of PY_MCP_CLIENT) {
505
- const re = new RegExp(`(^|[^a-z0-9_.-])${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_.-]|$)`, 'im');
506
- if (re.test(text)) hits.push(pkg);
507
- }
508
- return hits;
509
- }
510
-
511
- export function discoverMcpClients(roots = [process.cwd()], files = null) {
512
- const walk = files || walkWorkspace(roots);
513
- const byPkg = new Map(); // `${eco}:${pkg}` -> { pkg, eco, manifests:Set }
514
- const add = (eco, pkg, manifest) => {
515
- const key = `${eco}:${pkg}`;
516
- if (!byPkg.has(key)) byPkg.set(key, { pkg, eco, manifests: new Set() });
517
- byPkg.get(key).manifests.add(manifest);
518
- };
519
- for (const { file } of walk.manifests) {
520
- const base = path.basename(file);
521
- if (base === 'package.json') {
522
- const json = readJson(file);
523
- if (!json) continue;
524
- for (const pkg of npmMcpClientDeps(json)) add('npm', pkg, file);
525
- } else {
526
- const text = readText(file, 100_000);
527
- if (text == null) continue;
528
- for (const pkg of pyMcpClientDeps(text)) add('pip', pkg, file);
529
- }
530
- }
531
- const assets = [];
532
- for (const { pkg, eco, manifests } of byPkg.values()) {
533
- const list = [...manifests];
534
- assets.push({
535
- type: 'AI_TOOL',
536
- name: `${pkg} (${eco})`,
537
- identifier: `mcp-client:${eco}:${pkg}`,
538
- vendor: 'mcp-client',
539
- metadata: {
540
- category: 'mcp-client',
541
- ecosystem: eco,
542
- package: pkg,
543
- usedInProjects: list.length,
544
- manifests: list.slice(0, 10),
545
- },
546
- });
547
- }
548
- return assets;
549
- }
550
-
551
- // ── API keys sitting in .env files ───────────────────────────────
552
-
553
- const KEY_NAME_VENDOR = {
554
- OPENAI_API_KEY: 'openai', AZURE_OPENAI_API_KEY: 'azure-openai', AZURE_OPENAI_KEY: 'azure-openai',
555
- ANTHROPIC_API_KEY: 'anthropic', GOOGLE_API_KEY: 'google', GOOGLE_GENAI_API_KEY: 'google',
556
- GEMINI_API_KEY: 'google', MISTRAL_API_KEY: 'mistral', GROQ_API_KEY: 'groq', COHERE_API_KEY: 'cohere',
557
- HUGGINGFACE_API_KEY: 'huggingface', HUGGINGFACEHUB_API_TOKEN: 'huggingface', HF_TOKEN: 'huggingface',
558
- OPENROUTER_API_KEY: 'openrouter', XAI_API_KEY: 'xai', DEEPSEEK_API_KEY: 'deepseek',
559
- TOGETHER_API_KEY: 'together', TOGETHERAI_API_KEY: 'together', PERPLEXITY_API_KEY: 'perplexity',
560
- REPLICATE_API_TOKEN: 'replicate', FIREWORKS_API_KEY: 'fireworks', DASHSCOPE_API_KEY: 'alibaba',
561
- AI21_API_KEY: 'ai21', ANYSCALE_API_KEY: 'anyscale', VOYAGE_API_KEY: 'voyage', NVIDIA_API_KEY: 'nvidia',
562
- CEREBRAS_API_KEY: 'cerebras', STABILITY_API_KEY: 'stability', ELEVENLABS_API_KEY: 'elevenlabs',
563
- WATSONX_APIKEY: 'ibm', LANGCHAIN_API_KEY: 'langsmith', LANGSMITH_API_KEY: 'langsmith',
564
- PINECONE_API_KEY: 'pinecone', WEAVIATE_API_KEY: 'weaviate',
565
- };
566
- // Value-shape fingerprints — catch a key even under a non-standard var name.
567
- const KEY_VALUE_PATTERNS = [
568
- { re: /^sk-ant-[A-Za-z0-9_-]{20,}/, vendor: 'anthropic' },
569
- { re: /^sk-or-[A-Za-z0-9_-]{20,}/, vendor: 'openrouter' },
570
- { re: /^sk-proj-[A-Za-z0-9_-]{20,}/, vendor: 'openai' },
571
- { re: /^sk-[A-Za-z0-9]{32,}/, vendor: 'openai' },
572
- { re: /^AIza[0-9A-Za-z_-]{30,}/, vendor: 'google' },
573
- { re: /^gsk_[A-Za-z0-9]{20,}/, vendor: 'groq' },
574
- { re: /^hf_[A-Za-z0-9]{20,}/, vendor: 'huggingface' },
575
- { re: /^xai-[A-Za-z0-9]{20,}/, vendor: 'xai' },
576
- { re: /^r8_[A-Za-z0-9]{20,}/, vendor: 'replicate' },
577
- { re: /^pplx-[A-Za-z0-9]{20,}/, vendor: 'perplexity' },
578
- { re: /^fw_[A-Za-z0-9]{20,}/, vendor: 'fireworks' },
579
- ];
580
-
581
- function classifyKey(name, value) {
582
- if (KEY_NAME_VENDOR[name]) return KEY_NAME_VENDOR[name];
583
- for (const { re, vendor } of KEY_VALUE_PATTERNS) if (re.test(value)) return vendor;
584
- // Fall back: a *_API_KEY / *_API_TOKEN whose name hints at a model provider.
585
- if (/(_API_KEY|_API_TOKEN|_APIKEY)$/.test(name) && /(LLM|AI|GPT|CLAUDE|MODEL|OPENAI|ANTHROPIC|GEMINI)/.test(name)) return 'unknown';
586
- return null;
587
- }
588
-
589
- export function discoverDotenvKeys(roots = [process.cwd()], files = null) {
590
- const walk = files || walkWorkspace(roots);
591
- const assets = [];
592
- const seen = new Set();
593
- for (const { file } of walk.env) {
594
- const text = readText(file, 100_000);
595
- if (text == null) continue;
596
- for (const line of text.split(/\r?\n/)) {
597
- const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/);
598
- if (!m) continue;
599
- const name = m[1];
600
- let value = m[2].trim().replace(/^["']|["']$/g, '');
601
- if (!value || value.length < 8 || /^\$\{/.test(value) || /(your|xxx|placeholder|changeme|<|example)/i.test(value)) continue;
602
- const vendor = classifyKey(name, value);
603
- if (!vendor) continue;
604
- const key = `${name}:${file}`;
605
- if (seen.has(key)) continue;
606
- seen.add(key);
607
- assets.push({
608
- type: 'MODEL_KEY',
609
- name,
610
- identifier: `dotenv:${file}:${name}`,
611
- vendor,
612
- metadata: { source: 'dotenv', file, fingerprint: `${value.slice(0, 3)}…${value.slice(-2)}` },
613
- // The raw value is intentionally NOT transmitted.
614
- });
615
- }
616
- }
617
- return assets;
618
- }
619
-
620
- // ── RAG vector stores / embedding indexes ────────────────────────
621
- // A vector store is a first-class AI asset: it holds embedded (often sensitive)
622
- // corpus data, is a retrieval-poisoning target, and — when persisted as a
623
- // pickle-backed index (LangChain FAISS) — executes code on load. We surface
624
- // three shapes: a persisted local index on disk, a client library in a project
625
- // manifest, and a managed/cloud endpoint configured in a .env. No file is
626
- // executed and no index contents are read — detection is by path + config only.
627
-
628
- /** Redact a connection value to a bare host, never transmitting credentials. */
629
- function endpointHost(value) {
630
- const v = String(value || '').trim().replace(/^["']|["']$/g, '');
631
- if (!v) return null;
632
- const m = v.match(/^[a-z]+:\/\/([^/:?#\s]+)/i);
633
- if (m) return m[1];
634
- // bare host[:port] or a *.svc.<region>.pinecone.io style host
635
- if (/^[a-z0-9.-]+\.[a-z]{2,}(:\d+)?$/i.test(v)) return v.split(':')[0];
636
- return null;
637
- }
638
-
639
- export function discoverVectorStores(roots = [process.cwd()], files = null) {
640
- const walk = files || walkWorkspace(roots);
641
- const assets = [];
642
-
643
- // (1) Persisted local indexes — one asset per store directory.
644
- const byDir = new Map();
645
- for (const { file } of walk.vector) {
646
- const dir = path.dirname(file);
647
- if (!byDir.has(dir)) byDir.set(dir, []);
648
- byDir.get(dir).push(path.basename(file).toLowerCase());
649
- }
650
- for (const [dir, names] of byDir.entries()) {
651
- let engine = 'unknown';
652
- if (names.includes('chroma.sqlite3') || names.some((n) => n.startsWith('chroma-'))) engine = 'chroma';
653
- else if (names.includes('index.faiss')) engine = 'faiss';
654
- else if (names.some((n) => n.endsWith('.lance'))) engine = 'lancedb';
655
- else if (names.some((n) => n.endsWith('.usearch'))) engine = 'usearch';
656
- else if (names.includes('docstore.json') || names.includes('default__vector_store.json')) engine = 'llamaindex';
657
- else continue; // a lone index.pkl with no recognised sibling — skip (avoid FP)
658
- // LangChain FAISS.save_local writes a pickle sidecar → code-exec on load.
659
- const pickleBacked = engine === 'faiss' && names.includes('index.pkl');
660
- assets.push({
661
- type: 'VECTOR_STORE',
662
- name: `${engine} index (${path.basename(dir)})`,
663
- identifier: `vector:local:${dir}`,
664
- vendor: engine,
665
- metadata: { surface: 'local-index', engine, hosted: false, pickleBacked, dir, files: names.slice(0, 20) },
666
- });
667
- }
668
-
669
- // (2) Client libraries in project manifests — one asset per engine.
670
- const byEngine = new Map(); // engine -> { hosted, manifests:Set }
671
- const addLib = (engine, hosted, manifest) => {
672
- if (!byEngine.has(engine)) byEngine.set(engine, { hosted, manifests: new Set() });
673
- byEngine.get(engine).manifests.add(manifest);
674
- };
675
- for (const { file } of walk.manifests) {
676
- const base = path.basename(file);
677
- if (base === 'package.json') {
678
- const json = readJson(file);
679
- if (!json) continue;
680
- const deps = { ...(json.dependencies || {}), ...(json.devDependencies || {}), ...(json.peerDependencies || {}), ...(json.optionalDependencies || {}) };
681
- for (const name of Object.keys(deps)) {
682
- const info = vectorLibInfo(name);
683
- if (info) addLib(info.engine, info.hosted, file);
684
- }
685
- } else {
686
- const text = readText(file, 100_000);
687
- if (text == null) continue;
688
- for (const pkg of PY_VECTOR) {
689
- const re = new RegExp(`(^|[^a-z0-9_.-])${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_.-]|$)`, 'im');
690
- if (re.test(text)) {
691
- const info = vectorLibInfo(pkg);
692
- if (info) addLib(info.engine, info.hosted, file);
693
- }
694
- }
695
- }
696
- }
697
- for (const [engine, { hosted, manifests }] of byEngine.entries()) {
698
- const list = [...manifests];
699
- assets.push({
700
- type: 'VECTOR_STORE',
701
- name: `${engine} client`,
702
- identifier: `vector:client:${engine}`,
703
- vendor: engine,
704
- metadata: { surface: 'client-lib', engine, hosted, usedInProjects: list.length, manifests: list.slice(0, 10) },
705
- });
706
- }
707
-
708
- // (3) Managed/cloud endpoints declared in .env files — one asset per engine.
709
- const byCloud = new Map(); // engine -> { hosts:Set, hasKey, hasEndpoint, files:Set }
710
- for (const { file } of walk.env) {
711
- const text = readText(file, 100_000);
712
- if (text == null) continue;
713
- for (const line of text.split(/\r?\n/)) {
714
- const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/);
715
- if (!m) continue;
716
- const cfg = VECTOR_ENV[m[1]];
717
- if (!cfg) continue;
718
- const value = m[2].trim().replace(/^["']|["']$/g, '');
719
- if (!value || /(your|xxx|placeholder|changeme|<|example)/i.test(value)) continue;
720
- if (!byCloud.has(cfg.engine)) byCloud.set(cfg.engine, { hosts: new Set(), hasKey: false, hasEndpoint: false, files: new Set() });
721
- const e = byCloud.get(cfg.engine);
722
- e.files.add(file);
723
- if (cfg.kind === 'key') e.hasKey = true;
724
- else {
725
- e.hasEndpoint = true;
726
- const h = endpointHost(value);
727
- if (h) e.hosts.add(h);
728
- }
729
- }
730
- }
731
- for (const [engine, e] of byCloud.entries()) {
732
- assets.push({
733
- type: 'VECTOR_STORE',
734
- name: `${engine} (cloud)`,
735
- identifier: `vector:cloud:${engine}`,
736
- vendor: engine,
737
- metadata: {
738
- surface: 'cloud-endpoint',
739
- engine,
740
- hosted: true,
741
- hasApiKey: e.hasKey,
742
- hosts: [...e.hosts].slice(0, 5),
743
- files: [...e.files].slice(0, 10),
744
- },
745
- });
746
- }
747
-
748
- return assets;
749
- }
750
-
751
- // ── installed AI tools & local model runtimes (presence) ─────────
752
-
753
- export function discoverAiTools() {
754
- const checks = [
755
- { vendor: 'cursor', name: 'Cursor', probe: [path.join(HOME, '.cursor')] },
756
- { vendor: 'claude', name: 'Claude Desktop', probe: [path.join(APPDATA, 'Claude'), path.join(HOME, 'Library', 'Application Support', 'Claude'), path.join(HOME, '.config', 'Claude')] },
757
- { vendor: 'windsurf', name: 'Windsurf', probe: [path.join(HOME, '.codeium', 'windsurf')] },
758
- { vendor: 'continue', name: 'Continue', probe: [path.join(HOME, '.continue')] },
759
- { vendor: 'zed', name: 'Zed', probe: [path.join(HOME, '.config', 'zed'), path.join(HOME, 'Library', 'Application Support', 'Zed')] },
760
- { vendor: 'cody', name: 'Sourcegraph Cody', probe: [path.join(vscodeUserDir(), 'globalStorage', 'sourcegraph.cody-ai')] },
761
- { vendor: 'copilot', name: 'GitHub Copilot (VS Code)', probe: [path.join(vscodeUserDir(), 'globalStorage', 'github.copilot'), path.join(vscodeUserDir(), 'globalStorage', 'github.copilot-chat')] },
762
- { vendor: 'tabnine', name: 'Tabnine', probe: [path.join(HOME, '.tabnine'), path.join(LOCALAPPDATA, 'TabNine')] },
763
- ];
764
- const assets = [];
765
- for (const c of checks) {
766
- const at = firstExisting(c.probe);
767
- if (at) assets.push({ type: 'AI_TOOL', name: c.name, identifier: at, vendor: c.vendor, metadata: { category: 'assistant', detectedAt: at } });
768
- }
769
- return [...assets, ...discoverLocalRuntimes()];
770
- }
771
-
772
- /** Local model runtimes — detected by directory AND by running process. */
773
- export function discoverLocalRuntimes() {
774
- const runtimes = [
775
- { vendor: 'ollama', name: 'Ollama', dirs: [path.join(HOME, '.ollama'), path.join(LOCALAPPDATA, 'Ollama')], modelsDir: path.join(HOME, '.ollama', 'models', 'manifests'), proc: ['ollama'] },
776
- { vendor: 'lmstudio', name: 'LM Studio', dirs: [path.join(HOME, '.lmstudio'), path.join(HOME, '.cache', 'lm-studio'), path.join(LOCALAPPDATA, 'LM Studio')], proc: ['lm studio', 'lmstudio', 'lms'] },
777
- { vendor: 'jan', name: 'Jan', dirs: [path.join(HOME, 'jan'), path.join(HOME, '.jan'), path.join(APPDATA, 'Jan')], proc: ['jan'] },
778
- { vendor: 'gpt4all', name: 'GPT4All', dirs: [path.join(HOME, '.cache', 'gpt4all'), path.join(HOME, 'Library', 'Application Support', 'nomic.ai', 'GPT4All'), path.join(LOCALAPPDATA, 'nomic.ai', 'GPT4All')], proc: ['gpt4all'] },
779
- { vendor: 'huggingface', name: 'Hugging Face cache', dirs: [path.join(HOME, '.cache', 'huggingface'), path.join(process.env.HF_HOME || '', 'hub')], proc: [] },
780
- { vendor: 'localai', name: 'LocalAI', dirs: [path.join(HOME, '.localai')], proc: ['local-ai', 'localai'] },
781
- { vendor: 'textgen', name: 'Text Generation WebUI', dirs: [], proc: ['text-generation', 'oobabooga'] },
782
- { vendor: 'vllm', name: 'vLLM', dirs: [], proc: ['vllm'] },
783
- ];
784
- const procs = listProcesses();
785
- const assets = [];
786
- for (const r of runtimes) {
787
- const at = firstExisting(r.dirs);
788
- const running = r.proc.some((tok) => procs.some((p) => p.includes(tok)));
789
- if (!at && !running) continue;
790
- const meta = { category: 'local-runtime', detectedAt: at || null, running };
791
- if (r.vendor === 'ollama' && r.modelsDir) meta.models = ollamaModels(r.modelsDir);
792
- assets.push({ type: 'AI_TOOL', name: r.name, identifier: at || `proc:${r.vendor}`, vendor: r.vendor, metadata: meta });
793
- }
794
- return assets;
795
- }
796
-
797
- /** Enumerate locally-pulled Ollama models from the manifests tree (names only). */
798
- function ollamaModels(manifestsDir) {
799
- const out = [];
800
- const walk = (dir, depth) => {
801
- if (depth > 5 || out.length > 100) return;
802
- let entries;
803
- try {
804
- entries = fs.readdirSync(dir, { withFileTypes: true });
805
- } catch {
806
- return;
807
- }
808
- for (const e of entries) {
809
- const full = path.join(dir, e.name);
810
- if (e.isDirectory()) walk(full, depth + 1);
811
- else if (e.isFile()) {
812
- // manifests/<registry>/<namespace>/<model>/<tag> -> namespace/model:tag
813
- const rel = path.relative(manifestsDir, full).split(path.sep);
814
- if (rel.length >= 2) out.push(`${rel.slice(1, -1).join('/')}:${rel[rel.length - 1]}`);
815
- }
816
- }
817
- };
818
- walk(manifestsDir, 0);
819
- return out.slice(0, 100);
820
- }
821
-
822
- /** Best-effort process listing (short timeout, never throws). */
823
- function listProcesses() {
824
- try {
825
- if (PLAT === 'win32') {
826
- const out = execFileSync('tasklist', ['/fo', 'csv', '/nh'], { timeout: 4000, encoding: 'utf8', windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
827
- return out.split(/\r?\n/).map((l) => (l.match(/^"([^"]+)"/)?.[1] || '').toLowerCase()).filter(Boolean);
828
- }
829
- const out = execFileSync('ps', ['-eo', 'comm='], { timeout: 4000, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
830
- return out.split(/\r?\n/).map((s) => s.trim().toLowerCase()).filter(Boolean);
831
- } catch {
832
- return [];
833
- }
834
- }
835
-
836
- // ── coding agents (autonomous tool-runners) ──────────────────────
837
-
838
- /**
839
- * Discover installed CODING AGENTS, separate from passive AI tools. For each we
840
- * record whether the Shomra runtime firewall hook is installed (`guarded`) so
841
- * the backend can flag an unguarded agent — one that can run shell / edit files
842
- * / call MCP with no policy checkpoint. This is the shadow-agent surface.
843
- */
844
- export function discoverCodingAgents(roots = [process.cwd()]) {
845
- const cwd = process.cwd();
846
- const agents = [
847
- { vendor: 'claude-code', name: 'Claude Code', probes: [path.join(HOME, '.claude.json'), path.join(HOME, '.claude')], hookFiles: [path.join(HOME, '.claude', 'settings.json'), path.join(cwd, '.claude', 'settings.json')] },
848
- { vendor: 'cursor', name: 'Cursor', probes: [path.join(HOME, '.cursor')], hookFiles: [path.join(HOME, '.cursor', 'hooks.json'), path.join(cwd, '.cursor', 'hooks.json')] },
849
- { vendor: 'windsurf', name: 'Windsurf', probes: [path.join(HOME, '.codeium', 'windsurf')], hookFiles: [path.join(HOME, '.codeium', 'windsurf', 'hooks.json'), path.join(cwd, '.windsurf', 'hooks.json')] },
850
- { vendor: 'gemini', name: 'Gemini CLI', probes: [path.join(HOME, '.gemini')], hookFiles: [path.join(HOME, '.gemini', 'settings.json'), path.join(cwd, '.gemini', 'settings.json')] },
851
- { vendor: 'codex', name: 'OpenAI Codex CLI', probes: [path.join(HOME, '.codex')], hookFiles: [path.join(HOME, '.codex', 'hooks.json'), path.join(cwd, '.codex', 'hooks.json')] },
852
- { vendor: 'copilot', name: 'GitHub Copilot CLI', probes: [path.join(HOME, '.copilot')], hookFiles: [path.join(HOME, '.copilot', 'hooks', 'shomra.json'), path.join(cwd, '.github', 'hooks', 'shomra.json')] },
853
- { vendor: 'cline', name: 'Cline', probes: [path.join(vscodeUserDir(), 'globalStorage', 'saoudrizwan.claude-dev')], hookFiles: [path.join(HOME, '.cline', 'hooks.json'), path.join(cwd, '.cline', 'hooks.json')] },
854
- { vendor: 'roo', name: 'Roo Code', probes: [path.join(vscodeUserDir(), 'globalStorage', 'rooveterinaryinc.roo-cline')], hookFiles: [path.join(cwd, '.roo', 'hooks.json')] },
855
- { vendor: 'aider', name: 'Aider', probes: [path.join(HOME, '.aider.conf.yml'), path.join(cwd, '.aider.conf.yml'), path.join(HOME, '.aider')], hookFiles: [path.join(HOME, '.aider.conf.yml'), path.join(cwd, '.aider.conf.yml')] },
856
- ];
857
- const assets = [];
858
- for (const a of agents) {
859
- const installedAt = a.probes.find((p) => exists(p));
860
- if (!installedAt) continue;
861
- const guardFile = a.hookFiles.find((f) => {
862
- const t = readText(f, 20_000);
863
- return t != null && /shomra/i.test(t);
864
- });
865
- // PERMISSION POSTURE — what this agent may do without asking. The user-scope
866
- // settings read here govern every project on the machine and live in no
867
- // repository, so this is the one surface a repo scan structurally cannot
868
- // reach. `content` carries ONLY the canonical grant document (see
869
- // agent-posture.mjs); the settings files themselves never leave the machine.
870
- const posture = readVendorPosture(a.vendor, cwd);
871
- const grant = canonicalGrant(posture);
872
- assets.push({
873
- type: 'AI_AGENT',
874
- name: a.name,
875
- identifier: `agent:${a.vendor}`,
876
- vendor: a.vendor,
877
- ...(grant ? { content: grant } : {}),
878
- metadata: {
879
- detectedAt: installedAt,
880
- guarded: !!guardFile,
881
- guardFile: guardFile || null,
882
- posture: posture
883
- ? {
884
- tier: posture.tier,
885
- readable: posture.readable,
886
- claim: posture.claim,
887
- mode: posture.mode,
888
- allowCount: posture.allow.length,
889
- denyCount: posture.deny.length,
890
- askCount: posture.ask.length,
891
- allow: posture.allow.slice(0, 25),
892
- enableAllProjectMcpServers: posture.enableAllProjectMcpServers,
893
- switches: posture.switches,
894
- mcpServerCount: posture.mcpServers.length,
895
- autoApprovedMcp: posture.autoApprovedMcp,
896
- unreadableCount: posture.unreadableCount,
897
- // Paths only — which files were consulted and whether each parsed.
898
- // Needed so an operator can tell "configured safely" from "we could
899
- // not open the file that decides it".
900
- sources: posture.sources.map((s) => ({ path: s.path, scope: s.scope, state: s.state, reason: s.reason })),
901
- }
902
- : null,
903
- },
904
- });
905
- }
906
- return assets;
907
- }
908
-
909
- // ── model-provider API keys in the environment ───────────────────
910
-
911
- export function discoverModelKeys() {
912
- const assets = [];
913
- for (const [name, v] of Object.entries(process.env)) {
914
- if (!v || v.length < 8) continue;
915
- const vendor = KEY_NAME_VENDOR[name] || (/(_API_KEY|_API_TOKEN|_APIKEY)$/.test(name) ? classifyKey(name, v) : null);
916
- if (!vendor) continue;
917
- assets.push({
918
- type: 'MODEL_KEY',
919
- name,
920
- identifier: `env:${name}`,
921
- vendor,
922
- metadata: { source: 'environment', fingerprint: `${v.slice(0, 3)}…${v.slice(-2)}` },
923
- // The raw value is intentionally NOT transmitted.
924
- });
925
- }
926
- return assets;
927
- }
928
-
929
- function redactEnv(env) {
930
- if (!env || typeof env !== 'object') return {};
931
- const out = {};
932
- for (const [k, v] of Object.entries(env)) {
933
- const s = String(v ?? '');
934
- out[k] = s.length > 8 ? `${s.slice(0, 3)}…${s.slice(-2)}` : s;
935
- }
936
- return out;
937
- }
938
-
939
- // ── aggregate ────────────────────────────────────────────────────
940
-
941
- export function discoverAll(roots = [process.cwd()], opts = {}) {
942
- const { autoExpand = true } = opts;
943
- const scanRoots = resolveRoots(roots, autoExpand);
944
- const files = walkWorkspace(scanRoots); // one walk, shared by every file-based discoverer
945
- const all = [
946
- ...discoverMcpServers(scanRoots, files),
947
- ...discoverRulesFiles(scanRoots, files),
948
- ...discoverAiDependencies(scanRoots, files),
949
- ...discoverAiUsageInCode(scanRoots, files),
950
- ...discoverMcpClients(scanRoots, files),
951
- ...discoverVectorStores(scanRoots, files),
952
- ...discoverDotenvKeys(scanRoots, files),
953
- ...discoverAiTools(),
954
- ...discoverCodingAgents(scanRoots),
955
- ...discoverModelKeys(),
956
- ];
957
- // ⚠ CLAMPED BEFORE DEDUP, and before anything leaves this function. A report is
958
- // validated all-or-nothing, so ONE over-long field — an MCP server launched by
959
- // an inline `node -e '<1675 chars>'` is the case that found this — rejects the
960
- // whole payload and costs the machine its entire inventory. Clamping first also
961
- // means the dedup key below is the key the backend will see, so a value that
962
- // was abbreviated on the wire cannot dedup differently here than it does there.
963
- // See wire-limits.mjs for why truncation carries a fingerprint.
964
- const clamped = all.map(clampAsset);
965
-
966
- // Final dedup by (type, identifier) — a runtime can be found by both dir and
967
- // process; an env key can also appear in a .env file.
968
- const seen = new Set();
969
- const out = [];
970
- for (const a of clamped) {
971
- const key = `${a.type}::${a.identifier || a.name}`;
972
- if (seen.has(key)) continue;
973
- seen.add(key);
974
- out.push(a);
975
- }
976
- return out;
977
- }