@shomra/agent 0.2.1
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/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +220 -0
- package/code-sast.mjs +763 -0
- package/discovery.mjs +812 -0
- package/guard-signals.mjs +747 -0
- package/model-refs.mjs +130 -0
- package/package.json +51 -0
- package/shomra.mjs +4193 -0
package/discovery.mjs
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
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
|
+
|
|
24
|
+
const HOME = os.homedir();
|
|
25
|
+
const APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
|
|
26
|
+
const LOCALAPPDATA = process.env.LOCALAPPDATA || path.join(HOME, 'AppData', 'Local');
|
|
27
|
+
const PLAT = process.platform;
|
|
28
|
+
|
|
29
|
+
/** VS Code (and forks) per-user dir, where extensions keep global state. */
|
|
30
|
+
function vscodeUserDir(variant = 'Code') {
|
|
31
|
+
if (PLAT === 'win32') return path.join(APPDATA, variant, 'User');
|
|
32
|
+
if (PLAT === 'darwin') return path.join(HOME, 'Library', 'Application Support', variant, 'User');
|
|
33
|
+
return path.join(HOME, '.config', variant, 'User');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readJson(file) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(stripJsonComments(fs.readFileSync(file, 'utf8')));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** VS Code / Cursor settings are JSONC — tolerate // and /* */ comments. */
|
|
44
|
+
function stripJsonComments(s) {
|
|
45
|
+
return String(s)
|
|
46
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
47
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
48
|
+
}
|
|
49
|
+
function readText(file, cap = 200_000) {
|
|
50
|
+
try {
|
|
51
|
+
const b = fs.readFileSync(file, 'utf8');
|
|
52
|
+
return b.length > cap ? b.slice(0, cap) : b;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function exists(p) {
|
|
58
|
+
try {
|
|
59
|
+
return fs.existsSync(p);
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function firstExisting(paths) {
|
|
65
|
+
return paths.find((p) => p && exists(p)) || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── workspace root discovery ─────────────────────────────────────
|
|
69
|
+
// The old scanner only looked at cwd. Real AI assets live scattered across a
|
|
70
|
+
// developer's project folders, so we discover those folders instead of hoping
|
|
71
|
+
// the agent was launched from inside one.
|
|
72
|
+
|
|
73
|
+
const IGNORE_DIRS = new Set([
|
|
74
|
+
'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt',
|
|
75
|
+
'.cache', '.venv', 'venv', 'env', '__pycache__', '.tox', 'target', 'vendor',
|
|
76
|
+
'bin', 'obj', '.gradle', '.idea', 'coverage', '.pytest_cache', '.mypy_cache',
|
|
77
|
+
'Pods', '.terraform', '.expo', 'tmp', 'temp', '.turbo', '.parcel-cache',
|
|
78
|
+
'.svelte-kit', 'bower_components', '.pnpm-store', 'site-packages', '.yarn',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/** Common parent dirs under $HOME where people keep code checkouts. */
|
|
82
|
+
function workspaceParents() {
|
|
83
|
+
const names = [
|
|
84
|
+
'Desktop', 'Documents', 'source', 'source/repos', 'repos', 'Repos',
|
|
85
|
+
'projects', 'Projects', 'dev', 'Dev', 'Developer', 'git', 'Git', 'code',
|
|
86
|
+
'Code', 'workspace', 'Workspace', 'work', 'src', 'go/src', 'ghq',
|
|
87
|
+
'OneDrive/Desktop', 'OneDrive/Documents',
|
|
88
|
+
];
|
|
89
|
+
return names.map((n) => path.join(HOME, n)).filter(exists);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Expand the caller's roots into the set of project directories to scan.
|
|
94
|
+
* When autoExpand is on, add the immediate subdirectories of the common
|
|
95
|
+
* workspace parents (depth 1) as candidate roots — capped so a machine with
|
|
96
|
+
* hundreds of repos stays fast.
|
|
97
|
+
*/
|
|
98
|
+
function resolveRoots(roots, autoExpand) {
|
|
99
|
+
const out = new Set();
|
|
100
|
+
for (const r of roots || []) if (r) out.add(path.resolve(r));
|
|
101
|
+
if (autoExpand) {
|
|
102
|
+
out.add(HOME); // catch dotfile configs / .env at the home root (shallow — see maxDepth)
|
|
103
|
+
for (const parent of workspaceParents()) {
|
|
104
|
+
out.add(parent);
|
|
105
|
+
try {
|
|
106
|
+
for (const e of fs.readdirSync(parent, { withFileTypes: true })) {
|
|
107
|
+
if (e.isDirectory() && !e.name.startsWith('.') && !IGNORE_DIRS.has(e.name)) {
|
|
108
|
+
out.add(path.join(parent, e.name));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
/* unreadable parent */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...out].slice(0, 500);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const RULE_NAMES = new Set([
|
|
120
|
+
'.cursorrules', '.windsurfrules', '.clinerules', '.roorules', '.aider.conf.yml',
|
|
121
|
+
'.aider.conf.yaml', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md', 'copilot-instructions.md',
|
|
122
|
+
]);
|
|
123
|
+
const MANIFEST_NAMES = new Set([
|
|
124
|
+
'package.json', 'requirements.txt', 'requirements-dev.txt', 'pyproject.toml',
|
|
125
|
+
'Pipfile', 'environment.yml', 'environment.yaml',
|
|
126
|
+
]);
|
|
127
|
+
const isEnvFile = (base) =>
|
|
128
|
+
/^\.env(\..+)?$/.test(base) && !/(example|sample|template|dist)/i.test(base);
|
|
129
|
+
|
|
130
|
+
// Persisted on-disk vector-store / embedding-index artifacts. `index.pkl` is
|
|
131
|
+
// LangChain FAISS.save_local's pickle sidecar — a code-execution surface on
|
|
132
|
+
// load — so we track it, but only mint a store when its `index.faiss` sibling
|
|
133
|
+
// is present (see discoverVectorStores) to avoid flagging unrelated pickles.
|
|
134
|
+
const VECTOR_INDEX_BASENAMES = new Set([
|
|
135
|
+
'chroma.sqlite3', // Chroma persistent client (sqlite backend)
|
|
136
|
+
'index.faiss', 'index.pkl', // LangChain FAISS.save_local pair
|
|
137
|
+
'docstore.json', 'default__vector_store.json', // LlamaIndex persist dir
|
|
138
|
+
'chroma-embeddings.parquet', 'chroma-collections.parquet', // legacy Chroma (duckdb+parquet)
|
|
139
|
+
]);
|
|
140
|
+
const VECTOR_INDEX_EXTS = new Set(['faiss', 'lance', 'usearch']);
|
|
141
|
+
const isVectorIndex = (base) =>
|
|
142
|
+
VECTOR_INDEX_BASENAMES.has(base.toLowerCase()) ||
|
|
143
|
+
VECTOR_INDEX_EXTS.has((base.slice(base.lastIndexOf('.') + 1) || '').toLowerCase());
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One bounded breadth-first walk per root that collects every file of interest.
|
|
147
|
+
* Returns { mcp:[], rules:[], manifests:[], env:[], vector:[] } absolute-path lists.
|
|
148
|
+
* Depth- and count-limited so it never turns into a full-disk crawl.
|
|
149
|
+
*/
|
|
150
|
+
function walkWorkspace(roots) {
|
|
151
|
+
const found = { mcp: [], rules: [], manifests: [], env: [], vector: [] };
|
|
152
|
+
const seenDir = new Set();
|
|
153
|
+
let budget = 40_000; // total directories visited across all roots
|
|
154
|
+
const maxDepth = 6;
|
|
155
|
+
|
|
156
|
+
const consider = (base, full, parentBase) => {
|
|
157
|
+
if (base === '.mcp.json' || base === 'mcp.json') found.mcp.push({ file: full, parentBase });
|
|
158
|
+
else if (base === 'settings.json' && (parentBase === '.gemini' || parentBase === '.zed'))
|
|
159
|
+
found.mcp.push({ file: full, parentBase });
|
|
160
|
+
else if (RULE_NAMES.has(base)) {
|
|
161
|
+
if (base === 'copilot-instructions.md' && parentBase !== '.github') return;
|
|
162
|
+
found.rules.push({ file: full, parentBase });
|
|
163
|
+
} else if (MANIFEST_NAMES.has(base)) found.manifests.push({ file: full, parentBase });
|
|
164
|
+
else if (isEnvFile(base)) found.env.push({ file: full, parentBase });
|
|
165
|
+
else if (isVectorIndex(base)) found.vector.push({ file: full, parentBase });
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
for (const root of roots) {
|
|
169
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
170
|
+
while (queue.length && budget > 0) {
|
|
171
|
+
const { dir, depth } = queue.shift();
|
|
172
|
+
let real;
|
|
173
|
+
try {
|
|
174
|
+
real = fs.realpathSync(dir);
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (seenDir.has(real)) continue; // dedup shared roots / symlink loops
|
|
179
|
+
seenDir.add(real);
|
|
180
|
+
budget--;
|
|
181
|
+
let entries;
|
|
182
|
+
try {
|
|
183
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
184
|
+
} catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
for (const e of entries) {
|
|
188
|
+
const full = path.join(dir, e.name);
|
|
189
|
+
if (e.isDirectory()) {
|
|
190
|
+
if (depth < maxDepth && !IGNORE_DIRS.has(e.name)) queue.push({ dir: full, depth: depth + 1 });
|
|
191
|
+
} else if (e.isFile()) {
|
|
192
|
+
consider(e.name, full, path.basename(dir));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return found;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function vendorFromPath(file) {
|
|
201
|
+
if (/[\\/]\.cursor[\\/]/.test(file)) return 'cursor';
|
|
202
|
+
if (/[\\/]\.vscode[\\/]/.test(file)) return 'vscode';
|
|
203
|
+
if (/[\\/]\.gemini[\\/]/.test(file)) return 'gemini';
|
|
204
|
+
if (/[\\/]\.zed[\\/]/.test(file)) return 'zed';
|
|
205
|
+
return 'project';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── MCP servers ──────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/** Global MCP config files across the major AI clients, per-platform. */
|
|
211
|
+
function globalMcpCandidates() {
|
|
212
|
+
const c = [];
|
|
213
|
+
if (PLAT === 'win32') c.push({ vendor: 'claude', file: path.join(APPDATA, 'Claude', 'claude_desktop_config.json') });
|
|
214
|
+
else if (PLAT === 'darwin') c.push({ vendor: 'claude', file: path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') });
|
|
215
|
+
else c.push({ vendor: 'claude', file: path.join(HOME, '.config', 'Claude', 'claude_desktop_config.json') });
|
|
216
|
+
c.push({ vendor: 'cursor', file: path.join(HOME, '.cursor', 'mcp.json') });
|
|
217
|
+
c.push({ vendor: 'windsurf', file: path.join(HOME, '.codeium', 'windsurf', 'mcp_config.json') });
|
|
218
|
+
c.push({ vendor: 'continue', file: path.join(HOME, '.continue', 'config.json') });
|
|
219
|
+
c.push({ vendor: 'claude-code', file: path.join(HOME, '.claude.json') });
|
|
220
|
+
c.push({ vendor: 'gemini', file: path.join(HOME, '.gemini', 'settings.json') });
|
|
221
|
+
c.push({ vendor: 'cline', file: path.join(vscodeUserDir(), 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json') });
|
|
222
|
+
c.push({ vendor: 'roo', file: path.join(vscodeUserDir(), 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json') });
|
|
223
|
+
// VS Code / Cursor native MCP + Zed context servers.
|
|
224
|
+
c.push({ vendor: 'vscode', file: path.join(vscodeUserDir(), 'mcp.json') });
|
|
225
|
+
c.push({ vendor: 'vscode', file: path.join(vscodeUserDir(), 'settings.json') });
|
|
226
|
+
c.push({ vendor: 'cursor', file: path.join(vscodeUserDir('Cursor'), 'settings.json') });
|
|
227
|
+
c.push({ vendor: 'zed', file: PLAT === 'darwin' ? path.join(HOME, 'Library', 'Application Support', 'Zed', 'settings.json') : path.join(HOME, '.config', 'zed', 'settings.json') });
|
|
228
|
+
return c;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Pull the server map out of the many shapes these configs use. */
|
|
232
|
+
function extractServers(json) {
|
|
233
|
+
if (!json || typeof json !== 'object') return {};
|
|
234
|
+
return (
|
|
235
|
+
json.mcpServers ||
|
|
236
|
+
json.servers ||
|
|
237
|
+
json['mcp.servers'] ||
|
|
238
|
+
json.mcp?.servers ||
|
|
239
|
+
json.context_servers || // Zed
|
|
240
|
+
{}
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function discoverMcpServers(roots = [process.cwd()], files = null) {
|
|
245
|
+
const walk = files || walkWorkspace(roots);
|
|
246
|
+
const candidates = [
|
|
247
|
+
...globalMcpCandidates(),
|
|
248
|
+
...walk.mcp.map(({ file }) => ({ vendor: vendorFromPath(file), file })),
|
|
249
|
+
];
|
|
250
|
+
const assets = [];
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
for (const { vendor, file } of candidates) {
|
|
253
|
+
const json = readJson(file);
|
|
254
|
+
if (!json) continue;
|
|
255
|
+
const servers = extractServers(json);
|
|
256
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
257
|
+
if (!cfg || typeof cfg !== 'object') continue;
|
|
258
|
+
const command = [cfg.command, ...(Array.isArray(cfg.args) ? cfg.args : [])].filter(Boolean).join(' ');
|
|
259
|
+
const identifier = cfg.url || cfg.serverUrl || command || name;
|
|
260
|
+
const key = `${name}:${identifier}`;
|
|
261
|
+
if (seen.has(key)) continue;
|
|
262
|
+
seen.add(key);
|
|
263
|
+
assets.push({
|
|
264
|
+
type: 'MCP_SERVER',
|
|
265
|
+
name,
|
|
266
|
+
identifier,
|
|
267
|
+
vendor,
|
|
268
|
+
metadata: { command, url: cfg.url || cfg.serverUrl || null, configFile: file, env: redactEnv(cfg.env) },
|
|
269
|
+
// Content the backend statically analyzes (command + env values + url).
|
|
270
|
+
content: JSON.stringify({ command, url: cfg.url || cfg.serverUrl, env: cfg.env || {} }),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return assets;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── AI rules / instruction files ─────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/** Known AI rules / instruction files an agent treats as trusted input. */
|
|
280
|
+
export function discoverRulesFiles(roots = [process.cwd()], files = null) {
|
|
281
|
+
const walk = files || walkWorkspace(roots);
|
|
282
|
+
const assets = [];
|
|
283
|
+
const seen = new Set();
|
|
284
|
+
for (const { file } of walk.rules) {
|
|
285
|
+
if (seen.has(file)) continue;
|
|
286
|
+
seen.add(file);
|
|
287
|
+
const content = readText(file, 50_000);
|
|
288
|
+
if (content == null) continue;
|
|
289
|
+
assets.push({
|
|
290
|
+
type: 'AI_RULES',
|
|
291
|
+
name: path.basename(file),
|
|
292
|
+
identifier: file,
|
|
293
|
+
vendor: vendorFromPath(file) === 'project' ? 'rules' : vendorFromPath(file),
|
|
294
|
+
metadata: { bytes: content.length, dir: path.dirname(file) },
|
|
295
|
+
content: content.slice(0, 50_000),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return assets;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── AI-SDK dependencies in code ──────────────────────────────────
|
|
302
|
+
// A repo that imports openai / anthropic / langchain IS an AI asset even with
|
|
303
|
+
// no MCP config. We surface each AI library once per machine (with the sample
|
|
304
|
+
// manifests that pull it in) so shadow AI usage in code becomes visible.
|
|
305
|
+
|
|
306
|
+
const NPM_AI = new Set([
|
|
307
|
+
'openai', 'ai', 'langchain', 'llamaindex', 'ollama', 'replicate', 'cohere-ai',
|
|
308
|
+
'groq-sdk', 'together-ai', 'openrouter', 'mistralai', 'chromadb',
|
|
309
|
+
]);
|
|
310
|
+
const NPM_AI_PREFIX = ['@anthropic-ai/', '@google/generative-ai', '@google/genai', '@ai-sdk/', '@langchain/', '@llamaindex/', '@mistralai/', '@huggingface/', '@pinecone-database/', '@qdrant/'];
|
|
311
|
+
const PY_AI = [
|
|
312
|
+
'openai', 'anthropic', 'google-generativeai', 'google-genai', 'langchain',
|
|
313
|
+
'langchain-openai', 'langchain-anthropic', 'langchain-community', 'llama-index',
|
|
314
|
+
'llama_index', 'transformers', 'sentence-transformers', 'mistralai', 'cohere',
|
|
315
|
+
'groq', 'huggingface-hub', 'huggingface_hub', 'ollama', 'litellm', 'guidance',
|
|
316
|
+
'vllm', 'crewai', 'autogen', 'pyautogen', 'haystack-ai', 'instructor', 'dspy',
|
|
317
|
+
'dspy-ai', 'semantic-kernel', 'replicate', 'together', 'chromadb', 'qdrant-client',
|
|
318
|
+
'pinecone-client', 'pinecone', 'faiss-cpu', 'faiss-gpu', 'tiktoken',
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
// Vector-store / embedding-index client libraries. Each gets its own
|
|
322
|
+
// VECTOR_STORE asset (discoverVectorStores) instead of a generic AI_TOOL, so
|
|
323
|
+
// they're excluded from the AI-SDK dependency roll-up above. `hosted` marks a
|
|
324
|
+
// managed/cloud store — embedded data leaves the environment to a third party.
|
|
325
|
+
const VECTOR_LIBS = {
|
|
326
|
+
chromadb: { engine: 'chroma', hosted: false },
|
|
327
|
+
'faiss-cpu': { engine: 'faiss', hosted: false },
|
|
328
|
+
'faiss-gpu': { engine: 'faiss', hosted: false },
|
|
329
|
+
lancedb: { engine: 'lancedb', hosted: false },
|
|
330
|
+
pgvector: { engine: 'pgvector', hosted: false },
|
|
331
|
+
'qdrant-client': { engine: 'qdrant', hosted: true },
|
|
332
|
+
'pinecone-client': { engine: 'pinecone', hosted: true },
|
|
333
|
+
pinecone: { engine: 'pinecone', hosted: true },
|
|
334
|
+
'weaviate-client': { engine: 'weaviate', hosted: true },
|
|
335
|
+
'weaviate-ts-client': { engine: 'weaviate', hosted: true },
|
|
336
|
+
pymilvus: { engine: 'milvus', hosted: true },
|
|
337
|
+
};
|
|
338
|
+
/** Resolve a package name (incl. scoped npm prefixes) to its vector engine. */
|
|
339
|
+
function vectorLibInfo(pkg) {
|
|
340
|
+
if (VECTOR_LIBS[pkg]) return VECTOR_LIBS[pkg];
|
|
341
|
+
if (pkg.startsWith('@pinecone-database/')) return { engine: 'pinecone', hosted: true };
|
|
342
|
+
if (pkg.startsWith('@qdrant/')) return { engine: 'qdrant', hosted: true };
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const isVectorLib = (pkg) => !!vectorLibInfo(pkg);
|
|
346
|
+
// Python vector-store packages, matched by import/require name in text manifests.
|
|
347
|
+
const PY_VECTOR = [
|
|
348
|
+
'chromadb', 'faiss-cpu', 'faiss-gpu', 'lancedb', 'pgvector', 'qdrant-client',
|
|
349
|
+
'pinecone-client', 'pinecone', 'weaviate-client', 'pymilvus',
|
|
350
|
+
];
|
|
351
|
+
// .env variable names that configure a managed/cloud vector store. `kind`
|
|
352
|
+
// distinguishes an endpoint (egress target) from a credential.
|
|
353
|
+
const VECTOR_ENV = {
|
|
354
|
+
PINECONE_API_KEY: { engine: 'pinecone', kind: 'key' },
|
|
355
|
+
PINECONE_ENVIRONMENT: { engine: 'pinecone', kind: 'endpoint' },
|
|
356
|
+
PINECONE_HOST: { engine: 'pinecone', kind: 'endpoint' },
|
|
357
|
+
PINECONE_INDEX: { engine: 'pinecone', kind: 'endpoint' },
|
|
358
|
+
PINECONE_INDEX_NAME: { engine: 'pinecone', kind: 'endpoint' },
|
|
359
|
+
WEAVIATE_URL: { engine: 'weaviate', kind: 'endpoint' },
|
|
360
|
+
WEAVIATE_HOST: { engine: 'weaviate', kind: 'endpoint' },
|
|
361
|
+
WEAVIATE_API_KEY: { engine: 'weaviate', kind: 'key' },
|
|
362
|
+
QDRANT_URL: { engine: 'qdrant', kind: 'endpoint' },
|
|
363
|
+
QDRANT_HOST: { engine: 'qdrant', kind: 'endpoint' },
|
|
364
|
+
QDRANT_API_KEY: { engine: 'qdrant', kind: 'key' },
|
|
365
|
+
MILVUS_URI: { engine: 'milvus', kind: 'endpoint' },
|
|
366
|
+
MILVUS_HOST: { engine: 'milvus', kind: 'endpoint' },
|
|
367
|
+
ZILLIZ_CLOUD_URI: { engine: 'milvus', kind: 'endpoint' },
|
|
368
|
+
CHROMA_SERVER_HOST: { engine: 'chroma', kind: 'endpoint' },
|
|
369
|
+
CHROMA_HOST: { engine: 'chroma', kind: 'endpoint' },
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
function npmAiDeps(pkg) {
|
|
373
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}), ...(pkg.optionalDependencies || {}) };
|
|
374
|
+
const hits = [];
|
|
375
|
+
for (const name of Object.keys(deps)) {
|
|
376
|
+
if (NPM_AI.has(name) || NPM_AI_PREFIX.some((p) => name.startsWith(p))) hits.push(name);
|
|
377
|
+
}
|
|
378
|
+
return hits;
|
|
379
|
+
}
|
|
380
|
+
function pyAiDeps(text) {
|
|
381
|
+
const hits = [];
|
|
382
|
+
for (const pkg of PY_AI) {
|
|
383
|
+
const re = new RegExp(`(^|[^a-z0-9_.-])${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_.-]|$)`, 'im');
|
|
384
|
+
if (re.test(text)) hits.push(pkg);
|
|
385
|
+
}
|
|
386
|
+
return hits;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function discoverAiDependencies(roots = [process.cwd()], files = null) {
|
|
390
|
+
const walk = files || walkWorkspace(roots);
|
|
391
|
+
const byPkg = new Map(); // `${eco}:${pkg}` -> { pkg, eco, manifests:Set }
|
|
392
|
+
const add = (eco, pkg, manifest) => {
|
|
393
|
+
const key = `${eco}:${pkg}`;
|
|
394
|
+
if (!byPkg.has(key)) byPkg.set(key, { pkg, eco, manifests: new Set() });
|
|
395
|
+
byPkg.get(key).manifests.add(manifest);
|
|
396
|
+
};
|
|
397
|
+
for (const { file } of walk.manifests) {
|
|
398
|
+
const base = path.basename(file);
|
|
399
|
+
if (base === 'package.json') {
|
|
400
|
+
const json = readJson(file);
|
|
401
|
+
if (!json) continue;
|
|
402
|
+
// Vector-store libs are surfaced as VECTOR_STORE assets, not AI tools.
|
|
403
|
+
for (const pkg of npmAiDeps(json)) if (!isVectorLib(pkg)) add('npm', pkg, file);
|
|
404
|
+
} else {
|
|
405
|
+
const text = readText(file, 100_000);
|
|
406
|
+
if (text == null) continue;
|
|
407
|
+
for (const pkg of pyAiDeps(text)) if (!isVectorLib(pkg)) add('pip', pkg, file);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const assets = [];
|
|
411
|
+
for (const { pkg, eco, manifests } of byPkg.values()) {
|
|
412
|
+
const list = [...manifests];
|
|
413
|
+
assets.push({
|
|
414
|
+
type: 'AI_TOOL',
|
|
415
|
+
name: `${pkg} (${eco})`,
|
|
416
|
+
identifier: `dep:${eco}:${pkg}`,
|
|
417
|
+
vendor: 'ai-sdk',
|
|
418
|
+
metadata: {
|
|
419
|
+
category: 'dependency',
|
|
420
|
+
ecosystem: eco,
|
|
421
|
+
package: pkg,
|
|
422
|
+
usedInProjects: list.length,
|
|
423
|
+
manifests: list.slice(0, 10),
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return assets;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── API keys sitting in .env files ───────────────────────────────
|
|
431
|
+
|
|
432
|
+
const KEY_NAME_VENDOR = {
|
|
433
|
+
OPENAI_API_KEY: 'openai', AZURE_OPENAI_API_KEY: 'azure-openai', AZURE_OPENAI_KEY: 'azure-openai',
|
|
434
|
+
ANTHROPIC_API_KEY: 'anthropic', GOOGLE_API_KEY: 'google', GOOGLE_GENAI_API_KEY: 'google',
|
|
435
|
+
GEMINI_API_KEY: 'google', MISTRAL_API_KEY: 'mistral', GROQ_API_KEY: 'groq', COHERE_API_KEY: 'cohere',
|
|
436
|
+
HUGGINGFACE_API_KEY: 'huggingface', HUGGINGFACEHUB_API_TOKEN: 'huggingface', HF_TOKEN: 'huggingface',
|
|
437
|
+
OPENROUTER_API_KEY: 'openrouter', XAI_API_KEY: 'xai', DEEPSEEK_API_KEY: 'deepseek',
|
|
438
|
+
TOGETHER_API_KEY: 'together', TOGETHERAI_API_KEY: 'together', PERPLEXITY_API_KEY: 'perplexity',
|
|
439
|
+
REPLICATE_API_TOKEN: 'replicate', FIREWORKS_API_KEY: 'fireworks', DASHSCOPE_API_KEY: 'alibaba',
|
|
440
|
+
AI21_API_KEY: 'ai21', ANYSCALE_API_KEY: 'anyscale', VOYAGE_API_KEY: 'voyage', NVIDIA_API_KEY: 'nvidia',
|
|
441
|
+
CEREBRAS_API_KEY: 'cerebras', STABILITY_API_KEY: 'stability', ELEVENLABS_API_KEY: 'elevenlabs',
|
|
442
|
+
WATSONX_APIKEY: 'ibm', LANGCHAIN_API_KEY: 'langsmith', LANGSMITH_API_KEY: 'langsmith',
|
|
443
|
+
PINECONE_API_KEY: 'pinecone', WEAVIATE_API_KEY: 'weaviate',
|
|
444
|
+
};
|
|
445
|
+
// Value-shape fingerprints — catch a key even under a non-standard var name.
|
|
446
|
+
const KEY_VALUE_PATTERNS = [
|
|
447
|
+
{ re: /^sk-ant-[A-Za-z0-9_-]{20,}/, vendor: 'anthropic' },
|
|
448
|
+
{ re: /^sk-or-[A-Za-z0-9_-]{20,}/, vendor: 'openrouter' },
|
|
449
|
+
{ re: /^sk-proj-[A-Za-z0-9_-]{20,}/, vendor: 'openai' },
|
|
450
|
+
{ re: /^sk-[A-Za-z0-9]{32,}/, vendor: 'openai' },
|
|
451
|
+
{ re: /^AIza[0-9A-Za-z_-]{30,}/, vendor: 'google' },
|
|
452
|
+
{ re: /^gsk_[A-Za-z0-9]{20,}/, vendor: 'groq' },
|
|
453
|
+
{ re: /^hf_[A-Za-z0-9]{20,}/, vendor: 'huggingface' },
|
|
454
|
+
{ re: /^xai-[A-Za-z0-9]{20,}/, vendor: 'xai' },
|
|
455
|
+
{ re: /^r8_[A-Za-z0-9]{20,}/, vendor: 'replicate' },
|
|
456
|
+
{ re: /^pplx-[A-Za-z0-9]{20,}/, vendor: 'perplexity' },
|
|
457
|
+
{ re: /^fw_[A-Za-z0-9]{20,}/, vendor: 'fireworks' },
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
function classifyKey(name, value) {
|
|
461
|
+
if (KEY_NAME_VENDOR[name]) return KEY_NAME_VENDOR[name];
|
|
462
|
+
for (const { re, vendor } of KEY_VALUE_PATTERNS) if (re.test(value)) return vendor;
|
|
463
|
+
// Fall back: a *_API_KEY / *_API_TOKEN whose name hints at a model provider.
|
|
464
|
+
if (/(_API_KEY|_API_TOKEN|_APIKEY)$/.test(name) && /(LLM|AI|GPT|CLAUDE|MODEL|OPENAI|ANTHROPIC|GEMINI)/.test(name)) return 'unknown';
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function discoverDotenvKeys(roots = [process.cwd()], files = null) {
|
|
469
|
+
const walk = files || walkWorkspace(roots);
|
|
470
|
+
const assets = [];
|
|
471
|
+
const seen = new Set();
|
|
472
|
+
for (const { file } of walk.env) {
|
|
473
|
+
const text = readText(file, 100_000);
|
|
474
|
+
if (text == null) continue;
|
|
475
|
+
for (const line of text.split(/\r?\n/)) {
|
|
476
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/);
|
|
477
|
+
if (!m) continue;
|
|
478
|
+
const name = m[1];
|
|
479
|
+
let value = m[2].trim().replace(/^["']|["']$/g, '');
|
|
480
|
+
if (!value || value.length < 8 || /^\$\{/.test(value) || /(your|xxx|placeholder|changeme|<|example)/i.test(value)) continue;
|
|
481
|
+
const vendor = classifyKey(name, value);
|
|
482
|
+
if (!vendor) continue;
|
|
483
|
+
const key = `${name}:${file}`;
|
|
484
|
+
if (seen.has(key)) continue;
|
|
485
|
+
seen.add(key);
|
|
486
|
+
assets.push({
|
|
487
|
+
type: 'MODEL_KEY',
|
|
488
|
+
name,
|
|
489
|
+
identifier: `dotenv:${file}:${name}`,
|
|
490
|
+
vendor,
|
|
491
|
+
metadata: { source: 'dotenv', file, fingerprint: `${value.slice(0, 3)}…${value.slice(-2)}` },
|
|
492
|
+
// The raw value is intentionally NOT transmitted.
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return assets;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── RAG vector stores / embedding indexes ────────────────────────
|
|
500
|
+
// A vector store is a first-class AI asset: it holds embedded (often sensitive)
|
|
501
|
+
// corpus data, is a retrieval-poisoning target, and — when persisted as a
|
|
502
|
+
// pickle-backed index (LangChain FAISS) — executes code on load. We surface
|
|
503
|
+
// three shapes: a persisted local index on disk, a client library in a project
|
|
504
|
+
// manifest, and a managed/cloud endpoint configured in a .env. No file is
|
|
505
|
+
// executed and no index contents are read — detection is by path + config only.
|
|
506
|
+
|
|
507
|
+
/** Redact a connection value to a bare host, never transmitting credentials. */
|
|
508
|
+
function endpointHost(value) {
|
|
509
|
+
const v = String(value || '').trim().replace(/^["']|["']$/g, '');
|
|
510
|
+
if (!v) return null;
|
|
511
|
+
const m = v.match(/^[a-z]+:\/\/([^/:?#\s]+)/i);
|
|
512
|
+
if (m) return m[1];
|
|
513
|
+
// bare host[:port] or a *.svc.<region>.pinecone.io style host
|
|
514
|
+
if (/^[a-z0-9.-]+\.[a-z]{2,}(:\d+)?$/i.test(v)) return v.split(':')[0];
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function discoverVectorStores(roots = [process.cwd()], files = null) {
|
|
519
|
+
const walk = files || walkWorkspace(roots);
|
|
520
|
+
const assets = [];
|
|
521
|
+
|
|
522
|
+
// (1) Persisted local indexes — one asset per store directory.
|
|
523
|
+
const byDir = new Map();
|
|
524
|
+
for (const { file } of walk.vector) {
|
|
525
|
+
const dir = path.dirname(file);
|
|
526
|
+
if (!byDir.has(dir)) byDir.set(dir, []);
|
|
527
|
+
byDir.get(dir).push(path.basename(file).toLowerCase());
|
|
528
|
+
}
|
|
529
|
+
for (const [dir, names] of byDir.entries()) {
|
|
530
|
+
let engine = 'unknown';
|
|
531
|
+
if (names.includes('chroma.sqlite3') || names.some((n) => n.startsWith('chroma-'))) engine = 'chroma';
|
|
532
|
+
else if (names.includes('index.faiss')) engine = 'faiss';
|
|
533
|
+
else if (names.some((n) => n.endsWith('.lance'))) engine = 'lancedb';
|
|
534
|
+
else if (names.some((n) => n.endsWith('.usearch'))) engine = 'usearch';
|
|
535
|
+
else if (names.includes('docstore.json') || names.includes('default__vector_store.json')) engine = 'llamaindex';
|
|
536
|
+
else continue; // a lone index.pkl with no recognised sibling — skip (avoid FP)
|
|
537
|
+
// LangChain FAISS.save_local writes a pickle sidecar → code-exec on load.
|
|
538
|
+
const pickleBacked = engine === 'faiss' && names.includes('index.pkl');
|
|
539
|
+
assets.push({
|
|
540
|
+
type: 'VECTOR_STORE',
|
|
541
|
+
name: `${engine} index (${path.basename(dir)})`,
|
|
542
|
+
identifier: `vector:local:${dir}`,
|
|
543
|
+
vendor: engine,
|
|
544
|
+
metadata: { surface: 'local-index', engine, hosted: false, pickleBacked, dir, files: names.slice(0, 20) },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// (2) Client libraries in project manifests — one asset per engine.
|
|
549
|
+
const byEngine = new Map(); // engine -> { hosted, manifests:Set }
|
|
550
|
+
const addLib = (engine, hosted, manifest) => {
|
|
551
|
+
if (!byEngine.has(engine)) byEngine.set(engine, { hosted, manifests: new Set() });
|
|
552
|
+
byEngine.get(engine).manifests.add(manifest);
|
|
553
|
+
};
|
|
554
|
+
for (const { file } of walk.manifests) {
|
|
555
|
+
const base = path.basename(file);
|
|
556
|
+
if (base === 'package.json') {
|
|
557
|
+
const json = readJson(file);
|
|
558
|
+
if (!json) continue;
|
|
559
|
+
const deps = { ...(json.dependencies || {}), ...(json.devDependencies || {}), ...(json.peerDependencies || {}), ...(json.optionalDependencies || {}) };
|
|
560
|
+
for (const name of Object.keys(deps)) {
|
|
561
|
+
const info = vectorLibInfo(name);
|
|
562
|
+
if (info) addLib(info.engine, info.hosted, file);
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
const text = readText(file, 100_000);
|
|
566
|
+
if (text == null) continue;
|
|
567
|
+
for (const pkg of PY_VECTOR) {
|
|
568
|
+
const re = new RegExp(`(^|[^a-z0-9_.-])${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_.-]|$)`, 'im');
|
|
569
|
+
if (re.test(text)) {
|
|
570
|
+
const info = vectorLibInfo(pkg);
|
|
571
|
+
if (info) addLib(info.engine, info.hosted, file);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
for (const [engine, { hosted, manifests }] of byEngine.entries()) {
|
|
577
|
+
const list = [...manifests];
|
|
578
|
+
assets.push({
|
|
579
|
+
type: 'VECTOR_STORE',
|
|
580
|
+
name: `${engine} client`,
|
|
581
|
+
identifier: `vector:client:${engine}`,
|
|
582
|
+
vendor: engine,
|
|
583
|
+
metadata: { surface: 'client-lib', engine, hosted, usedInProjects: list.length, manifests: list.slice(0, 10) },
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// (3) Managed/cloud endpoints declared in .env files — one asset per engine.
|
|
588
|
+
const byCloud = new Map(); // engine -> { hosts:Set, hasKey, hasEndpoint, files:Set }
|
|
589
|
+
for (const { file } of walk.env) {
|
|
590
|
+
const text = readText(file, 100_000);
|
|
591
|
+
if (text == null) continue;
|
|
592
|
+
for (const line of text.split(/\r?\n/)) {
|
|
593
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/);
|
|
594
|
+
if (!m) continue;
|
|
595
|
+
const cfg = VECTOR_ENV[m[1]];
|
|
596
|
+
if (!cfg) continue;
|
|
597
|
+
const value = m[2].trim().replace(/^["']|["']$/g, '');
|
|
598
|
+
if (!value || /(your|xxx|placeholder|changeme|<|example)/i.test(value)) continue;
|
|
599
|
+
if (!byCloud.has(cfg.engine)) byCloud.set(cfg.engine, { hosts: new Set(), hasKey: false, hasEndpoint: false, files: new Set() });
|
|
600
|
+
const e = byCloud.get(cfg.engine);
|
|
601
|
+
e.files.add(file);
|
|
602
|
+
if (cfg.kind === 'key') e.hasKey = true;
|
|
603
|
+
else {
|
|
604
|
+
e.hasEndpoint = true;
|
|
605
|
+
const h = endpointHost(value);
|
|
606
|
+
if (h) e.hosts.add(h);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
for (const [engine, e] of byCloud.entries()) {
|
|
611
|
+
assets.push({
|
|
612
|
+
type: 'VECTOR_STORE',
|
|
613
|
+
name: `${engine} (cloud)`,
|
|
614
|
+
identifier: `vector:cloud:${engine}`,
|
|
615
|
+
vendor: engine,
|
|
616
|
+
metadata: {
|
|
617
|
+
surface: 'cloud-endpoint',
|
|
618
|
+
engine,
|
|
619
|
+
hosted: true,
|
|
620
|
+
hasApiKey: e.hasKey,
|
|
621
|
+
hosts: [...e.hosts].slice(0, 5),
|
|
622
|
+
files: [...e.files].slice(0, 10),
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return assets;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── installed AI tools & local model runtimes (presence) ─────────
|
|
631
|
+
|
|
632
|
+
export function discoverAiTools() {
|
|
633
|
+
const checks = [
|
|
634
|
+
{ vendor: 'cursor', name: 'Cursor', probe: [path.join(HOME, '.cursor')] },
|
|
635
|
+
{ vendor: 'claude', name: 'Claude Desktop', probe: [path.join(APPDATA, 'Claude'), path.join(HOME, 'Library', 'Application Support', 'Claude'), path.join(HOME, '.config', 'Claude')] },
|
|
636
|
+
{ vendor: 'windsurf', name: 'Windsurf', probe: [path.join(HOME, '.codeium', 'windsurf')] },
|
|
637
|
+
{ vendor: 'continue', name: 'Continue', probe: [path.join(HOME, '.continue')] },
|
|
638
|
+
{ vendor: 'zed', name: 'Zed', probe: [path.join(HOME, '.config', 'zed'), path.join(HOME, 'Library', 'Application Support', 'Zed')] },
|
|
639
|
+
{ vendor: 'cody', name: 'Sourcegraph Cody', probe: [path.join(vscodeUserDir(), 'globalStorage', 'sourcegraph.cody-ai')] },
|
|
640
|
+
{ vendor: 'copilot', name: 'GitHub Copilot (VS Code)', probe: [path.join(vscodeUserDir(), 'globalStorage', 'github.copilot'), path.join(vscodeUserDir(), 'globalStorage', 'github.copilot-chat')] },
|
|
641
|
+
{ vendor: 'tabnine', name: 'Tabnine', probe: [path.join(HOME, '.tabnine'), path.join(LOCALAPPDATA, 'TabNine')] },
|
|
642
|
+
];
|
|
643
|
+
const assets = [];
|
|
644
|
+
for (const c of checks) {
|
|
645
|
+
const at = firstExisting(c.probe);
|
|
646
|
+
if (at) assets.push({ type: 'AI_TOOL', name: c.name, identifier: at, vendor: c.vendor, metadata: { category: 'assistant', detectedAt: at } });
|
|
647
|
+
}
|
|
648
|
+
return [...assets, ...discoverLocalRuntimes()];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Local model runtimes — detected by directory AND by running process. */
|
|
652
|
+
export function discoverLocalRuntimes() {
|
|
653
|
+
const runtimes = [
|
|
654
|
+
{ vendor: 'ollama', name: 'Ollama', dirs: [path.join(HOME, '.ollama'), path.join(LOCALAPPDATA, 'Ollama')], modelsDir: path.join(HOME, '.ollama', 'models', 'manifests'), proc: ['ollama'] },
|
|
655
|
+
{ 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'] },
|
|
656
|
+
{ vendor: 'jan', name: 'Jan', dirs: [path.join(HOME, 'jan'), path.join(HOME, '.jan'), path.join(APPDATA, 'Jan')], proc: ['jan'] },
|
|
657
|
+
{ 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'] },
|
|
658
|
+
{ vendor: 'huggingface', name: 'Hugging Face cache', dirs: [path.join(HOME, '.cache', 'huggingface'), path.join(process.env.HF_HOME || '', 'hub')], proc: [] },
|
|
659
|
+
{ vendor: 'localai', name: 'LocalAI', dirs: [path.join(HOME, '.localai')], proc: ['local-ai', 'localai'] },
|
|
660
|
+
{ vendor: 'textgen', name: 'Text Generation WebUI', dirs: [], proc: ['text-generation', 'oobabooga'] },
|
|
661
|
+
{ vendor: 'vllm', name: 'vLLM', dirs: [], proc: ['vllm'] },
|
|
662
|
+
];
|
|
663
|
+
const procs = listProcesses();
|
|
664
|
+
const assets = [];
|
|
665
|
+
for (const r of runtimes) {
|
|
666
|
+
const at = firstExisting(r.dirs);
|
|
667
|
+
const running = r.proc.some((tok) => procs.some((p) => p.includes(tok)));
|
|
668
|
+
if (!at && !running) continue;
|
|
669
|
+
const meta = { category: 'local-runtime', detectedAt: at || null, running };
|
|
670
|
+
if (r.vendor === 'ollama' && r.modelsDir) meta.models = ollamaModels(r.modelsDir);
|
|
671
|
+
assets.push({ type: 'AI_TOOL', name: r.name, identifier: at || `proc:${r.vendor}`, vendor: r.vendor, metadata: meta });
|
|
672
|
+
}
|
|
673
|
+
return assets;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Enumerate locally-pulled Ollama models from the manifests tree (names only). */
|
|
677
|
+
function ollamaModels(manifestsDir) {
|
|
678
|
+
const out = [];
|
|
679
|
+
const walk = (dir, depth) => {
|
|
680
|
+
if (depth > 5 || out.length > 100) return;
|
|
681
|
+
let entries;
|
|
682
|
+
try {
|
|
683
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
684
|
+
} catch {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
for (const e of entries) {
|
|
688
|
+
const full = path.join(dir, e.name);
|
|
689
|
+
if (e.isDirectory()) walk(full, depth + 1);
|
|
690
|
+
else if (e.isFile()) {
|
|
691
|
+
// manifests/<registry>/<namespace>/<model>/<tag> -> namespace/model:tag
|
|
692
|
+
const rel = path.relative(manifestsDir, full).split(path.sep);
|
|
693
|
+
if (rel.length >= 2) out.push(`${rel.slice(1, -1).join('/')}:${rel[rel.length - 1]}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
walk(manifestsDir, 0);
|
|
698
|
+
return out.slice(0, 100);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Best-effort process listing (short timeout, never throws). */
|
|
702
|
+
function listProcesses() {
|
|
703
|
+
try {
|
|
704
|
+
if (PLAT === 'win32') {
|
|
705
|
+
const out = execFileSync('tasklist', ['/fo', 'csv', '/nh'], { timeout: 4000, encoding: 'utf8', windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
|
706
|
+
return out.split(/\r?\n/).map((l) => (l.match(/^"([^"]+)"/)?.[1] || '').toLowerCase()).filter(Boolean);
|
|
707
|
+
}
|
|
708
|
+
const out = execFileSync('ps', ['-eo', 'comm='], { timeout: 4000, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
709
|
+
return out.split(/\r?\n/).map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
710
|
+
} catch {
|
|
711
|
+
return [];
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ── coding agents (autonomous tool-runners) ──────────────────────
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Discover installed CODING AGENTS, separate from passive AI tools. For each we
|
|
719
|
+
* record whether the Shomra runtime firewall hook is installed (`guarded`) so
|
|
720
|
+
* the backend can flag an unguarded agent — one that can run shell / edit files
|
|
721
|
+
* / call MCP with no policy checkpoint. This is the shadow-agent surface.
|
|
722
|
+
*/
|
|
723
|
+
export function discoverCodingAgents(roots = [process.cwd()]) {
|
|
724
|
+
const cwd = process.cwd();
|
|
725
|
+
const agents = [
|
|
726
|
+
{ 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')] },
|
|
727
|
+
{ vendor: 'cursor', name: 'Cursor', probes: [path.join(HOME, '.cursor')], hookFiles: [path.join(HOME, '.cursor', 'hooks.json'), path.join(cwd, '.cursor', 'hooks.json')] },
|
|
728
|
+
{ vendor: 'windsurf', name: 'Windsurf', probes: [path.join(HOME, '.codeium', 'windsurf')], hookFiles: [path.join(HOME, '.codeium', 'windsurf', 'hooks.json'), path.join(cwd, '.windsurf', 'hooks.json')] },
|
|
729
|
+
{ vendor: 'gemini', name: 'Gemini CLI', probes: [path.join(HOME, '.gemini')], hookFiles: [path.join(HOME, '.gemini', 'settings.json'), path.join(cwd, '.gemini', 'settings.json')] },
|
|
730
|
+
{ vendor: 'codex', name: 'OpenAI Codex CLI', probes: [path.join(HOME, '.codex')], hookFiles: [path.join(HOME, '.codex', 'hooks.json'), path.join(cwd, '.codex', 'hooks.json')] },
|
|
731
|
+
{ 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')] },
|
|
732
|
+
{ 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')] },
|
|
733
|
+
{ vendor: 'roo', name: 'Roo Code', probes: [path.join(vscodeUserDir(), 'globalStorage', 'rooveterinaryinc.roo-cline')], hookFiles: [path.join(cwd, '.roo', 'hooks.json')] },
|
|
734
|
+
{ 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')] },
|
|
735
|
+
];
|
|
736
|
+
const assets = [];
|
|
737
|
+
for (const a of agents) {
|
|
738
|
+
const installedAt = a.probes.find((p) => exists(p));
|
|
739
|
+
if (!installedAt) continue;
|
|
740
|
+
const guardFile = a.hookFiles.find((f) => {
|
|
741
|
+
const t = readText(f, 20_000);
|
|
742
|
+
return t != null && /shomra/i.test(t);
|
|
743
|
+
});
|
|
744
|
+
assets.push({
|
|
745
|
+
type: 'AI_AGENT',
|
|
746
|
+
name: a.name,
|
|
747
|
+
identifier: `agent:${a.vendor}`,
|
|
748
|
+
vendor: a.vendor,
|
|
749
|
+
metadata: { detectedAt: installedAt, guarded: !!guardFile, guardFile: guardFile || null },
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
return assets;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ── model-provider API keys in the environment ───────────────────
|
|
756
|
+
|
|
757
|
+
export function discoverModelKeys() {
|
|
758
|
+
const assets = [];
|
|
759
|
+
for (const [name, v] of Object.entries(process.env)) {
|
|
760
|
+
if (!v || v.length < 8) continue;
|
|
761
|
+
const vendor = KEY_NAME_VENDOR[name] || (/(_API_KEY|_API_TOKEN|_APIKEY)$/.test(name) ? classifyKey(name, v) : null);
|
|
762
|
+
if (!vendor) continue;
|
|
763
|
+
assets.push({
|
|
764
|
+
type: 'MODEL_KEY',
|
|
765
|
+
name,
|
|
766
|
+
identifier: `env:${name}`,
|
|
767
|
+
vendor,
|
|
768
|
+
metadata: { source: 'environment', fingerprint: `${v.slice(0, 3)}…${v.slice(-2)}` },
|
|
769
|
+
// The raw value is intentionally NOT transmitted.
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
return assets;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function redactEnv(env) {
|
|
776
|
+
if (!env || typeof env !== 'object') return {};
|
|
777
|
+
const out = {};
|
|
778
|
+
for (const [k, v] of Object.entries(env)) {
|
|
779
|
+
const s = String(v ?? '');
|
|
780
|
+
out[k] = s.length > 8 ? `${s.slice(0, 3)}…${s.slice(-2)}` : s;
|
|
781
|
+
}
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── aggregate ────────────────────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
export function discoverAll(roots = [process.cwd()], opts = {}) {
|
|
788
|
+
const { autoExpand = true } = opts;
|
|
789
|
+
const scanRoots = resolveRoots(roots, autoExpand);
|
|
790
|
+
const files = walkWorkspace(scanRoots); // one walk, shared by every file-based discoverer
|
|
791
|
+
const all = [
|
|
792
|
+
...discoverMcpServers(scanRoots, files),
|
|
793
|
+
...discoverRulesFiles(scanRoots, files),
|
|
794
|
+
...discoverAiDependencies(scanRoots, files),
|
|
795
|
+
...discoverVectorStores(scanRoots, files),
|
|
796
|
+
...discoverDotenvKeys(scanRoots, files),
|
|
797
|
+
...discoverAiTools(),
|
|
798
|
+
...discoverCodingAgents(scanRoots),
|
|
799
|
+
...discoverModelKeys(),
|
|
800
|
+
];
|
|
801
|
+
// Final dedup by (type, identifier) — a runtime can be found by both dir and
|
|
802
|
+
// process; an env key can also appear in a .env file.
|
|
803
|
+
const seen = new Set();
|
|
804
|
+
const out = [];
|
|
805
|
+
for (const a of all) {
|
|
806
|
+
const key = `${a.type}::${a.identifier || a.name}`;
|
|
807
|
+
if (seen.has(key)) continue;
|
|
808
|
+
seen.add(key);
|
|
809
|
+
out.push(a);
|
|
810
|
+
}
|
|
811
|
+
return out;
|
|
812
|
+
}
|