agentaudit 3.9.28 → 3.9.30

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 (3) hide show
  1. package/cli.mjs +2302 -2251
  2. package/index.mjs +49 -1
  3. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -1,2251 +1,2302 @@
1
- #!/usr/bin/env node
2
- /**
3
- * AgentAudit CLI — Security scanner for AI tools
4
- *
5
- * Scan & Audit: scan <url>, audit <url>, discover
6
- * Registry: check <name|url>, lookup <name>
7
- * Setup: status, setup, config
8
- *
9
- * Global flags: --json, --quiet, --no-color, --provider, --debug, --export
10
- */
11
-
12
- import fs from 'fs';
13
- import os from 'os';
14
- import path from 'path';
15
- import { execSync } from 'child_process';
16
- import { createInterface } from 'readline';
17
- import { fileURLToPath } from 'url';
18
-
19
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
- const SKILL_DIR = path.resolve(__dirname);
21
- const REGISTRY_URL = 'https://agentaudit.dev';
22
-
23
- // ── Provider resolution ────
24
- function resolveProvider(flagOverride, keys) {
25
- const orModel = process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
26
- const ollamaModel = process.env.OLLAMA_MODEL || 'llama3.1';
27
- const ollamaHost = process.env.OLLAMA_HOST || 'http://localhost:11434';
28
- const customUrl = process.env.LLM_API_URL;
29
- const customKey = process.env.LLM_API_KEY;
30
- const customModel = process.env.LLM_MODEL || 'default';
31
-
32
- const providers = {
33
- anthropic: keys.anthropicKey ? { id: 'anthropic', label: 'Anthropic (Claude)', key: keys.anthropicKey } : null,
34
- openai: keys.openaiKey ? { id: 'openai', label: 'OpenAI (GPT-4o)', key: keys.openaiKey } : null,
35
- openrouter: keys.openrouterKey ? { id: 'openrouter', label: `OpenRouter (${orModel})`, key: keys.openrouterKey } : null,
36
- ollama: process.env.OLLAMA_MODEL || process.env.OLLAMA_HOST ? { id: 'ollama', label: `Ollama (${ollamaModel})`, key: null, host: ollamaHost, model: ollamaModel } : null,
37
- custom: customUrl ? { id: 'custom', label: `Custom (${customModel})`, key: customKey, url: customUrl, model: customModel } : null,
38
- };
39
- // Aliases
40
- const aliases = { claude: 'anthropic', gpt: 'openai', 'gpt-4o': 'openai', 'gpt4': 'openai', or: 'openrouter', local: 'ollama' };
41
-
42
- // Priority: --provider flag > AGENTAUDIT_PROVIDER env > config file > model-inferred > auto-detect
43
- const preferred = flagOverride
44
- || process.env.AGENTAUDIT_PROVIDER?.toLowerCase()
45
- || loadConfig()?.preferred_provider
46
- || null;
47
-
48
- if (preferred) {
49
- const resolved = aliases[preferred] || preferred;
50
- const p = providers[resolved];
51
- if (!p) return null;
52
- return p;
53
- }
54
-
55
- // Smart inference: if model is set, try to match it to a provider
56
- const activeModel = globalModelOverride || process.env.AGENTAUDIT_MODEL || loadConfig()?.preferred_model;
57
- if (activeModel) {
58
- const lm = activeModel.toLowerCase();
59
- // Direct provider models (no slash = native format)
60
- if (!lm.includes('/')) {
61
- if (lm.startsWith('claude') && providers.anthropic) return providers.anthropic;
62
- if ((lm.startsWith('gpt') || lm.startsWith('o3') || lm.startsWith('o4') || lm.startsWith('o1')) && providers.openai) return providers.openai;
63
- if (providers.ollama && (process.env.OLLAMA_MODEL || process.env.OLLAMA_HOST)) return providers.ollama;
64
- }
65
- // Slash format = OpenRouter convention (provider/model)
66
- if (lm.includes('/') && providers.openrouter) return providers.openrouter;
67
- }
68
-
69
- // Auto-detect priority: Anthropic > OpenAI > OpenRouter > Custom > Ollama (local last — usually weaker)
70
- return providers.anthropic || providers.openai || providers.openrouter || providers.custom || providers.ollama || null;
71
- }
72
-
73
- // ── Global flags (set in main before command routing) ────
74
- let jsonMode = false;
75
- let quietMode = false;
76
- let modelOverride = null; // --model flag or AGENTAUDIT_MODEL env or config
77
- let globalModelOverride = null; // same, but set early for resolveProvider
78
-
79
- // ── ANSI Colors (respects NO_COLOR and --no-color) ───────
80
-
81
- const noColor = !!(process.env.NO_COLOR || process.argv.includes('--no-color'));
82
-
83
- const c = noColor ? {
84
- reset: '', bold: '', dim: '', red: '', green: '', yellow: '',
85
- blue: '', magenta: '', cyan: '', white: '', gray: '',
86
- bgRed: '', bgGreen: '', bgYellow: '',
87
- } : {
88
- reset: '\x1b[0m',
89
- bold: '\x1b[1m',
90
- dim: '\x1b[2m',
91
- red: '\x1b[31m',
92
- green: '\x1b[32m',
93
- yellow: '\x1b[33m',
94
- blue: '\x1b[34m',
95
- magenta: '\x1b[35m',
96
- cyan: '\x1b[36m',
97
- white: '\x1b[37m',
98
- gray: '\x1b[90m',
99
- bgRed: '\x1b[41m',
100
- bgGreen: '\x1b[42m',
101
- bgYellow: '\x1b[43m',
102
- };
103
-
104
- const icons = {
105
- safe: `${c.green}✔${c.reset}`,
106
- caution: `${c.yellow}⚠${c.reset}`,
107
- unsafe: `${c.red}✖${c.reset}`,
108
- info: `${c.blue}ℹ${c.reset}`,
109
- scan: `${c.cyan}◉${c.reset}`,
110
- tree: `${c.gray}├──${c.reset}`,
111
- treeLast: `${c.gray}└──${c.reset}`,
112
- pipe: `${c.gray}│${c.reset}`,
113
- bullet: `${c.gray}•${c.reset}`,
114
- };
115
-
116
- // ── Credentials ─────────────────────────────────────────
117
-
118
- const home = process.env.HOME || process.env.USERPROFILE || '';
119
- const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
120
- const USER_CRED_DIR = path.join(xdgConfig, 'agentaudit');
121
- const USER_CRED_FILE = path.join(USER_CRED_DIR, 'credentials.json');
122
- const SKILL_CRED_FILE = path.join(SKILL_DIR, 'config', 'credentials.json');
123
-
124
- function loadCredentials() {
125
- for (const f of [SKILL_CRED_FILE, USER_CRED_FILE]) {
126
- if (fs.existsSync(f)) {
127
- try {
128
- const data = JSON.parse(fs.readFileSync(f, 'utf8'));
129
- if (data.api_key) return data;
130
- } catch {}
131
- }
132
- }
133
- if (process.env.AGENTAUDIT_API_KEY) {
134
- return { api_key: process.env.AGENTAUDIT_API_KEY, agent_name: 'env' };
135
- }
136
- return null;
137
- }
138
-
139
- function saveCredentials(data) {
140
- const json = JSON.stringify(data, null, 2);
141
- fs.mkdirSync(USER_CRED_DIR, { recursive: true });
142
- fs.writeFileSync(USER_CRED_FILE, json, { mode: 0o600 });
143
- try {
144
- fs.mkdirSync(path.dirname(SKILL_CRED_FILE), { recursive: true });
145
- fs.writeFileSync(SKILL_CRED_FILE, json, { mode: 0o600 });
146
- } catch {}
147
- }
148
-
149
- const USER_CONFIG_FILE = path.join(USER_CRED_DIR, 'config.json');
150
-
151
- function loadConfig() {
152
- try {
153
- if (fs.existsSync(USER_CONFIG_FILE)) {
154
- return JSON.parse(fs.readFileSync(USER_CONFIG_FILE, 'utf8'));
155
- }
156
- } catch {}
157
- return {};
158
- }
159
-
160
- function saveConfig(data) {
161
- const existing = loadConfig();
162
- const merged = { ...existing, ...data };
163
- fs.mkdirSync(USER_CRED_DIR, { recursive: true });
164
- fs.writeFileSync(USER_CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 });
165
- }
166
-
167
- function askQuestion(question) {
168
- const rl = createInterface({ input: process.stdin, output: process.stdout });
169
- return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer.trim()); }));
170
- }
171
-
172
- /**
173
- * Interactive multi-select in terminal. No dependencies.
174
- * items: [{ label, sublabel?, value, checked? }]
175
- * Returns: array of selected values
176
- */
177
- function multiSelect(items, { title = 'Select items', hint = 'Space=toggle ↑↓=move a=all n=none Enter=confirm' } = {}) {
178
- return new Promise((resolve) => {
179
- if (!process.stdin.isTTY) {
180
- // Non-interactive: return all items
181
- resolve(items.map(i => i.value));
182
- return;
183
- }
184
-
185
- const selected = new Set(items.filter(i => i.checked).map((_, idx) => idx));
186
- let cursor = 0;
187
-
188
- const render = () => {
189
- // Move cursor up to overwrite previous render
190
- process.stdout.write(`\x1b[${items.length + 3}A\x1b[J`);
191
- draw();
192
- };
193
-
194
- const draw = () => {
195
- console.log(` ${c.bold}${title}${c.reset} ${c.dim}(${selected.size}/${items.length} selected)${c.reset}`);
196
- console.log(` ${c.dim}${hint}${c.reset}`);
197
- console.log();
198
- for (let i = 0; i < items.length; i++) {
199
- const item = items[i];
200
- const isCursor = i === cursor;
201
- const isSelected = selected.has(i);
202
- const pointer = isCursor ? `${c.cyan}❯${c.reset}` : ' ';
203
- const checkbox = isSelected ? `${c.green}◉${c.reset}` : `${c.dim}○${c.reset}`;
204
- const label = isCursor ? `${c.bold}${item.label}${c.reset}` : item.label;
205
- const sub = item.sublabel ? ` ${c.dim}${item.sublabel}${c.reset}` : '';
206
- console.log(` ${pointer} ${checkbox} ${label}${sub}`);
207
- }
208
- };
209
-
210
- // Initial draw
211
- draw();
212
-
213
- process.stdin.setRawMode(true);
214
- process.stdin.resume();
215
- process.stdin.setEncoding('utf8');
216
-
217
- const onData = (key) => {
218
- // Ctrl+C
219
- if (key === '\x03') {
220
- process.stdin.setRawMode(false);
221
- process.stdin.pause();
222
- process.stdin.removeListener('data', onData);
223
- console.log();
224
- process.exitCode = 0; return;
225
- }
226
-
227
- // Enter
228
- if (key === '\r' || key === '\n') {
229
- process.stdin.setRawMode(false);
230
- process.stdin.pause();
231
- process.stdin.removeListener('data', onData);
232
- resolve(items.filter((_, i) => selected.has(i)).map(i => i.value));
233
- return;
234
- }
235
-
236
- // Space — toggle
237
- if (key === ' ') {
238
- if (selected.has(cursor)) selected.delete(cursor);
239
- else selected.add(cursor);
240
- render();
241
- return;
242
- }
243
-
244
- // a — select all
245
- if (key === 'a') {
246
- for (let i = 0; i < items.length; i++) selected.add(i);
247
- render();
248
- return;
249
- }
250
-
251
- // n select none
252
- if (key === 'n') {
253
- selected.clear();
254
- render();
255
- return;
256
- }
257
-
258
- // Arrow up / k
259
- if (key === '\x1b[A' || key === 'k') {
260
- cursor = (cursor - 1 + items.length) % items.length;
261
- render();
262
- return;
263
- }
264
-
265
- // Arrow down / j
266
- if (key === '\x1b[B' || key === 'j') {
267
- cursor = (cursor + 1) % items.length;
268
- render();
269
- return;
270
- }
271
- };
272
-
273
- process.stdin.on('data', onData);
274
- });
275
- }
276
-
277
- async function registerAgent(agentName) {
278
- const res = await fetch(`${REGISTRY_URL}/api/register`, {
279
- method: 'POST',
280
- headers: { 'Content-Type': 'application/json' },
281
- body: JSON.stringify({ agent_name: agentName }),
282
- signal: AbortSignal.timeout(15_000),
283
- });
284
- if (!res.ok) throw new Error(`Registration failed (HTTP ${res.status}): ${await res.text()}`);
285
- return res.json();
286
- }
287
-
288
- async function setupCommand() {
289
- console.log(` ${c.bold}Setup${c.reset}`);
290
- console.log();
291
-
292
- const existing = loadCredentials();
293
- if (existing) {
294
- console.log(` ${icons.safe} Already configured as ${c.bold}${existing.agent_name}${c.reset}`);
295
- console.log(` ${c.dim}Key: ${existing.api_key.slice(0, 8)}...${c.reset}`);
296
- console.log();
297
- const answer = await askQuestion(` Reconfigure? ${c.dim}(y/N)${c.reset} `);
298
- if (answer.toLowerCase() !== 'y') {
299
- console.log(` ${c.dim}Keeping existing config.${c.reset}`);
300
- return;
301
- }
302
- console.log();
303
- }
304
-
305
- console.log(` ${c.bold}1)${c.reset} Register new agent ${c.dim}(free, creates API key automatically)${c.reset}`);
306
- console.log(` ${c.bold}2)${c.reset} Enter existing API key`);
307
- console.log();
308
- const choice = await askQuestion(` Choice ${c.dim}(1/2)${c.reset}: `);
309
- console.log();
310
-
311
- if (choice === '2') {
312
- const key = await askQuestion(` API Key: `);
313
- if (!key) { console.log(` ${c.red}No key entered.${c.reset}`); return; }
314
- const name = await askQuestion(` Agent name ${c.dim}(optional)${c.reset}: `);
315
- saveCredentials({ api_key: key, agent_name: name || 'custom' });
316
- console.log();
317
- console.log(` ${icons.safe} Saved! Key stored in ${c.dim}${USER_CRED_FILE}${c.reset}`);
318
- } else {
319
- const name = await askQuestion(` Agent name ${c.dim}(e.g. my-scanner, claude-desktop)${c.reset}: `);
320
- if (!name || !/^[a-zA-Z0-9._-]{2,64}$/.test(name)) {
321
- console.log(` ${c.red}Invalid name. Use 2-64 chars: letters, numbers, dash, underscore, dot.${c.reset}`);
322
- return;
323
- }
324
- process.stdout.write(` Registering ${c.bold}${name}${c.reset}...`);
325
- try {
326
- const data = await registerAgent(name);
327
- saveCredentials({ api_key: data.api_key, agent_name: data.agent_name });
328
- console.log(` ${c.green}done!${c.reset}`);
329
- console.log();
330
- console.log(` ${icons.safe} Registered as ${c.bold}${data.agent_name}${c.reset}`);
331
- console.log(` ${c.dim}Key: ${data.api_key.slice(0, 12)}...${c.reset}`);
332
- console.log(` ${c.dim}Saved to: ${USER_CRED_FILE}${c.reset}`);
333
- } catch (err) {
334
- console.log(` ${c.red}failed${c.reset}`);
335
- console.log(` ${c.red}${err.message}${c.reset}`);
336
- return;
337
- }
338
- }
339
-
340
- console.log();
341
- console.log(` ${c.bold}Ready!${c.reset} You can now:`);
342
- console.log(` ${c.dim}•${c.reset} Discover servers: ${c.cyan}agentaudit discover${c.reset}`);
343
- console.log(` ${c.dim}•${c.reset} Audit packages: ${c.cyan}agentaudit audit <repo-url>${c.reset} ${c.dim}(deep LLM analysis)${c.reset}`);
344
- console.log(` ${c.dim}•${c.reset} Quick scan: ${c.cyan}agentaudit scan <repo-url>${c.reset} ${c.dim}(regex-based)${c.reset}`);
345
- console.log(` ${c.dim}•${c.reset} Check registry: ${c.cyan}agentaudit check <name>${c.reset}`);
346
- console.log(` ${c.dim}•${c.reset} Submit reports via MCP in Claude/Cursor/Windsurf`);
347
- console.log();
348
- }
349
-
350
- // ── Structured error output ─────────────────────────────
351
-
352
- function emitError(code, message, hint, exitCode = 2) {
353
- if (jsonMode) {
354
- process.stderr.write(JSON.stringify({ error: true, code, message, hint: hint || undefined, exitCode }) + '\n');
355
- }
356
- process.exitCode = exitCode;
357
- }
358
-
359
- // ── Levenshtein distance for typo correction ────────────
360
-
361
- function levenshtein(a, b) {
362
- const m = a.length, n = b.length;
363
- const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
364
- for (let i = 0; i <= m; i++) dp[i][0] = i;
365
- for (let j = 0; j <= n; j++) dp[0][j] = j;
366
- for (let i = 1; i <= m; i++)
367
- for (let j = 1; j <= n; j++)
368
- dp[i][j] = Math.min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + (a[i-1] !== b[j-1] ? 1 : 0));
369
- return dp[m][n];
370
- }
371
-
372
- // ── Helpers ──────────────────────────────────────────────
373
-
374
- function getVersion() {
375
- try {
376
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
377
- return pkg.version || '0.0.0';
378
- } catch { return '0.0.0'; }
379
- }
380
-
381
- function banner() {
382
- if (quietMode || jsonMode) return;
383
- const creds = loadCredentials();
384
- const agentInfo = creds?.agent_name ? ` ${c.dim}·${c.reset} ${c.green}${creds.agent_name}${c.reset}` : '';
385
- console.log();
386
- console.log(` 🛡 ${c.bold}${c.cyan}AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset}${agentInfo}`);
387
- console.log(` ${c.dim}Security scanner for AI tools${c.reset}`);
388
- console.log();
389
- }
390
-
391
- function slugFromUrl(url) {
392
- const match = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
393
- if (match) {
394
- const owner = match[1].toLowerCase().replace(/[^a-z0-9-]/g, '-');
395
- const repo = match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
396
- // Generic repo names get owner prefix to avoid collisions
397
- const generic = ['mcp', 'server', 'plugin', 'tool', 'agent', 'sdk', 'api', 'app', 'cli', 'lib', 'core'];
398
- if (generic.includes(repo)) return `${owner}-${repo}`;
399
- return repo;
400
- }
401
- return url.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 60);
402
- }
403
-
404
- function elapsed(startMs) {
405
- const ms = Date.now() - startMs;
406
- if (ms < 1000) return `${ms}ms`;
407
- return `${(ms / 1000).toFixed(1)}s`;
408
- }
409
-
410
- function riskBadge(score) {
411
- if (score === 0) return `${c.bgGreen}${c.bold}${c.white} SAFE ${c.reset}`;
412
- if (score <= 10) return `${c.bgGreen}${c.white} LOW ${c.reset}`;
413
- if (score <= 30) return `${c.bgYellow}${c.bold} CAUTION ${c.reset}`;
414
- return `${c.bgRed}${c.bold}${c.white} UNSAFE ${c.reset}`;
415
- }
416
-
417
- function severityColor(sev) {
418
- switch (sev) {
419
- case 'critical': return c.red;
420
- case 'high': return c.red;
421
- case 'medium': return c.yellow;
422
- case 'low': return c.blue;
423
- default: return c.gray;
424
- }
425
- }
426
-
427
- function severityIcon(sev) {
428
- switch (sev) {
429
- case 'critical': return `${c.red}●${c.reset}`;
430
- case 'high': return `${c.red}●${c.reset}`;
431
- case 'medium': return `${c.yellow}●${c.reset}`;
432
- case 'low': return `${c.blue}●${c.reset}`;
433
- default: return `${c.green}●${c.reset}`;
434
- }
435
- }
436
-
437
- // ── File Collection (same logic as MCP server) ──────────
438
-
439
- function extractJSON(text) {
440
- // 1. Try parsing the entire text as JSON directly
441
- try { return JSON.parse(text.trim()); } catch {}
442
-
443
- // 2. Strip markdown code fences — try last fence first (report is usually at the end)
444
- const fenceMatches = [...text.matchAll(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/g)];
445
- for (let i = fenceMatches.length - 1; i >= 0; i--) {
446
- try {
447
- const parsed = JSON.parse(fenceMatches[i][1].trim());
448
- if (parsed && typeof parsed === 'object' && ('risk_score' in parsed || 'findings' in parsed || 'result' in parsed)) return parsed;
449
- } catch {}
450
- }
451
- // Try any fence even without report keys
452
- for (let i = fenceMatches.length - 1; i >= 0; i--) {
453
- try { return JSON.parse(fenceMatches[i][1].trim()); } catch {}
454
- }
455
-
456
- // 3. Find ALL balanced top-level { ... } blocks, try each (prefer largest valid one)
457
- const blocks = [];
458
- let searchFrom = 0;
459
- while (searchFrom < text.length) {
460
- const start = text.indexOf('{', searchFrom);
461
- if (start === -1) break;
462
- let depth = 0, inStr = false, esc = false;
463
- let end = -1;
464
- for (let i = start; i < text.length; i++) {
465
- const ch = text[i];
466
- if (esc) { esc = false; continue; }
467
- if (ch === '\\' && inStr) { esc = true; continue; }
468
- if (ch === '"') { inStr = !inStr; continue; }
469
- if (inStr) continue;
470
- if (ch === '{') depth++;
471
- else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } }
472
- }
473
- if (end > start) {
474
- blocks.push(text.slice(start, end + 1));
475
- searchFrom = end + 1;
476
- } else {
477
- searchFrom = start + 1;
478
- }
479
- }
480
- // Try largest block first (the report JSON is usually the biggest)
481
- blocks.sort((a, b) => b.length - a.length);
482
- for (const block of blocks) {
483
- try { return JSON.parse(block); } catch {}
484
- }
485
-
486
- return null;
487
- }
488
-
489
- const MAX_FILE_SIZE = 50_000;
490
- const MAX_TOTAL_SIZE = 300_000;
491
- const SKIP_DIRS = new Set([
492
- 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
493
- '.next', '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache', 'vendor',
494
- 'test', 'tests', '__tests__', 'spec', 'specs', 'docs', 'doc',
495
- 'examples', 'example', 'fixtures', '.github', '.vscode', '.idea',
496
- 'e2e', 'benchmark', 'benchmarks', '.tox', '.eggs', 'htmlcov',
497
- ]);
498
- const SKIP_EXTENSIONS = new Set([
499
- '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff',
500
- '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.zip', '.tar', '.gz',
501
- '.map', '.min.js', '.min.css', '.d.ts', '.pyc', '.pyo', '.so',
502
- '.dylib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite',
503
- ]);
504
-
505
- function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
506
- if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
507
- let entries;
508
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
509
- catch { return collected; }
510
- entries.sort((a, b) => a.name.localeCompare(b.name));
511
- for (const entry of entries) {
512
- if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
513
- const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
514
- const fullPath = path.join(dir, entry.name);
515
- if (entry.isDirectory()) {
516
- if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
517
- collectFiles(fullPath, relPath, collected, totalSize);
518
- } else {
519
- const ext = path.extname(entry.name).toLowerCase();
520
- if (SKIP_EXTENSIONS.has(ext)) continue;
521
- try {
522
- const stat = fs.statSync(fullPath);
523
- if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
524
- const content = fs.readFileSync(fullPath, 'utf8');
525
- totalSize.bytes += content.length;
526
- collected.push({ path: relPath, content, size: stat.size });
527
- } catch {}
528
- }
529
- }
530
- return collected;
531
- }
532
-
533
- // ── Detect package properties ───────────────────────────
534
-
535
- function detectPackageInfo(repoPath, files) {
536
- const info = { type: 'unknown', tools: [], prompts: [], language: 'unknown', entrypoint: null };
537
-
538
- // Detect language
539
- const exts = files.map(f => path.extname(f.path).toLowerCase());
540
- const extCounts = {};
541
- exts.forEach(e => { extCounts[e] = (extCounts[e] || 0) + 1; });
542
- const topExt = Object.entries(extCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
543
-
544
- const langMap = { '.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript', '.mjs': 'JavaScript', '.rs': 'Rust', '.go': 'Go', '.java': 'Java', '.rb': 'Ruby' };
545
- info.language = langMap[topExt] || topExt || 'unknown';
546
-
547
- // Detect package type
548
- const allContent = files.map(f => f.content).join('\n');
549
- if (allContent.includes('@modelcontextprotocol') || allContent.includes('FastMCP') || allContent.includes('mcp.server') || allContent.includes('mcp_server')) {
550
- info.type = 'mcp-server';
551
- } else if (files.some(f => f.path.toLowerCase() === 'skill.md')) {
552
- info.type = 'agent-skill';
553
- } else if (allContent.includes('#!/usr/bin/env') || allContent.includes('argparse') || allContent.includes('commander')) {
554
- info.type = 'cli-tool';
555
- } else {
556
- info.type = 'library';
557
- }
558
-
559
- // Extract MCP tools (look for tool definitions)
560
- const toolPatterns = [
561
- // JS/TS: name: 'tool_name' or "tool_name" in tool definitions
562
- /(?:name|tool_name)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
563
- // Python: @mcp.tool() def func_name or Tool(name="...")
564
- /(?:@(?:mcp|server)\.tool\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*))|(?:Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"])/gi,
565
- // Direct: tool names in ListTools handlers
566
- /['"]name['"]\s*:\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
567
- ];
568
-
569
- const toolSet = new Set();
570
- for (const file of files) {
571
- for (const pattern of toolPatterns) {
572
- pattern.lastIndex = 0;
573
- let m;
574
- while ((m = pattern.exec(file.content)) !== null) {
575
- const name = m[1] || m[2];
576
- if (name && name.length > 2 && name.length < 50 && !['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none'].includes(name)) {
577
- toolSet.add(name);
578
- }
579
- }
580
- }
581
- }
582
- info.tools = [...toolSet];
583
-
584
- // Extract prompts (look for prompt definitions)
585
- const promptPatterns = [
586
- /(?:prompt|PROMPT)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
587
- /@(?:mcp|server)\.prompt\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*)/gi,
588
- ];
589
- const promptSet = new Set();
590
- for (const file of files) {
591
- for (const pattern of promptPatterns) {
592
- pattern.lastIndex = 0;
593
- let m;
594
- while ((m = pattern.exec(file.content)) !== null) {
595
- if (m[1] && m[1].length > 2) promptSet.add(m[1]);
596
- }
597
- }
598
- }
599
- info.prompts = [...promptSet];
600
-
601
- // Detect entrypoint
602
- const entryFiles = ['index.js', 'index.ts', 'index.mjs', 'main.py', 'server.py', 'app.py', 'src/index.ts', 'src/main.ts', 'src/index.js'];
603
- for (const ef of entryFiles) {
604
- if (files.some(f => f.path === ef)) { info.entrypoint = ef; break; }
605
- }
606
-
607
- return info;
608
- }
609
-
610
- // ── Quick static checks ─────────────────────────────────
611
-
612
- function quickChecks(files) {
613
- const findings = [];
614
-
615
- const checks = [
616
- {
617
- id: 'EXEC_INJECTION',
618
- title: 'Command injection risk',
619
- severity: 'high',
620
- pattern: /(?:exec(?:Sync)?|spawn|child_process|subprocess|os\.system|os\.popen|Popen)\s*\([^)]*(?:\$\{|`|\+\s*(?:req|input|args|param|user|query))/i,
621
- category: 'injection',
622
- },
623
- {
624
- id: 'EVAL_USAGE',
625
- title: 'Dynamic code evaluation',
626
- severity: 'high',
627
- pattern: /(?:^|[^a-z])eval\s*\([^)]*(?:input|req|user|param|arg|query)/im,
628
- category: 'injection',
629
- },
630
- {
631
- id: 'HARDCODED_SECRET',
632
- title: 'Potential hardcoded secret',
633
- severity: 'medium',
634
- pattern: /(?:api[_-]?key|password|secret|token)\s*[:=]\s*['"][A-Za-z0-9+/=_-]{16,}['"]/i,
635
- category: 'secrets',
636
- },
637
- {
638
- id: 'SSL_DISABLED',
639
- title: 'SSL/TLS verification disabled',
640
- severity: 'medium',
641
- pattern: /(?:rejectUnauthorized\s*:\s*false|verify\s*=\s*False|VERIFY_SSL\s*=\s*false|NODE_TLS_REJECT_UNAUTHORIZED|InsecureRequestWarning)/i,
642
- category: 'crypto',
643
- },
644
- {
645
- id: 'PATH_TRAVERSAL',
646
- title: 'Potential path traversal',
647
- severity: 'medium',
648
- pattern: /(?:\.\.\/|\.\.\\|path\.join|os\.path\.join)\s*\([^)]*(?:input|req|user|param|arg|query)/i,
649
- category: 'filesystem',
650
- },
651
- {
652
- id: 'CORS_WILDCARD',
653
- title: 'Wildcard CORS origin',
654
- severity: 'low',
655
- pattern: /(?:Access-Control-Allow-Origin|cors)\s*[:({]\s*['"]\*/i,
656
- category: 'network',
657
- },
658
- {
659
- id: 'TELEMETRY',
660
- title: 'Undisclosed telemetry',
661
- severity: 'low',
662
- pattern: /(?:posthog|mixpanel|analytics|telemetry|tracking|sentry).*(?:init|setup|track|capture)/i,
663
- category: 'privacy',
664
- },
665
- {
666
- id: 'SHELL_EXEC',
667
- title: 'Shell command execution',
668
- severity: 'high',
669
- pattern: /(?:subprocess\.(?:run|call|Popen)|os\.system|os\.popen|execSync|child_process\.exec)\s*\(/i,
670
- category: 'injection',
671
- },
672
- {
673
- id: 'SQL_INJECTION',
674
- title: 'Potential SQL injection',
675
- severity: 'high',
676
- pattern: /(?:execute|query|raw)\s*\(\s*(?:f['"]|['"].*?%s|['"].*?\{|['"].*?\+)/i,
677
- category: 'injection',
678
- },
679
- {
680
- id: 'YAML_UNSAFE',
681
- title: 'Unsafe YAML loading',
682
- severity: 'medium',
683
- pattern: /yaml\.(?:load|unsafe_load)\s*\(/i,
684
- category: 'deserialization',
685
- },
686
- {
687
- id: 'PICKLE_LOAD',
688
- title: 'Unsafe deserialization (pickle)',
689
- severity: 'high',
690
- pattern: /pickle\.loads?\s*\(/i,
691
- category: 'deserialization',
692
- },
693
- {
694
- id: 'PROMPT_INJECTION',
695
- title: 'Prompt injection vector',
696
- severity: 'high',
697
- pattern: /(?:<IMPORTANT>|<SYSTEM>|ignore previous|you are now|new instructions)/i,
698
- category: 'prompt-injection',
699
- },
700
- ];
701
-
702
- for (const file of files) {
703
- for (const check of checks) {
704
- const match = check.pattern.exec(file.content);
705
- if (match) {
706
- // Find line number
707
- const lines = file.content.slice(0, match.index).split('\n');
708
- findings.push({
709
- ...check,
710
- file: file.path,
711
- line: lines.length,
712
- snippet: match[0].trim().slice(0, 80),
713
- confidence: 'medium',
714
- });
715
- }
716
- }
717
- }
718
-
719
- return findings;
720
- }
721
-
722
- // ── Registry check ──────────────────────────────────────
723
-
724
- async function checkRegistry(slug) {
725
- try {
726
- const res = await fetch(`${REGISTRY_URL}/api/skills/${encodeURIComponent(slug)}`, {
727
- signal: AbortSignal.timeout(5000),
728
- });
729
- if (res.ok) return await res.json();
730
- } catch {}
731
- return null;
732
- }
733
-
734
- // ── Print results ───────────────────────────────────────
735
-
736
- function printScanResult(url, info, files, findings, registryData, duration) {
737
- if (jsonMode) return; // JSON mode handles output separately
738
-
739
- const slug = slugFromUrl(url);
740
-
741
- // Quiet mode: compact one-line-per-package output
742
- if (quietMode) {
743
- if (findings.length > 0) {
744
- const bySev = {};
745
- for (const f of findings) { bySev[f.severity] = (bySev[f.severity] || 0) + 1; }
746
- const sevStr = Object.entries(bySev).map(([s, n]) => {
747
- const sc = severityColor(s);
748
- return `${sc}${n} ${s}${c.reset}`;
749
- }).join(', ');
750
- console.log(`${icons.caution} ${c.bold}${slug}${c.reset} ${findings.length} findings (${sevStr}) ${c.dim}${duration}${c.reset}`);
751
- for (const f of findings) {
752
- const sc = severityColor(f.severity);
753
- console.log(` ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title} ${c.dim}${f.file}:${f.line}${c.reset}`);
754
- }
755
- } else {
756
- console.log(`${icons.safe} ${c.bold}${slug}${c.reset} ${c.green}clean${c.reset} ${c.dim}${files.length} files, ${duration}${c.reset}`);
757
- }
758
- return;
759
- }
760
-
761
- // Header
762
- console.log(`${icons.scan} ${c.bold}${slug}${c.reset} ${c.dim}${url}${c.reset}`);
763
- console.log(`${icons.pipe} ${c.dim}${info.language} ${info.type}${c.reset} ${c.dim}${files.length} files scanned in ${duration}${c.reset}`);
764
-
765
- // Tools & prompts tree
766
- const items = [
767
- ...info.tools.map(t => ({ kind: 'tool', name: t })),
768
- ...info.prompts.map(p => ({ kind: 'prompt', name: p })),
769
- ];
770
-
771
- if (items.length > 0) {
772
- console.log(`${icons.pipe}`);
773
- for (let i = 0; i < items.length; i++) {
774
- const isLast = i === items.length - 1 && findings.length === 0;
775
- const branch = isLast ? icons.treeLast : icons.tree;
776
- const item = items[i];
777
- const kindLabel = item.kind === 'tool' ? `${c.dim}tool${c.reset} ` : `${c.dim}prompt${c.reset}`;
778
- const padName = item.name.padEnd(28);
779
-
780
- // Check if this tool has a finding associated
781
- const toolFinding = findings.find(f =>
782
- f.snippet && f.snippet.toLowerCase().includes(item.name.toLowerCase())
783
- );
784
-
785
- if (toolFinding) {
786
- const sc = severityColor(toolFinding.severity);
787
- console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${sc}⚠ flagged${c.reset}${toolFinding.title}`);
788
- } else {
789
- console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${c.green}✔ ok${c.reset}`);
790
- }
791
- }
792
- } else {
793
- console.log(`${icons.pipe} ${c.dim}(no tools or prompts detected)${c.reset}`);
794
- }
795
-
796
- // Findings
797
- if (findings.length > 0) {
798
- console.log(`${icons.pipe}`);
799
- console.log(`${icons.pipe} ${c.bold}Findings (${findings.length})${c.reset} ${c.dim}static analysis may include false positives${c.reset}`);
800
- for (let i = 0; i < findings.length; i++) {
801
- const f = findings[i];
802
- const isLast = i === findings.length - 1;
803
- const branch = isLast ? icons.treeLast : icons.tree;
804
- const pipeOrSpace = isLast ? ' ' : `${icons.pipe} `;
805
- const sc = severityColor(f.severity);
806
- console.log(`${branch} ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
807
- console.log(`${pipeOrSpace} ${c.dim}${f.file}:${f.line}${c.reset} ${c.dim}${f.snippet || ''}${c.reset}`);
808
- }
809
- }
810
-
811
- // Registry status
812
- console.log(`${icons.pipe}`);
813
- if (registryData) {
814
- const rd = registryData;
815
- const riskScore = rd.risk_score ?? rd.latest_risk_score ?? 0;
816
- console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${riskBadge(riskScore)} Risk ${riskScore} ${c.dim}${REGISTRY_URL}/skills/${slug}${c.reset}`);
817
- } else {
818
- console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${c.dim}not audited yet${c.reset}`);
819
- }
820
-
821
- console.log();
822
- }
823
-
824
- function printSummary(results) {
825
- const total = results.length;
826
- const safe = results.filter(r => r.findings.length === 0).length;
827
- const withFindings = total - safe;
828
- const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
829
-
830
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
831
- console.log(` ${c.bold}Summary${c.reset} ${total} packages scanned`);
832
- console.log();
833
- if (safe > 0) console.log(` ${icons.safe} ${c.green}${safe} clean${c.reset}`);
834
- if (withFindings > 0) console.log(` ${icons.caution} ${c.yellow}${withFindings} with findings${c.reset} (${totalFindings} total)`);
835
-
836
- // Breakdown by severity
837
- const bySev = {};
838
- results.forEach(r => r.findings.forEach(f => {
839
- bySev[f.severity] = (bySev[f.severity] || 0) + 1;
840
- }));
841
- if (Object.keys(bySev).length > 0) {
842
- console.log();
843
- for (const sev of ['critical', 'high', 'medium', 'low']) {
844
- if (bySev[sev]) {
845
- console.log(` ${severityIcon(sev)} ${bySev[sev]}× ${severityColor(sev)}${sev}${c.reset}`);
846
- }
847
- }
848
- }
849
-
850
- console.log();
851
- }
852
-
853
- // ── Clone & Scan ────────────────────────────────────────
854
-
855
- async function scanRepo(url) {
856
- const start = Date.now();
857
- const slug = slugFromUrl(url);
858
-
859
- if (!jsonMode) process.stdout.write(`${icons.scan} Scanning ${c.bold}${slug}${c.reset} ${c.dim}...${c.reset}`);
860
-
861
- // Clone
862
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
863
- const repoPath = path.join(tmpDir, 'repo');
864
- try {
865
- execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
866
- timeout: 30_000,
867
- stdio: 'pipe',
868
- });
869
- } catch (err) {
870
- if (!jsonMode) {
871
- process.stdout.write(` ${c.red}✖ clone failed${c.reset}\n`);
872
- const msg = err.stderr?.toString().trim() || err.message?.split('\n')[0] || '';
873
- if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
874
- console.log(` ${c.dim}Make sure git is installed and the URL is accessible.${c.reset}`);
875
- }
876
- return null;
877
- }
878
-
879
- // Collect files
880
- const files = collectFiles(repoPath);
881
-
882
- // Detect info
883
- const info = detectPackageInfo(repoPath, files);
884
-
885
- // Quick checks
886
- const findings = quickChecks(files);
887
-
888
- // Registry lookup
889
- const registryData = await checkRegistry(slug);
890
-
891
- // Cleanup
892
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
893
-
894
- const duration = elapsed(start);
895
-
896
- if (!jsonMode) {
897
- // Clear the "Scanning..." line
898
- process.stdout.write('\r\x1b[K');
899
-
900
- // Print result
901
- printScanResult(url, info, files, findings, registryData, duration);
902
- }
903
-
904
- return { slug, url, info, files: files.length, findings, registryData, duration };
905
- }
906
-
907
- // ── Discover local MCP configs ──────────────────────────
908
-
909
- function findMcpConfigs() {
910
- const home = process.env.HOME || process.env.USERPROFILE || '';
911
- const platform = process.platform;
912
-
913
- // All known MCP config locations
914
- const candidates = [
915
- // Claude Desktop
916
- { name: 'Claude Desktop', path: path.join(home, '.claude', 'mcp.json') },
917
- { name: 'Claude Desktop', path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') },
918
- { name: 'Claude Desktop', path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') },
919
- { name: 'Claude Desktop', path: path.join(home, '.config', 'claude', 'claude_desktop_config.json') },
920
- // Cursor
921
- { name: 'Cursor', path: path.join(home, '.cursor', 'mcp.json') },
922
- // Windsurf / Codeium
923
- { name: 'Windsurf', path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json') },
924
- // VS Code
925
- { name: 'VS Code', path: path.join(home, '.vscode', 'mcp.json') },
926
- // Continue.dev
927
- { name: 'Continue', path: path.join(home, '.continue', 'config.json') },
928
- ];
929
-
930
- // Also check AGENTAUDIT_TEST_CONFIG env for testing
931
- if (process.env.AGENTAUDIT_TEST_CONFIG) {
932
- candidates.push({ name: 'Test Config', path: process.env.AGENTAUDIT_TEST_CONFIG });
933
- }
934
-
935
- // Also scan workspace .cursor/mcp.json, .vscode/mcp.json in cwd
936
- const cwd = process.cwd();
937
- candidates.push(
938
- { name: 'Cursor (project)', path: path.join(cwd, '.cursor', 'mcp.json') },
939
- { name: 'VS Code (project)', path: path.join(cwd, '.vscode', 'mcp.json') },
940
- );
941
-
942
- const found = [];
943
- for (const c of candidates) {
944
- if (fs.existsSync(c.path)) {
945
- try {
946
- const content = JSON.parse(fs.readFileSync(c.path, 'utf8'));
947
- found.push({ ...c, content });
948
- } catch {}
949
- }
950
- }
951
- return found;
952
- }
953
-
954
- function extractServersFromConfig(config) {
955
- // Handle both { mcpServers: {...} } and { servers: {...} } formats
956
- const servers = config.mcpServers || config.servers || {};
957
- const result = [];
958
-
959
- for (const [name, serverConfig] of Object.entries(servers)) {
960
- const info = {
961
- name,
962
- command: serverConfig.command || null,
963
- args: serverConfig.args || [],
964
- url: serverConfig.url || null,
965
- sourceUrl: null,
966
- };
967
-
968
- // Try to extract source URL from args (common patterns)
969
- const allArgs = [info.command, ...info.args].filter(Boolean).join(' ');
970
-
971
- // npx package-name → npm package
972
- const npxMatch = allArgs.match(/npx\s+(?:-y\s+)?(@?[a-z0-9][\w./-]*)/i);
973
- if (npxMatch) info.npmPackage = npxMatch[1];
974
-
975
- // node /path/to/something → try to find package.json
976
- const nodePathMatch = allArgs.match(/node\s+["']?([^"'\s]+)/);
977
- if (nodePathMatch) {
978
- const scriptPath = nodePathMatch[1];
979
- // Walk up to find package.json with repository
980
- let dir = path.dirname(path.resolve(scriptPath));
981
- for (let i = 0; i < 5; i++) {
982
- const pkgPath = path.join(dir, 'package.json');
983
- if (fs.existsSync(pkgPath)) {
984
- try {
985
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
986
- if (pkg.repository?.url) {
987
- info.sourceUrl = pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
988
- }
989
- if (pkg.name) info.npmPackage = pkg.name;
990
- } catch {}
991
- break;
992
- }
993
- const parent = path.dirname(dir);
994
- if (parent === dir) break;
995
- dir = parent;
996
- }
997
- }
998
-
999
- // python/uvx with package name
1000
- const pyMatch = allArgs.match(/(?:uvx|pip run|python -m)\s+(@?[a-z0-9][\w./-]*)/i);
1001
- if (pyMatch) info.pyPackage = pyMatch[1];
1002
-
1003
- // URL-based MCP server (remote HTTP)
1004
- if (info.url && !info.npmPackage && !info.pyPackage) {
1005
- try {
1006
- const parsed = new URL(info.url);
1007
- // Extract service name from hostname: mcp.supabase.com supabase
1008
- const hostParts = parsed.hostname.split('.');
1009
- if (hostParts.length >= 2) {
1010
- const serviceName = hostParts.length === 3 ? hostParts[1] : hostParts[0];
1011
- info.remoteService = serviceName;
1012
- }
1013
- } catch {}
1014
- }
1015
-
1016
- result.push(info);
1017
- }
1018
- return result;
1019
- }
1020
-
1021
- function serverSlug(server) {
1022
- // Try to derive a slug for registry lookup
1023
- if (server.npmPackage) return server.npmPackage.replace(/^@/, '').replace(/\//g, '-');
1024
- if (server.pyPackage) return server.pyPackage.replace(/[^a-z0-9-]/gi, '-');
1025
- return server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
1026
- }
1027
-
1028
- async function searchGitHub(query) {
1029
- try {
1030
- const res = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=1`, {
1031
- signal: AbortSignal.timeout(5000),
1032
- headers: { 'Accept': 'application/vnd.github+json' },
1033
- });
1034
- if (res.ok) {
1035
- const data = await res.json();
1036
- if (data.items?.length > 0) {
1037
- return data.items[0].html_url;
1038
- }
1039
- }
1040
- } catch {}
1041
- return null;
1042
- }
1043
-
1044
- async function resolveSourceUrl(server) {
1045
- // Already have it
1046
- if (server.sourceUrl) return server.sourceUrl;
1047
-
1048
- // Try npm registry
1049
- if (server.npmPackage) {
1050
- try {
1051
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(server.npmPackage)}`, {
1052
- signal: AbortSignal.timeout(5000),
1053
- });
1054
- if (res.ok) {
1055
- const data = await res.json();
1056
- let repoUrl = data.repository?.url;
1057
- if (repoUrl) {
1058
- repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
1059
- if (repoUrl.startsWith('http')) return repoUrl;
1060
- }
1061
- }
1062
- } catch {}
1063
- // Fallback: try GitHub search for the package name
1064
- const ghUrl = await searchGitHub(server.npmPackage);
1065
- if (ghUrl) return ghUrl;
1066
- return `https://www.npmjs.com/package/${server.npmPackage}`;
1067
- }
1068
-
1069
- // Try PyPI
1070
- if (server.pyPackage) {
1071
- try {
1072
- const res = await fetch(`https://pypi.org/pypi/${encodeURIComponent(server.pyPackage)}/json`, {
1073
- signal: AbortSignal.timeout(5000),
1074
- });
1075
- if (res.ok) {
1076
- const data = await res.json();
1077
- const urls = data.info?.project_urls || {};
1078
- const source = urls.Source || urls.Repository || urls.Homepage || urls['Source Code'] || data.info?.home_page;
1079
- if (source && source.startsWith('http')) return source;
1080
- }
1081
- } catch {}
1082
- // Fallback: GitHub search
1083
- const ghUrl = await searchGitHub(server.pyPackage);
1084
- if (ghUrl) return ghUrl;
1085
- return `https://pypi.org/project/${server.pyPackage}/`;
1086
- }
1087
-
1088
- // URL-based remote MCP server — try GitHub search by service name
1089
- if (server.remoteService) {
1090
- // Try npm registry with common MCP naming patterns
1091
- for (const tryName of [
1092
- `@${server.remoteService}/mcp-server-${server.remoteService}`,
1093
- `${server.remoteService}-mcp`,
1094
- `mcp-server-${server.remoteService}`,
1095
- server.remoteService,
1096
- ]) {
1097
- try {
1098
- const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(tryName)}`, {
1099
- signal: AbortSignal.timeout(3000),
1100
- });
1101
- if (res.ok) {
1102
- const data = await res.json();
1103
- let repoUrl = data.repository?.url;
1104
- if (repoUrl) {
1105
- repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
1106
- if (repoUrl.startsWith('http')) return repoUrl;
1107
- }
1108
- }
1109
- } catch {}
1110
- }
1111
- }
1112
-
1113
- // Last resort: if server has a url, show it as context
1114
- if (server.url) {
1115
- try {
1116
- const parsed = new URL(server.url);
1117
- return `https://github.com/search?q=${encodeURIComponent(parsed.hostname + ' MCP')}&type=repositories`;
1118
- } catch {}
1119
- }
1120
-
1121
- return null;
1122
- }
1123
-
1124
- async function discoverCommand(options = {}) {
1125
- const autoScan = options.scan || false;
1126
- const interactiveAudit = options.audit || false;
1127
-
1128
- if (!jsonMode) {
1129
- console.log(` ${c.bold}Discovering MCP servers in your AI editors...${c.reset}`);
1130
- console.log();
1131
- }
1132
-
1133
- const configs = findMcpConfigs();
1134
-
1135
- if (configs.length === 0) {
1136
- console.log(` ${c.yellow}No MCP configurations found.${c.reset}`);
1137
- console.log(` ${c.dim}Searched: Claude Desktop, Cursor, Windsurf, VS Code${c.reset}`);
1138
- console.log();
1139
- console.log(` ${c.dim}MCP config locations:${c.reset}`);
1140
- console.log(` ${c.dim} Claude: ~/.claude/mcp.json${c.reset}`);
1141
- console.log(` ${c.dim} Cursor: ~/.cursor/mcp.json${c.reset}`);
1142
- console.log(` ${c.dim} Windsurf: ~/.codeium/windsurf/mcp_config.json${c.reset}`);
1143
- console.log(` ${c.dim} VS Code: ~/.vscode/mcp.json${c.reset}`);
1144
- console.log();
1145
- return;
1146
- }
1147
-
1148
- let totalServers = 0;
1149
- let checkedServers = 0;
1150
- let auditedServers = 0;
1151
- let unauditedServers = 0;
1152
- const unauditedWithUrls = [];
1153
- const allServersWithUrls = []; // For --scan: all servers we can scan
1154
-
1155
- for (const config of configs) {
1156
- const servers = extractServersFromConfig(config.content);
1157
- const serverCount = servers.length;
1158
- totalServers += serverCount;
1159
-
1160
- const countLabel = serverCount === 0
1161
- ? `${c.dim}no servers${c.reset}`
1162
- : `found ${c.bold}${serverCount}${c.reset} server${serverCount > 1 ? 's' : ''}`;
1163
-
1164
- console.log(`${icons.bullet} Scanning ${c.bold}${config.name}${c.reset} ${c.dim}${config.path}${c.reset} ${countLabel}`);
1165
-
1166
- if (serverCount === 0) {
1167
- console.log();
1168
- continue;
1169
- }
1170
-
1171
- console.log();
1172
-
1173
- for (let i = 0; i < servers.length; i++) {
1174
- const server = servers[i];
1175
- const isLast = i === servers.length - 1;
1176
- const branch = isLast ? icons.treeLast : icons.tree;
1177
- const pipe = isLast ? ' ' : `${icons.pipe} `;
1178
-
1179
- const slug = serverSlug(server);
1180
- checkedServers++;
1181
-
1182
- // Registry lookup
1183
- const registryData = await checkRegistry(slug);
1184
-
1185
- // Also try with server name directly
1186
- let regData = registryData;
1187
- if (!regData && slug !== server.name.toLowerCase()) {
1188
- regData = await checkRegistry(server.name.toLowerCase());
1189
- }
1190
-
1191
- // Determine source display
1192
- let sourceLabel = '';
1193
- if (server.npmPackage) sourceLabel = `${c.dim}npm:${server.npmPackage}${c.reset}`;
1194
- else if (server.pyPackage) sourceLabel = `${c.dim}pip:${server.pyPackage}${c.reset}`;
1195
- else if (server.url) sourceLabel = `${c.dim}${server.url.length > 60 ? server.url.slice(0, 57) + '...' : server.url}${c.reset}`;
1196
- else if (server.command) sourceLabel = `${c.dim}${[server.command, ...server.args.slice(0, 2)].join(' ')}${c.reset}`;
1197
-
1198
- // Always resolve source URL (needed for --scan)
1199
- const resolvedUrl = await resolveSourceUrl(server);
1200
-
1201
- if (regData) {
1202
- auditedServers++;
1203
- const riskScore = regData.risk_score ?? regData.latest_risk_score ?? 0;
1204
- const hasOfficial = regData.has_official_audit;
1205
- console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1206
- console.log(`${pipe} ${riskBadge(riskScore)} Risk ${riskScore} ${hasOfficial ? `${c.green}✔ official${c.reset} ` : ''}${c.dim}${REGISTRY_URL}/skills/${slug}${c.reset}`);
1207
- if (resolvedUrl) allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: true, regData });
1208
- } else {
1209
- unauditedServers++;
1210
- console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1211
- if (resolvedUrl) {
1212
- console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Run: ${c.cyan}agentaudit audit ${resolvedUrl}${c.reset}`);
1213
- unauditedWithUrls.push({ name: server.name, sourceUrl: resolvedUrl });
1214
- allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: false });
1215
- } else {
1216
- console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Source URL unknown — check the package's GitHub/npm page${c.reset}`);
1217
- }
1218
- }
1219
-
1220
- if (server.sourceUrl && !server.sourceUrl.includes('npmjs.com')) {
1221
- console.log(`${pipe} ${c.dim}source: ${server.sourceUrl}${c.reset}`);
1222
- }
1223
- }
1224
-
1225
- console.log();
1226
- }
1227
-
1228
- // Summary
1229
- console.log(`${c.dim}${''.repeat(60)}${c.reset}`);
1230
- console.log(` ${c.bold}Summary${c.reset} ${totalServers} server${totalServers !== 1 ? 's' : ''} across ${configs.length} config${configs.length !== 1 ? 's' : ''}`);
1231
- console.log();
1232
- if (auditedServers > 0) console.log(` ${icons.safe} ${c.green}${auditedServers} audited${c.reset}`);
1233
- if (unauditedServers > 0) console.log(` ${icons.caution} ${c.yellow}${unauditedServers} not audited${c.reset}`);
1234
- console.log();
1235
-
1236
- // --scan: automatically scan all servers with resolved source URLs (git-cloneable only)
1237
- if (autoScan) {
1238
- const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1239
- const scanTargets = allServersWithUrls.filter(s => s.sourceUrl && isCloneable(s.sourceUrl));
1240
- // Deduplicate by sourceUrl
1241
- const seen = new Set();
1242
- const dedupedTargets = scanTargets.filter(s => {
1243
- if (seen.has(s.sourceUrl)) return false;
1244
- seen.add(s.sourceUrl);
1245
- return true;
1246
- });
1247
- const skipped = allServersWithUrls.filter(s => s.sourceUrl && !isCloneable(s.sourceUrl));
1248
- if (dedupedTargets.length > 0) {
1249
- console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1250
- console.log(` ${c.bold}${icons.scan} Auto-scanning ${dedupedTargets.length} server${dedupedTargets.length !== 1 ? 's' : ''}...${c.reset}`);
1251
- if (skipped.length > 0) {
1252
- console.log(` ${c.dim}(${skipped.length} skipped — no cloneable source URL)${c.reset}`);
1253
- }
1254
- console.log();
1255
-
1256
- const scanResults = [];
1257
- for (const target of dedupedTargets) {
1258
- const result = await scanRepo(target.sourceUrl);
1259
- if (result) scanResults.push({ ...result, serverName: target.name });
1260
- }
1261
-
1262
- if (scanResults.length > 1) {
1263
- // Print combined scan summary
1264
- console.log(`${c.dim}${''.repeat(60)}${c.reset}`);
1265
- console.log(` ${c.bold}Scan Summary${c.reset} ${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} scanned`);
1266
- console.log();
1267
-
1268
- let totalFindings = 0;
1269
- let serversWithFindings = 0;
1270
-
1271
- for (const r of scanResults) {
1272
- const findingCount = r.findings ? r.findings.length : 0;
1273
- totalFindings += findingCount;
1274
- if (findingCount > 0) serversWithFindings++;
1275
-
1276
- const status = findingCount === 0
1277
- ? `${icons.safe} ${c.green}clean${c.reset}`
1278
- : `${icons.caution} ${c.yellow}${findingCount} finding${findingCount !== 1 ? 's' : ''}${c.reset}`;
1279
- console.log(` ${status} ${c.bold}${r.serverName || r.slug}${c.reset} ${c.dim}(${r.duration})${c.reset}`);
1280
- }
1281
-
1282
- console.log();
1283
- if (serversWithFindings > 0) {
1284
- console.log(` ${c.yellow}${serversWithFindings}/${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} with findings (${totalFindings} total)${c.reset}`);
1285
- console.log(` ${c.dim}Run ${c.cyan}agentaudit scan <url> --deep${c.dim} for deep LLM analysis on flagged servers${c.reset}`);
1286
- } else {
1287
- console.log(` ${c.green}All servers passed quick scan${c.reset}`);
1288
- console.log(` ${c.dim}Run ${c.cyan}agentaudit scan <url> --deep${c.dim} for thorough LLM-powered analysis${c.reset}`);
1289
- }
1290
- console.log();
1291
- }
1292
- } else {
1293
- console.log(` ${c.dim}No scannable source URLs found.${c.reset}`);
1294
- console.log();
1295
- }
1296
- } else if (interactiveAudit && allServersWithUrls.length > 0) {
1297
- // Interactive multi-select for audit
1298
- const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1299
- const auditCandidates = [];
1300
- const seen = new Set();
1301
- for (const s of allServersWithUrls) {
1302
- if (!s.sourceUrl || !isCloneable(s.sourceUrl)) continue;
1303
- if (seen.has(s.sourceUrl)) continue;
1304
- seen.add(s.sourceUrl);
1305
- auditCandidates.push(s);
1306
- }
1307
-
1308
- if (auditCandidates.length > 0) {
1309
- console.log();
1310
- const items = auditCandidates.map(s => ({
1311
- label: s.name,
1312
- sublabel: s.hasAudit ? `${c.green}✔ audited${c.reset} ${s.sourceUrl}` : s.sourceUrl,
1313
- value: s,
1314
- checked: !s.hasAudit, // Pre-select unaudited
1315
- }));
1316
-
1317
- const selected = await multiSelect(items, {
1318
- title: 'Select servers to audit',
1319
- hint: 'Space=toggle ↑↓=move a=all n=none Enter=confirm',
1320
- });
1321
-
1322
- if (selected.length > 0) {
1323
- console.log();
1324
- console.log(` ${c.bold}Auditing ${selected.length} server${selected.length !== 1 ? 's' : ''}...${c.reset}`);
1325
- console.log();
1326
- for (const s of selected) {
1327
- await auditRepo(s.sourceUrl);
1328
- console.log();
1329
- }
1330
- } else {
1331
- console.log();
1332
- console.log(` ${c.dim}No servers selected.${c.reset}`);
1333
- }
1334
- }
1335
- } else if (unauditedServers > 0) {
1336
- if (unauditedWithUrls.length > 0) {
1337
- console.log(` ${c.dim}To audit unaudited servers:${c.reset}`);
1338
- for (const { name, sourceUrl } of unauditedWithUrls) {
1339
- console.log(` ${c.cyan}agentaudit audit ${sourceUrl}${c.reset} ${c.dim}(${name})${c.reset}`);
1340
- }
1341
- } else {
1342
- console.log(` ${c.dim}To audit unaudited servers, run:${c.reset}`);
1343
- console.log(` ${c.cyan}agentaudit audit <source-url>${c.reset}`);
1344
- }
1345
- console.log();
1346
- console.log(` ${c.dim}Or run ${c.cyan}agentaudit discover --quick${c.dim} to quick-scan all servers${c.reset}`);
1347
- console.log(` ${c.dim}Or run ${c.cyan}agentaudit discover --deep${c.dim} to select & deep-audit interactively${c.reset}`);
1348
- console.log();
1349
- }
1350
-
1351
- if (!autoScan && !interactiveAudit && !jsonMode) {
1352
- console.log(` ${c.dim}Looking for general package scanning? Try ${c.cyan}pip audit${c.dim} or ${c.cyan}npm audit${c.dim}.${c.reset}`);
1353
- console.log();
1354
- }
1355
- }
1356
-
1357
- // ── Audit command (deep LLM-powered) ────────────────────
1358
-
1359
- function loadAuditPrompt() {
1360
- const promptPath = path.join(SKILL_DIR, 'prompts', 'audit-prompt.md');
1361
- if (fs.existsSync(promptPath)) return fs.readFileSync(promptPath, 'utf8');
1362
- return null;
1363
- }
1364
-
1365
- async function auditRepo(url) {
1366
- const start = Date.now();
1367
- const slug = slugFromUrl(url);
1368
-
1369
- console.log(`${icons.scan} ${c.bold}Auditing ${slug}${c.reset} ${c.dim}${url}${c.reset}`);
1370
- console.log(`${icons.pipe} ${c.dim}Deep LLM-powered analysis (3-pass: UNDERSTAND → DETECT → CLASSIFY)${c.reset}`);
1371
- console.log();
1372
-
1373
- // Step 1: Clone
1374
- process.stdout.write(` ${c.dim}[1/4]${c.reset} Cloning repository...`);
1375
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
1376
- const repoPath = path.join(tmpDir, 'repo');
1377
- try {
1378
- execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
1379
- timeout: 30_000, stdio: 'pipe',
1380
- });
1381
- console.log(` ${c.green}done${c.reset}`);
1382
- } catch (err) {
1383
- console.log(` ${c.red}failed${c.reset}`);
1384
- const msg = err.stderr?.toString().trim() || err.message?.split('\n')[0] || '';
1385
- if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
1386
- console.log(` ${c.dim}Make sure git is installed and the URL is accessible.${c.reset}`);
1387
- return null;
1388
- }
1389
-
1390
- // Step 2: Collect files
1391
- process.stdout.write(` ${c.dim}[2/4]${c.reset} Collecting source files...`);
1392
- const files = collectFiles(repoPath);
1393
- console.log(` ${c.green}${files.length} files${c.reset}`);
1394
-
1395
- // Step 3: Build audit payload
1396
- process.stdout.write(` ${c.dim}[3/4]${c.reset} Preparing audit payload...`);
1397
- const auditPrompt = loadAuditPrompt();
1398
-
1399
- let codeBlock = '';
1400
- for (const file of files) {
1401
- codeBlock += `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
1402
- }
1403
- console.log(` ${c.green}done${c.reset}`);
1404
-
1405
- // Step 4: LLM Analysis
1406
- // Check for API keys to determine which LLM to use
1407
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
1408
- const openaiKey = process.env.OPENAI_API_KEY;
1409
- const openrouterKey = process.env.OPENROUTER_API_KEY;
1410
- const openrouterModel = process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
1411
-
1412
- // --provider flag overrides auto-detection
1413
- const providerFlag = process.argv.find(a => a.startsWith('--provider='))?.split('=')[1]?.toLowerCase()
1414
- || (process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1]?.toLowerCase() : null);
1415
-
1416
- const resolvedProvider = resolveProvider(providerFlag, { anthropicKey, openaiKey, openrouterKey });
1417
- const activeProvider = resolvedProvider?.label || null;
1418
-
1419
- if (!resolvedProvider) {
1420
- // No LLM API key clear explanation
1421
- console.log();
1422
- console.log(` ${c.yellow}No LLM provider configured.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
1423
- console.log();
1424
- console.log(` ${c.bold}Option 1: Set an API key${c.reset} ${c.dim}(any one of these)${c.reset}`);
1425
- console.log(` ${c.cyan}ANTHROPIC_API_KEY${c.reset} Anthropic Claude ${c.dim}(recommended)${c.reset}`);
1426
- console.log(` ${c.cyan}OPENAI_API_KEY${c.reset} OpenAI GPT-4o`);
1427
- console.log(` ${c.cyan}OPENROUTER_API_KEY${c.reset} OpenRouter ${c.dim}(200+ models)${c.reset}`);
1428
- console.log(` ${c.cyan}OLLAMA_MODEL${c.reset} Ollama ${c.dim}(local, free, set model name)${c.reset}`);
1429
- console.log(` ${c.cyan}LLM_API_URL${c.reset} Any OpenAI-compatible API ${c.dim}(+ LLM_API_KEY, LLM_MODEL)${c.reset}`);
1430
- console.log();
1431
- console.log(` ${c.dim}# Linux / macOS:${c.reset}`);
1432
- console.log(` ${c.dim}export ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1433
- console.log(` ${c.dim}export OPENAI_API_KEY=sk-...${c.reset}`);
1434
- console.log();
1435
- console.log(` ${c.dim}# Windows (PowerShell):${c.reset}`);
1436
- console.log(` ${c.dim}$env:ANTHROPIC_API_KEY = "sk-ant-..."${c.reset}`);
1437
- console.log(` ${c.dim}$env:OPENAI_API_KEY = "sk-..."${c.reset}`);
1438
- console.log();
1439
- console.log(` ${c.dim}# Windows (CMD):${c.reset}`);
1440
- console.log(` ${c.dim}set ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1441
- console.log(` ${c.dim}set OPENAI_API_KEY=sk-...${c.reset}`);
1442
- console.log();
1443
- console.log(` ${c.bold}Option 2: Export for manual review${c.reset}`);
1444
- console.log(` ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
1445
- console.log(` ${c.dim}Creates a markdown file you can paste into any LLM (Claude, ChatGPT, etc.)${c.reset}`);
1446
- console.log();
1447
- console.log(` ${c.bold}Option 3: Use MCP in Claude/Cursor/Windsurf (no API key needed)${c.reset}`);
1448
- console.log(` ${c.dim}Add AgentAudit as MCP server — your editor's agent runs the audit using its own LLM.${c.reset}`);
1449
- console.log(` ${c.dim}Config: { "mcpServers": { "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } } }${c.reset}`);
1450
- console.log();
1451
-
1452
- // Check if --export flag
1453
- if (process.argv.includes('--export')) {
1454
- const exportPath = path.join(process.cwd(), `audit-${slug}.md`);
1455
- const exportContent = [
1456
- `# Security Audit: ${slug}`,
1457
- `**Source:** ${url}`,
1458
- `**Files:** ${files.length}`,
1459
- ``,
1460
- `## Audit Instructions`,
1461
- ``,
1462
- auditPrompt || '(audit prompt not found)',
1463
- ``,
1464
- `## Report Format`,
1465
- ``,
1466
- `After analysis, produce a JSON report:`,
1467
- '```json',
1468
- `{ "skill_slug": "${slug}", "source_url": "${url}", "risk_score": 0, "result": "safe", "findings": [] }`,
1469
- '```',
1470
- ``,
1471
- `## Source Code`,
1472
- ``,
1473
- codeBlock,
1474
- ].join('\n');
1475
- fs.writeFileSync(exportPath, exportContent);
1476
- console.log(` ${icons.safe} Exported to ${c.bold}${exportPath}${c.reset}`);
1477
- console.log(` ${c.dim}Paste this into any LLM (Claude, ChatGPT, etc.) for analysis${c.reset}`);
1478
- }
1479
-
1480
- // Cleanup
1481
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1482
- return null;
1483
- }
1484
-
1485
- // Determine actual model name for display
1486
- let actualModel;
1487
- if (resolvedProvider.id === 'anthropic') {
1488
- actualModel = modelOverride || 'claude-sonnet-4-20250514';
1489
- } else if (resolvedProvider.id === 'openrouter') {
1490
- actualModel = modelOverride || process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
1491
- } else if (resolvedProvider.id === 'openai') {
1492
- actualModel = modelOverride || 'gpt-4o';
1493
- } else if (resolvedProvider.id === 'ollama') {
1494
- actualModel = modelOverride || resolvedProvider.model;
1495
- } else {
1496
- actualModel = modelOverride || resolvedProvider.model || 'unknown';
1497
- }
1498
-
1499
- // We have an API key — run LLM audit
1500
- process.stdout.write(` ${c.dim}[4/4]${c.reset} Running LLM analysis ${c.dim}(${resolvedProvider.id}: ${actualModel})${c.reset}...`);
1501
-
1502
- const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
1503
- const userMessage = [
1504
- `Audit this package: **${slug}** (${url})`,
1505
- ``,
1506
- `After analysis, respond with ONLY a valid JSON object. No markdown fences, no explanation, no text before or after. Just the raw JSON:`,
1507
- `{ "skill_slug": "${slug}", "source_url": "${url}", "package_type": "<mcp-server|agent-skill|library|cli-tool>",`,
1508
- ` "risk_score": <0-100>, "result": "<safe|caution|unsafe>", "max_severity": "<none|low|medium|high|critical>",`,
1509
- ` "findings_count": <n>, "findings": [{ "id": "...", "title": "...", "severity": "...", "category": "...",`,
1510
- ` "description": "...", "file": "...", "line": <n>, "remediation": "...", "confidence": "...", "is_by_design": false }] }`,
1511
- ``,
1512
- `## Source Code`,
1513
- codeBlock,
1514
- ].join('\n');
1515
-
1516
- let report = null;
1517
- let _lastLlmText = '';
1518
- let providerMeta = {}; // Collect provider metadata for attestation
1519
-
1520
- try {
1521
- if (resolvedProvider.id === 'anthropic') {
1522
- const res = await fetch('https://api.anthropic.com/v1/messages', {
1523
- method: 'POST',
1524
- headers: {
1525
- 'x-api-key': resolvedProvider.key,
1526
- 'anthropic-version': '2023-06-01',
1527
- 'content-type': 'application/json',
1528
- },
1529
- body: JSON.stringify({
1530
- model: modelOverride || 'claude-sonnet-4-20250514',
1531
- max_tokens: 8192,
1532
- system: systemPrompt,
1533
- messages: [{ role: 'user', content: userMessage }],
1534
- }),
1535
- signal: AbortSignal.timeout(120_000),
1536
- });
1537
- const data = await res.json();
1538
- if (data.error) {
1539
- console.log(` ${c.red}failed${c.reset}`);
1540
- console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1541
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1542
- return null;
1543
- }
1544
- const text = data.content?.[0]?.text || '';
1545
- _lastLlmText = text;
1546
- report = extractJSON(text);
1547
- providerMeta = {
1548
- provider_msg_id: data.id || null,
1549
- input_tokens: data.usage?.input_tokens || null,
1550
- output_tokens: data.usage?.output_tokens || null,
1551
- reported_model: data.model || null,
1552
- };
1553
- } else {
1554
- // OpenAI, OpenRouter, Ollama, or Custom (all use OpenAI-compatible chat completions API)
1555
- let apiUrl, modelName, authHeaders;
1556
- switch (resolvedProvider.id) {
1557
- case 'openrouter':
1558
- apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
1559
- modelName = modelOverride || process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
1560
- authHeaders = { 'Authorization': `Bearer ${resolvedProvider.key}`, 'HTTP-Referer': 'https://agentaudit.dev', 'X-Title': 'AgentAudit' };
1561
- break;
1562
- case 'ollama':
1563
- apiUrl = `${resolvedProvider.host}/v1/chat/completions`;
1564
- modelName = modelOverride || resolvedProvider.model;
1565
- authHeaders = {};
1566
- break;
1567
- case 'custom':
1568
- apiUrl = resolvedProvider.url.endsWith('/chat/completions') ? resolvedProvider.url : `${resolvedProvider.url.replace(/\/$/, '')}/chat/completions`;
1569
- modelName = modelOverride || resolvedProvider.model;
1570
- authHeaders = resolvedProvider.key ? { 'Authorization': `Bearer ${resolvedProvider.key}` } : {};
1571
- break;
1572
- default: // openai
1573
- apiUrl = 'https://api.openai.com/v1/chat/completions';
1574
- modelName = modelOverride || 'gpt-4o';
1575
- authHeaders = { 'Authorization': `Bearer ${resolvedProvider.key}` };
1576
- }
1577
-
1578
- const res = await fetch(apiUrl, {
1579
- method: 'POST',
1580
- headers: { 'Content-Type': 'application/json', ...authHeaders },
1581
- body: JSON.stringify({
1582
- model: modelName,
1583
- max_tokens: 8192,
1584
- messages: [
1585
- { role: 'system', content: systemPrompt },
1586
- { role: 'user', content: userMessage },
1587
- ],
1588
- }),
1589
- signal: AbortSignal.timeout(resolvedProvider.id === 'ollama' ? 300_000 : 120_000), // Ollama: 5min (local can be slow)
1590
- });
1591
- const data = await res.json();
1592
- if (data.error) {
1593
- console.log(` ${c.red}failed${c.reset}`);
1594
- console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1595
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1596
- return null;
1597
- }
1598
- const text = data.choices?.[0]?.message?.content || '';
1599
- _lastLlmText = text;
1600
- report = extractJSON(text);
1601
- providerMeta = {
1602
- provider_msg_id: data.id || null,
1603
- provider_fingerprint: data.system_fingerprint || null,
1604
- input_tokens: data.usage?.prompt_tokens || null,
1605
- output_tokens: data.usage?.completion_tokens || null,
1606
- reported_model: data.model || null,
1607
- };
1608
- }
1609
-
1610
- console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
1611
- } catch (err) {
1612
- console.log(` ${c.red}failed${c.reset}`);
1613
- console.log(` ${c.red}${err.message}${c.reset}`);
1614
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1615
- return null;
1616
- }
1617
-
1618
- // Cleanup repo
1619
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1620
-
1621
- if (!report) {
1622
- console.log(` ${c.red}Could not parse LLM response as JSON${c.reset}`);
1623
- console.log(` ${c.dim}Hint: run with --debug to see the raw LLM response${c.reset}`);
1624
- if (process.argv.includes('--debug')) {
1625
- console.log(` ${c.dim}--- Raw LLM response (first 2000 chars) ---${c.reset}`);
1626
- console.log((typeof _lastLlmText === 'string' ? _lastLlmText : '(empty)').slice(0, 2000));
1627
- console.log(` ${c.dim}--- end ---${c.reset}`);
1628
- }
1629
- return null;
1630
- }
1631
-
1632
- // Display results
1633
- console.log();
1634
- const riskScore = report.risk_score || 0;
1635
- const trustScore = 100 - riskScore;
1636
- const trustColor = trustScore >= 70 ? c.green : trustScore >= 40 ? c.yellow : c.red;
1637
- const trustLabel = trustScore >= 70 ? 'SAFE' : trustScore >= 40 ? 'CAUTION' : 'UNSAFE';
1638
- console.log(` ${trustColor}${c.bold}${trustLabel}${c.reset} ${trustColor}Trust Score: ${trustScore}/100${c.reset} ${c.dim}(Risk: ${riskScore}/100)${c.reset}`);
1639
- console.log(` ${c.dim}Model: ${resolvedProvider.id}/${actualModel} Duration: ${elapsed(start)}${c.reset}`);
1640
- console.log();
1641
-
1642
- if (report.findings && report.findings.length > 0) {
1643
- console.log(` ${c.bold}Findings (${report.findings.length})${c.reset}`);
1644
- console.log();
1645
- for (const f of report.findings) {
1646
- const sc = severityColor(f.severity);
1647
- console.log(` ${severityIcon(f.severity)} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
1648
- if (f.file) console.log(` ${c.dim}${f.file}${f.line ? ':' + f.line : ''}${c.reset}`);
1649
- if (f.description) console.log(` ${c.dim}${f.description.slice(0, 120)}${c.reset}`);
1650
- console.log();
1651
- }
1652
- } else {
1653
- console.log(` ${c.green}No findings — package looks clean.${c.reset}`);
1654
- console.log();
1655
- }
1656
-
1657
- // Upload to registry
1658
- const creds = loadCredentials();
1659
- if (creds) {
1660
- process.stdout.write(` Uploading report to registry...`);
1661
- try {
1662
- const res = await fetch(`${REGISTRY_URL}/api/reports`, {
1663
- method: 'POST',
1664
- headers: {
1665
- 'Authorization': `Bearer ${creds.api_key}`,
1666
- 'Content-Type': 'application/json',
1667
- },
1668
- body: JSON.stringify({
1669
- ...report,
1670
- audit_model: providerMeta.reported_model || actualModel,
1671
- audit_provider: resolvedProvider.id,
1672
- provider_msg_id: providerMeta.provider_msg_id || undefined,
1673
- provider_fingerprint: providerMeta.provider_fingerprint || undefined,
1674
- input_tokens: providerMeta.input_tokens || undefined,
1675
- output_tokens: providerMeta.output_tokens || undefined,
1676
- audit_duration_ms: Date.now() - start,
1677
- }),
1678
- signal: AbortSignal.timeout(15_000),
1679
- });
1680
- if (res.ok) {
1681
- const data = await res.json();
1682
- const reportSlug = data?.skill_slug || data?.slug || slug;
1683
- console.log(` ${c.green}done${c.reset}`);
1684
- console.log(` ${c.dim}Report: ${REGISTRY_URL}/skills/${reportSlug}${c.reset}`);
1685
- } else {
1686
- console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
1687
- }
1688
- } catch (err) {
1689
- console.log(` ${c.yellow}failed${c.reset}`);
1690
- }
1691
- } else {
1692
- console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to upload reports to the registry${c.reset}`);
1693
- }
1694
-
1695
- console.log();
1696
- return report;
1697
- }
1698
-
1699
- // ── Check command ───────────────────────────────────────
1700
-
1701
- async function checkPackage(name, { autoAudit = false } = {}) {
1702
- if (!jsonMode) {
1703
- console.log(`${icons.info} Looking up ${c.bold}${name}${c.reset} in registry...`);
1704
- console.log();
1705
- }
1706
-
1707
- const data = await checkRegistry(name);
1708
- if (!data) {
1709
- if (!jsonMode) {
1710
- // Auto-audit: only when called from 'check' command AND input looks like a URL
1711
- if (autoAudit && (name.includes('github.com') || name.includes('://'))) {
1712
- console.log(` ${c.yellow}Not found in registry.${c.reset}`);
1713
- console.log(` ${c.dim}Starting audit for ${name}...${c.reset}`);
1714
- console.log();
1715
- return await auditRepo(name);
1716
- }
1717
- console.log(` ${c.yellow}✖ Not found${c.reset} "${name}" hasn't been audited yet.`);
1718
- console.log();
1719
- console.log(` ${c.dim}Next steps:${c.reset}`);
1720
- console.log(` ${c.cyan}agentaudit check <repo-url>${c.reset} ${c.dim}Auto-lookup + audit if not found${c.reset}`);
1721
- console.log(` ${c.cyan}agentaudit audit <repo-url>${c.reset} ${c.dim}Deep LLM audit${c.reset}`);
1722
- console.log(` ${c.cyan}agentaudit scan <repo-url>${c.reset} ${c.dim}Quick static check (no API key)${c.reset}`);
1723
- }
1724
- return null;
1725
- }
1726
-
1727
- if (!jsonMode) {
1728
- const riskScore = data.risk_score ?? data.latest_risk_score ?? 0;
1729
- const trustScore = data.trust_score ?? (100 - riskScore);
1730
- const totalFindings = data.total_findings ?? 0;
1731
- const totalReports = data.total_reports ?? 0;
1732
-
1733
- // Package name + verdict
1734
- console.log(` ${c.bold}${data.display_name || name}${c.reset} ${riskBadge(riskScore)}`);
1735
- if (data.description) console.log(` ${c.dim}${data.description}${c.reset}`);
1736
- console.log();
1737
-
1738
- // Trust Score (the main metric)
1739
- const trustColor = trustScore >= 70 ? c.green : trustScore >= 40 ? c.yellow : c.red;
1740
- const trustLabel = trustScore >= 70 ? 'SAFE' : trustScore >= 40 ? 'CAUTION' : 'UNSAFE';
1741
- console.log(` ${trustColor}${c.bold}${trustLabel}${c.reset} ${trustColor}Trust Score: ${trustScore}/100${c.reset} ${c.dim}(Risk: ${riskScore}/100)${c.reset}`);
1742
-
1743
- // Findings summary
1744
- if (totalFindings > 0) {
1745
- const maxSev = data.latest_max_severity;
1746
- const sevStr = maxSev ? `max severity: ${severityColor(maxSev)}${maxSev}${c.reset}` : '';
1747
- console.log(` ${c.dim}Findings: ${totalFindings}${sevStr ? ` (${sevStr}${c.dim})` : ''}${c.reset}`);
1748
- } else {
1749
- console.log(` ${c.dim}Findings: 0 (clean)${c.reset}`);
1750
- }
1751
-
1752
- // Consensus / Confidence
1753
- const uniqueAgents = data.unique_agents ?? 0;
1754
- const confidence = data.confidence ?? 'unverified';
1755
- const confidenceDisplay = {
1756
- consensus: { icon: '🟢', label: 'Consensus Certified', color: c.green, desc: `${totalReports} reports from ${uniqueAgents} independent auditors agree` },
1757
- verified: { icon: '🟢', label: 'Verified', color: c.green, desc: `${totalReports} reports from ${uniqueAgents} auditors` },
1758
- low: { icon: '🟡', label: 'Low Confidence', color: c.yellow, desc: `${totalReports} reports but ${uniqueAgents <= 1 ? 'only 1 auditor' : `only ${uniqueAgents} auditors`}` },
1759
- unverified: { icon: '🔴', label: 'Unverified', color: c.yellow, desc: 'Single audit, no independent confirmation' },
1760
- }[confidence] || { icon: '', label: confidence, color: c.dim, desc: '' };
1761
- console.log(` ${confidenceDisplay.icon} ${confidenceDisplay.color}${confidenceDisplay.label}${c.reset} ${c.dim}${confidenceDisplay.desc}${c.reset}`);
1762
-
1763
- // Audit info
1764
- console.log(` ${c.dim}Reports: ${totalReports} | Auditors: ${uniqueAgents} | Last: ${data.last_audited_at ? new Date(data.last_audited_at).toLocaleDateString() : 'unknown'}${c.reset}`);
1765
- if (data.has_official_audit) console.log(` ${c.green}✔ Officially audited${c.reset}`);
1766
-
1767
- // Recommendation
1768
- if (confidence === 'unverified' && trustScore >= 70) {
1769
- console.log();
1770
- console.log(` ${c.yellow}⚠ Score looks good but only 1 audit exists.${c.reset}`);
1771
- console.log(` ${c.dim} Consider running your own audit: agentaudit audit ${data.source_url || name}${c.reset}`);
1772
- } else if (confidence === 'low') {
1773
- console.log();
1774
- console.log(` ${c.yellow}⚠ Limited independent verification.${c.reset}`);
1775
- console.log(` ${c.dim} More auditors needed for consensus. Run: agentaudit audit ${data.source_url || name}${c.reset}`);
1776
- }
1777
-
1778
- // Links
1779
- console.log();
1780
- if (data.source_url) console.log(` ${c.dim}Source: ${data.source_url}${c.reset}`);
1781
- console.log(` ${c.dim}Registry: ${REGISTRY_URL}/skills/${encodeURIComponent(name)}${c.reset}`);
1782
- console.log();
1783
- }
1784
- return data;
1785
- }
1786
-
1787
- // ── Main ────────────────────────────────────────────────
1788
-
1789
- async function main() {
1790
- const rawArgs = process.argv.slice(2);
1791
-
1792
- // MCP server mode: launched by an editor (no TTY + no args) or explicit --stdio flag
1793
- if (rawArgs.includes('--stdio') || (!process.stdin.isTTY && rawArgs.length === 0)) {
1794
- await import('./index.mjs');
1795
- return;
1796
- }
1797
-
1798
- // Parse global flags early
1799
- jsonMode = rawArgs.includes('--json');
1800
- quietMode = rawArgs.includes('--quiet') || rawArgs.includes('-q');
1801
- // --no-color already handled at top level for `c` object
1802
-
1803
- // --model flag: --model=<name> or --model <name>
1804
- const modelFlagIdx = rawArgs.findIndex(a => a === '--model');
1805
- const modelFlagEq = rawArgs.find(a => a.startsWith('--model='));
1806
- modelOverride = modelFlagEq?.split('=')[1]
1807
- || (modelFlagIdx >= 0 ? rawArgs[modelFlagIdx + 1] : null)
1808
- || process.env.AGENTAUDIT_MODEL
1809
- || loadConfig()?.preferred_model
1810
- || null;
1811
- globalModelOverride = modelOverride;
1812
-
1813
- // Strip global flags from args
1814
- const globalFlags = new Set(['--json', '--quiet', '-q', '--no-color']);
1815
- let args = rawArgs.filter(a => !globalFlags.has(a));
1816
- // Strip --model and its value
1817
- args = args.filter((a, i, arr) => {
1818
- if (a.startsWith('--model=')) return false;
1819
- if (a === '--model') { arr[i + 1] = '__skip__'; return false; }
1820
- if (a === '__skip__') return false;
1821
- return true;
1822
- });
1823
-
1824
- if (args[0] === '-v' || args[0] === '--version') {
1825
- console.log(`agentaudit ${getVersion()}`);
1826
- process.exitCode = 0; return;
1827
- }
1828
-
1829
- if (args[0] === '--help' || args[0] === '-h') {
1830
- banner();
1831
- console.log(` ${c.bold}USAGE${c.reset}`);
1832
- console.log(` ${c.cyan}agentaudit${c.reset} <command> [options]`);
1833
- console.log();
1834
- console.log(` ${c.bold}SCAN & AUDIT${c.reset}`);
1835
- console.log(` ${c.cyan}scan${c.reset} <url> [url...] Quick static analysis ${c.dim}(~2s, no API key)${c.reset}`);
1836
- console.log(` ${c.cyan}audit${c.reset} <url> [url...] Deep LLM security audit ${c.dim}(~30s)${c.reset}`);
1837
- console.log(` ${c.cyan}discover${c.reset} Find MCP servers in your editors`);
1838
- console.log();
1839
- console.log(` ${c.bold}REGISTRY${c.reset}`);
1840
- console.log(` ${c.cyan}check${c.reset} <name|url> Look up or auto-audit package`);
1841
- console.log(` ${c.cyan}lookup${c.reset} <name> Look up package in registry`);
1842
- console.log();
1843
- console.log(` ${c.bold}SETUP${c.reset}`);
1844
- console.log(` ${c.cyan}status${c.reset} Check providers & API keys`);
1845
- console.log(` ${c.cyan}setup${c.reset} Register & configure`);
1846
- console.log(` ${c.cyan}models${c.reset} List available LLM models`);
1847
- console.log(` ${c.cyan}config set${c.reset} <key> <value> Set default provider/options`);
1848
- console.log();
1849
- console.log(` ${c.bold}OPTIONS${c.reset}`);
1850
- console.log(` ${c.dim}--json Machine-readable JSON output${c.reset}`);
1851
- console.log(` ${c.dim}--quiet Suppress banner${c.reset}`);
1852
- console.log(` ${c.dim}--no-color Disable colors ${c.reset}${c.dim}(also: NO_COLOR=1)${c.reset}`);
1853
- console.log(` ${c.dim}--provider <p> Force provider ${c.reset}${c.dim}(anthropic|openai|openrouter|ollama|custom)${c.reset}`);
1854
- console.log(` ${c.dim}--model <m> Override model ${c.reset}${c.dim}(e.g. gpt-4o-mini, claude-3.5-sonnet)${c.reset}`);
1855
- console.log(` ${c.dim}--export Export audit payload to markdown${c.reset}`);
1856
- console.log(` ${c.dim}--debug Show raw LLM response on errors${c.reset}`);
1857
- console.log();
1858
- console.log(` ${c.bold}EXAMPLES${c.reset}`);
1859
- console.log(` ${c.dim}$${c.reset} agentaudit scan https://github.com/owner/repo`);
1860
- console.log(` ${c.dim}$${c.reset} agentaudit audit https://github.com/owner/repo`);
1861
- console.log(` ${c.dim}$${c.reset} agentaudit check fastmcp`);
1862
- console.log(` ${c.dim}$${c.reset} agentaudit status`);
1863
- console.log();
1864
- console.log(` ${c.bold}PROVIDERS${c.reset} ${c.dim}(set any one for deep audits)${c.reset}`);
1865
- console.log(` ${c.dim}ANTHROPIC_API_KEY · OPENAI_API_KEY · OPENROUTER_API_KEY · OLLAMA_MODEL · LLM_API_URL${c.reset}`);
1866
- console.log(` ${c.dim}Set default: AGENTAUDIT_PROVIDER=openai AGENTAUDIT_MODEL=gpt-4o-mini${c.reset}`);
1867
- console.log(` ${c.dim}Or persist: agentaudit config set provider openai${c.reset}`);
1868
- console.log(` ${c.dim} agentaudit config set model gpt-4o-mini${c.reset}`);
1869
- console.log(` ${c.dim}Run ${c.cyan}agentaudit status${c.dim} to check configuration.${c.reset}`);
1870
- console.log();
1871
- process.exitCode = 0; return;
1872
- }
1873
-
1874
- // Default no-arg → discover
1875
- const command = args.length === 0 ? 'discover' : args[0];
1876
- const targets = args.slice(1);
1877
-
1878
- banner();
1879
-
1880
- if (command === 'setup') {
1881
- await setupCommand();
1882
- return;
1883
- }
1884
-
1885
- if (command === 'status') {
1886
- console.log(` ${c.bold}LLM Providers:${c.reset}`);
1887
- console.log();
1888
- const keys = {
1889
- anthropicKey: process.env.ANTHROPIC_API_KEY,
1890
- openaiKey: process.env.OPENAI_API_KEY,
1891
- openrouterKey: process.env.OPENROUTER_API_KEY,
1892
- };
1893
- const ollamaHost = process.env.OLLAMA_HOST || 'http://localhost:11434';
1894
- const ollamaModel = process.env.OLLAMA_MODEL;
1895
- const customUrl = process.env.LLM_API_URL;
1896
-
1897
- const checks = [
1898
- { name: 'Anthropic', env: 'ANTHROPIC_API_KEY', key: keys.anthropicKey, testUrl: 'https://api.anthropic.com/v1/messages', testHeaders: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }), testBody: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
1899
- { name: 'OpenAI', env: 'OPENAI_API_KEY', key: keys.openaiKey, testUrl: 'https://api.openai.com/v1/chat/completions', testHeaders: (k) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json' }), testBody: JSON.stringify({ model: 'gpt-4o-mini', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
1900
- { name: 'OpenRouter', env: 'OPENROUTER_API_KEY', key: keys.openrouterKey, testUrl: 'https://openrouter.ai/api/v1/chat/completions', testHeaders: (k) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://agentaudit.dev', 'X-Title': 'AgentAudit' }), testBody: JSON.stringify({ model: 'openai/gpt-4o-mini', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
1901
- { name: 'Ollama', env: 'OLLAMA_MODEL', key: ollamaModel, testUrl: `${ollamaHost}/api/tags`, testHeaders: () => ({}), testBody: null },
1902
- { name: 'Custom', env: 'LLM_API_URL', key: customUrl, testUrl: customUrl ? `${customUrl.replace(/\/$/, '')}/models` : null, testHeaders: (k) => process.env.LLM_API_KEY ? ({ 'Authorization': `Bearer ${process.env.LLM_API_KEY}` }) : {}, testBody: null },
1903
- ];
1904
-
1905
- for (const p of checks) {
1906
- if (!p.key) {
1907
- console.log(` ${c.dim}○${c.reset} ${p.name.padEnd(12)} ${c.dim}not set${c.reset} ${c.dim}(${p.env})${c.reset}`);
1908
- continue;
1909
- }
1910
- const masked = p.key.substring(0, 8) + '...' + p.key.substring(p.key.length - 4);
1911
- process.stdout.write(` ${c.yellow}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} checking...`);
1912
- try {
1913
- const res = await fetch(p.testUrl, {
1914
- method: p.testBody ? 'POST' : 'GET',
1915
- headers: p.testHeaders(p.key),
1916
- ...(p.testBody ? { body: p.testBody } : {}),
1917
- signal: AbortSignal.timeout(10_000),
1918
- });
1919
- if (res.ok || res.status === 200 || res.status === 201) {
1920
- process.stdout.write(`\r ${c.green}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} ${c.green}valid ✓${c.reset} \n`);
1921
- } else {
1922
- const body = await res.json().catch(() => ({}));
1923
- const rawMsg = body?.error?.message || body?.message || `HTTP ${res.status}`;
1924
- // Detect specific error types for clearer messages
1925
- const lcMsg = rawMsg.toLowerCase();
1926
- let errMsg = rawMsg;
1927
- let hint = '';
1928
- if (lcMsg.includes('credit') || lcMsg.includes('balance') || lcMsg.includes('quota') || lcMsg.includes('billing') || lcMsg.includes('exceeded') || lcMsg.includes('insufficient')) {
1929
- errMsg = 'no credits';
1930
- if (p.name === 'Anthropic') hint = `\n ${c.dim}└─ Add credits: console.anthropic.com/settings/plans${c.reset}`;
1931
- else if (p.name === 'OpenAI') hint = `\n ${c.dim}└─ Check usage: platform.openai.com/usage${c.reset}`;
1932
- else if (p.name === 'OpenRouter') hint = `\n ${c.dim}└─ Check balance: openrouter.ai/credits${c.reset}`;
1933
- } else if (res.status === 401 || lcMsg.includes('invalid') || lcMsg.includes('unauthorized') || lcMsg.includes('authentication')) {
1934
- errMsg = 'invalid key';
1935
- if (p.name === 'Anthropic') hint = `\n ${c.dim}└─ Check key: console.anthropic.com/settings/keys${c.reset}`;
1936
- else if (p.name === 'OpenAI') hint = `\n ${c.dim}└─ Check key: platform.openai.com/api-keys${c.reset}`;
1937
- } else if (res.status === 429) {
1938
- errMsg = 'rate limited';
1939
- hint = `\n ${c.dim}└─ Try again in a moment${c.reset}`;
1940
- }
1941
- process.stdout.write(`\r ${c.red}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} ${c.red}✖ ${errMsg}${c.reset}${hint} \n`);
1942
- }
1943
- } catch (e) {
1944
- process.stdout.write(`\r ${c.red}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} ${c.red}error ✗${c.reset} ${c.dim}(${e.message})${c.reset} \n`);
1945
- }
1946
- }
1947
-
1948
- const resolved = resolveProvider(null, keys);
1949
- console.log();
1950
- if (resolved) {
1951
- const activeModel = modelOverride || process.env.AGENTAUDIT_MODEL || loadConfig()?.preferred_model;
1952
- console.log(` ${c.bold}Active:${c.reset} ${c.green}${resolved.label}${c.reset}${activeModel ? ` ${c.dim}model: ${activeModel}${c.reset}` : ''}`);
1953
- console.log(` ${c.dim}Override: --provider=<name> --model=<name>${c.reset}`);
1954
- console.log(` ${c.dim}Set default: agentaudit config set provider <name>${c.reset}`);
1955
- console.log(` ${c.dim} agentaudit config set model <name>${c.reset}`);
1956
- } else {
1957
- console.log(` ${c.yellow}⚠ No working LLM provider.${c.reset} Deep audits require one.`);
1958
- console.log(` ${c.dim}Set a key: export ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1959
- console.log(` ${c.dim}Or scan without LLM: agentaudit scan <url>${c.reset}`);
1960
- }
1961
-
1962
- // AgentAudit registry key
1963
- console.log();
1964
- console.log(` ${c.bold}Registry:${c.reset}`);
1965
- const creds = loadCredentials();
1966
- if (creds?.api_key) {
1967
- const masked = creds.api_key.substring(0, 8) + '...' + creds.api_key.substring(creds.api_key.length - 4);
1968
- console.log(` ${c.green}●${c.reset} AgentAudit ${c.dim}${masked}${c.reset} ${c.dim}(${creds.agent_name || 'unknown'})${c.reset}`);
1969
- // Fetch agent stats from leaderboard
1970
- try {
1971
- const lbRes = await fetch(`${REGISTRY_URL}/api/leaderboard`, { signal: AbortSignal.timeout(5000) });
1972
- if (lbRes.ok) {
1973
- const agents = await lbRes.json();
1974
- const myName = creds.agent_name?.toLowerCase();
1975
- const idx = Array.isArray(agents) ? agents.findIndex((a) => (a.agent_name || '').toLowerCase() === myName) : -1;
1976
- if (idx >= 0) {
1977
- const me = agents[idx];
1978
- const pts = me.total_points || 0;
1979
- const reports = me.total_reports || 0;
1980
- const rank = idx + 1;
1981
- const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ' ';
1982
- console.log();
1983
- console.log(` ${c.bold}Your Stats:${c.reset}`);
1984
- console.log(` ${medal} Rank #${rank} of ${agents.length} ${c.dim}│${c.reset} ${c.cyan}${pts}${c.reset} points ${c.dim}│${c.reset} ${reports} reports`);
1985
- if (me.is_official) console.log(` ${c.green} Official Auditor${c.reset}`);
1986
- }
1987
- }
1988
- } catch {}
1989
- } else {
1990
- console.log(` ${c.dim}○${c.reset} AgentAudit ${c.dim}not set${c.reset} ${c.dim}(run: agentaudit setup)${c.reset}`);
1991
- }
1992
- console.log();
1993
- return;
1994
- }
1995
-
1996
- if (command === 'discover') {
1997
- const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
1998
- const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
1999
- await discoverCommand({ scan: scanFlag, audit: auditFlag });
2000
- return;
2001
- }
2002
-
2003
- if (command === 'models') {
2004
- const anthropicKey = process.env.ANTHROPIC_API_KEY;
2005
- const openaiKey = process.env.OPENAI_API_KEY;
2006
- const openrouterKey = process.env.OPENROUTER_API_KEY;
2007
-
2008
- console.log(` ${c.bold}Available models by provider:${c.reset}`);
2009
- console.log();
2010
-
2011
- // Static lists for Anthropic (no list API)
2012
- console.log(` ${c.bold}Anthropic${c.reset}${anthropicKey ? ` ${c.green}(configured)${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2013
- console.log(` ${c.dim}claude-sonnet-4-20250514${c.reset} ${c.dim}(default)${c.reset}`);
2014
- console.log(` ${c.dim}claude-opus-4-20250514${c.reset}`);
2015
- console.log(` ${c.dim}claude-haiku-3-20250514${c.reset}`);
2016
- console.log();
2017
-
2018
- // Static list for OpenAI
2019
- console.log(` ${c.bold}OpenAI${c.reset}${openaiKey ? ` ${c.green}(configured)${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2020
- console.log(` ${c.dim}gpt-4o${c.reset} ${c.dim}(default)${c.reset}`);
2021
- console.log(` ${c.dim}gpt-4o-mini${c.reset}`);
2022
- console.log(` ${c.dim}gpt-4.1${c.reset}`);
2023
- console.log(` ${c.dim}gpt-4.1-mini${c.reset}`);
2024
- console.log(` ${c.dim}o3${c.reset}`);
2025
- console.log(` ${c.dim}o4-mini${c.reset}`);
2026
- console.log();
2027
-
2028
- // OpenRouter fetch from API
2029
- console.log(` ${c.bold}OpenRouter${c.reset}${openrouterKey ? ` ${c.green}(configured)${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2030
- if (openrouterKey || targets.includes('--all')) {
2031
- process.stdout.write(` ${c.dim}Fetching models...${c.reset}`);
2032
- try {
2033
- const res = await fetch('https://openrouter.ai/api/v1/models', {
2034
- headers: openrouterKey ? { 'Authorization': `Bearer ${openrouterKey}` } : {},
2035
- signal: AbortSignal.timeout(10_000),
2036
- });
2037
- const data = await res.json();
2038
- const models = (data.data || [])
2039
- .filter(m => m.id && !m.id.includes(':free') && !m.id.includes('/extended'))
2040
- .sort((a, b) => (a.id || '').localeCompare(b.id || ''));
2041
-
2042
- // Group by provider prefix
2043
- const groups = {};
2044
- for (const m of models) {
2045
- const [prefix] = m.id.split('/');
2046
- if (!groups[prefix]) groups[prefix] = [];
2047
- groups[prefix].push(m);
2048
- }
2049
-
2050
- // Show popular ones first
2051
- const popular = ['anthropic', 'openai', 'google', 'meta-llama', 'mistralai', 'deepseek'];
2052
- const shown = new Set();
2053
- process.stdout.write(`\r ${c.green}${models.length} models available${c.reset} \n`);
2054
- console.log();
2055
-
2056
- for (const prefix of popular) {
2057
- if (!groups[prefix]) continue;
2058
- shown.add(prefix);
2059
- console.log(` ${c.bold}${prefix}${c.reset}`);
2060
- for (const m of groups[prefix].slice(0, 5)) {
2061
- console.log(` ${c.dim}${m.id}${c.reset}`);
2062
- }
2063
- if (groups[prefix].length > 5) {
2064
- console.log(` ${c.dim}... and ${groups[prefix].length - 5} more${c.reset}`);
2065
- }
2066
- }
2067
-
2068
- const otherCount = Object.keys(groups).filter(k => !shown.has(k)).length;
2069
- if (otherCount > 0) {
2070
- console.log();
2071
- console.log(` ${c.dim}+ ${otherCount} more providers. Use --model=<provider/model>${c.reset}`);
2072
- console.log(` ${c.dim}Full list: https://openrouter.ai/models${c.reset}`);
2073
- }
2074
- } catch (e) {
2075
- process.stdout.write(`\r ${c.red}Failed to fetch: ${e.message}${c.reset} \n`);
2076
- }
2077
- } else {
2078
- console.log(` ${c.dim}anthropic/claude-sonnet-4${c.reset} ${c.dim}(default)${c.reset}`);
2079
- console.log(` ${c.dim}Set OPENROUTER_API_KEY to see all ${c.bold}200+${c.reset}${c.dim} models${c.reset}`);
2080
- console.log(` ${c.dim}Or browse: https://openrouter.ai/models${c.reset}`);
2081
- }
2082
- console.log();
2083
-
2084
- // Ollama
2085
- const ollamaModel = process.env.OLLAMA_MODEL;
2086
- const ollamaHost = process.env.OLLAMA_HOST || 'http://localhost:11434';
2087
- console.log(` ${c.bold}Ollama${c.reset}${ollamaModel ? ` ${c.green}(configured: ${ollamaModel})${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2088
- if (ollamaModel || process.env.OLLAMA_HOST) {
2089
- try {
2090
- const res = await fetch(`${ollamaHost}/api/tags`, { signal: AbortSignal.timeout(5_000) });
2091
- const data = await res.json();
2092
- for (const m of (data.models || []).slice(0, 10)) {
2093
- console.log(` ${c.dim}${m.name}${c.reset}`);
2094
- }
2095
- } catch {
2096
- console.log(` ${c.dim}(Ollama not running at ${ollamaHost})${c.reset}`);
2097
- }
2098
- } else {
2099
- console.log(` ${c.dim}Set OLLAMA_MODEL to use local models${c.reset}`);
2100
- }
2101
- console.log();
2102
-
2103
- console.log(` ${c.bold}Set model:${c.reset}`);
2104
- console.log(` ${c.cyan}agentaudit config set model <name>${c.reset}`);
2105
- console.log(` ${c.cyan}agentaudit audit <url> --model <name>${c.reset}`);
2106
- console.log(` ${c.dim}Or env: AGENTAUDIT_MODEL=<name>${c.reset}`);
2107
- return;
2108
- }
2109
-
2110
- if (command === 'config') {
2111
- const subCmd = targets[0];
2112
- if (subCmd === 'set' && targets[1] === 'provider' && targets[2]) {
2113
- const validProviders = ['anthropic', 'openai', 'openrouter', 'ollama', 'custom', 'claude', 'gpt'];
2114
- const val = targets[2].toLowerCase();
2115
- if (!validProviders.includes(val)) {
2116
- console.log(` ${c.red}✖ Unknown provider: ${val}${c.reset}`);
2117
- console.log(` ${c.dim}Valid: anthropic, openai, openrouter, ollama, custom${c.reset}`);
2118
- process.exitCode = 2; return;
2119
- }
2120
- saveConfig({ preferred_provider: val });
2121
- console.log(` ${c.green}✔${c.reset} Default provider set to: ${c.bold}${val}${c.reset}`);
2122
- console.log(` ${c.dim}Override per-command: --provider=<name>${c.reset}`);
2123
- console.log(` ${c.dim}Or env: AGENTAUDIT_PROVIDER=<name>${c.reset}`);
2124
- } else if (subCmd === 'set' && targets[1] === 'model' && targets[2]) {
2125
- const val = targets[2];
2126
- saveConfig({ preferred_model: val });
2127
- console.log(` ${c.green}✔${c.reset} Default model set to: ${c.bold}${val}${c.reset}`);
2128
- console.log(` ${c.dim}Override per-command: --model=<name>${c.reset}`);
2129
- console.log(` ${c.dim}Or env: AGENTAUDIT_MODEL=<name>${c.reset}`);
2130
- } else if (subCmd === 'get' || !subCmd) {
2131
- const cfg = loadConfig();
2132
- console.log(` ${c.bold}Config:${c.reset} ${USER_CONFIG_FILE}`);
2133
- if (Object.keys(cfg).length === 0) {
2134
- console.log(` ${c.dim}(empty — using defaults)${c.reset}`);
2135
- } else {
2136
- for (const [k, v] of Object.entries(cfg)) {
2137
- console.log(` ${c.dim}${k}:${c.reset} ${v}`);
2138
- }
2139
- }
2140
- } else if (subCmd === 'reset') {
2141
- try { fs.unlinkSync(USER_CONFIG_FILE); } catch {}
2142
- console.log(` ${c.green}✔${c.reset} Config reset to defaults.`);
2143
- } else {
2144
- console.log(` ${c.red}✖ Unknown config command${c.reset}`);
2145
- console.log(` ${c.dim}Usage: agentaudit config set provider <name>${c.reset}`);
2146
- console.log(` ${c.dim} agentaudit config get${c.reset}`);
2147
- console.log(` ${c.dim} agentaudit config reset${c.reset}`);
2148
- }
2149
- return;
2150
- }
2151
-
2152
- if (command === 'lookup' || command === 'check') {
2153
- const names = targets.filter(t => !t.startsWith('--'));
2154
- if (names.length === 0) {
2155
- console.log(` ${c.red}✖ Package name or URL required${c.reset}`);
2156
- console.log(` ${c.dim}Usage: agentaudit check <name|url>${c.reset}`);
2157
- process.exitCode = 2;
2158
- return;
2159
- }
2160
- const results = [];
2161
- const allowAutoAudit = command === 'check'; // only 'check' auto-audits, 'lookup' never does
2162
- for (const t of names) {
2163
- const data = await checkPackage(t, { autoAudit: allowAutoAudit });
2164
- results.push(data);
2165
- }
2166
- if (jsonMode) {
2167
- console.log(JSON.stringify(results.length === 1 ? (results[0] || { error: 'not_found' }) : results, null, 2));
2168
- }
2169
- process.exitCode = 0; return;
2170
- }
2171
-
2172
- if (command === 'scan') {
2173
- const urls = targets.filter(t => !t.startsWith('--'));
2174
- if (urls.length === 0) {
2175
- console.log(` ${c.red}✖ Repository URL required${c.reset}`);
2176
- console.log(` ${c.dim}Usage: agentaudit scan <url>${c.reset}`);
2177
- console.log(` ${c.dim}Or discover local servers: ${c.cyan}agentaudit discover${c.reset}`);
2178
- process.exitCode = 2;
2179
- return;
2180
- }
2181
-
2182
- const results = [];
2183
- let hadErrors = false;
2184
- for (const url of urls) {
2185
- const result = await scanRepo(url);
2186
- if (result) results.push(result);
2187
- else hadErrors = true;
2188
- }
2189
-
2190
- if (jsonMode) {
2191
- const jsonOut = results.map(r => ({
2192
- slug: r.slug,
2193
- url: r.url,
2194
- findings: r.findings.map(f => ({
2195
- severity: f.severity,
2196
- title: f.title,
2197
- file: f.file,
2198
- line: f.line,
2199
- snippet: f.snippet,
2200
- })),
2201
- fileCount: r.files,
2202
- duration: r.duration,
2203
- }));
2204
- console.log(JSON.stringify(jsonOut.length === 1 ? jsonOut[0] : jsonOut, null, 2));
2205
- } else if (results.length > 1) {
2206
- printSummary(results);
2207
- }
2208
-
2209
- if (hadErrors && results.length === 0) { process.exitCode = 2; return; }
2210
- const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
2211
- process.exitCode = totalFindings > 0 ? 1 : 0;
2212
- return;
2213
- }
2214
-
2215
- if (command === 'audit') {
2216
- const urls = targets.filter(t => !t.startsWith('--'));
2217
- if (urls.length === 0) {
2218
- console.log(` ${c.red}✖ Repository URL required${c.reset}`);
2219
- console.log(` ${c.dim}Usage: agentaudit audit <url>${c.reset}`);
2220
- process.exitCode = 2;
2221
- return;
2222
- }
2223
-
2224
- let hasFindings = false;
2225
- for (const url of urls) {
2226
- const report = await auditRepo(url);
2227
- if (report?.findings?.length > 0) hasFindings = true;
2228
- }
2229
- process.exitCode = hasFindings ? 1 : 0;
2230
- return;
2231
- }
2232
-
2233
- // Typo correction via Levenshtein distance
2234
- const knownCommands = ['discover', 'scan', 'audit', 'check', 'lookup', 'status', 'setup', 'config', 'models'];
2235
- const suggestion = knownCommands
2236
- .map(cmd => ({ cmd, dist: levenshtein(command, cmd) }))
2237
- .filter(x => x.dist <= 3)
2238
- .sort((a, b) => a.dist - b.dist)[0];
2239
-
2240
- console.log(` ${c.red}✖ Unknown command: ${command}${c.reset}`);
2241
- if (suggestion) {
2242
- console.log(` ${c.dim}Did you mean: ${c.cyan}agentaudit ${suggestion.cmd}${c.reset}${c.dim}?${c.reset}`);
2243
- }
2244
- console.log(` ${c.dim}Run ${c.cyan}agentaudit --help${c.dim} for usage${c.reset}`);
2245
- process.exitCode = 2;
2246
- }
2247
-
2248
- main().catch(err => {
2249
- console.error(`${c.red}Error: ${err.message}${c.reset}`);
2250
- process.exitCode = 2;
2251
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AgentAudit CLI — Security scanner for AI tools
4
+ *
5
+ * Scan & Audit: scan <url>, audit <url>, discover
6
+ * Registry: check <name|url>, lookup <name>
7
+ * Setup: status, setup, config
8
+ *
9
+ * Global flags: --json, --quiet, --no-color, --provider, --debug, --export
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import os from 'os';
14
+ import path from 'path';
15
+ import { execSync } from 'child_process';
16
+ import { createInterface } from 'readline';
17
+ import { fileURLToPath } from 'url';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const SKILL_DIR = path.resolve(__dirname);
21
+ const REGISTRY_URL = 'https://agentaudit.dev';
22
+
23
+ // ── Provider resolution ────
24
+ function resolveProvider(flagOverride, keys) {
25
+ const orModel = process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
26
+ const ollamaModel = process.env.OLLAMA_MODEL || 'llama3.1';
27
+ const ollamaHost = process.env.OLLAMA_HOST || 'http://localhost:11434';
28
+ const customUrl = process.env.LLM_API_URL;
29
+ const customKey = process.env.LLM_API_KEY;
30
+ const customModel = process.env.LLM_MODEL || 'default';
31
+
32
+ const providers = {
33
+ anthropic: keys.anthropicKey ? { id: 'anthropic', label: 'Anthropic (Claude)', key: keys.anthropicKey } : null,
34
+ openai: keys.openaiKey ? { id: 'openai', label: 'OpenAI (GPT-4o)', key: keys.openaiKey } : null,
35
+ openrouter: keys.openrouterKey ? { id: 'openrouter', label: `OpenRouter (${orModel})`, key: keys.openrouterKey } : null,
36
+ ollama: process.env.OLLAMA_MODEL || process.env.OLLAMA_HOST ? { id: 'ollama', label: `Ollama (${ollamaModel})`, key: null, host: ollamaHost, model: ollamaModel } : null,
37
+ custom: customUrl ? { id: 'custom', label: `Custom (${customModel})`, key: customKey, url: customUrl, model: customModel } : null,
38
+ };
39
+ // Aliases
40
+ const aliases = { claude: 'anthropic', gpt: 'openai', 'gpt-4o': 'openai', 'gpt4': 'openai', or: 'openrouter', local: 'ollama' };
41
+
42
+ // Priority: --provider flag > AGENTAUDIT_PROVIDER env > config file > model-inferred > auto-detect
43
+ const preferred = flagOverride
44
+ || process.env.AGENTAUDIT_PROVIDER?.toLowerCase()
45
+ || loadConfig()?.preferred_provider
46
+ || null;
47
+
48
+ if (preferred) {
49
+ const resolved = aliases[preferred] || preferred;
50
+ const p = providers[resolved];
51
+ if (!p) return null;
52
+ return p;
53
+ }
54
+
55
+ // Smart inference: if model is set, try to match it to a provider
56
+ const activeModel = globalModelOverride || process.env.AGENTAUDIT_MODEL || loadConfig()?.preferred_model;
57
+ if (activeModel) {
58
+ const lm = activeModel.toLowerCase();
59
+ // Direct provider models (no slash = native format)
60
+ if (!lm.includes('/')) {
61
+ if (lm.startsWith('claude') && providers.anthropic) return providers.anthropic;
62
+ if ((lm.startsWith('gpt') || lm.startsWith('o3') || lm.startsWith('o4') || lm.startsWith('o1')) && providers.openai) return providers.openai;
63
+ if (providers.ollama && (process.env.OLLAMA_MODEL || process.env.OLLAMA_HOST)) return providers.ollama;
64
+ }
65
+ // Slash format = OpenRouter convention (provider/model)
66
+ if (lm.includes('/') && providers.openrouter) return providers.openrouter;
67
+ }
68
+
69
+ // Auto-detect priority: Anthropic > OpenAI > OpenRouter > Custom > Ollama (local last — usually weaker)
70
+ return providers.anthropic || providers.openai || providers.openrouter || providers.custom || providers.ollama || null;
71
+ }
72
+
73
+ // ── Global flags (set in main before command routing) ────
74
+ let jsonMode = false;
75
+ let quietMode = false;
76
+ let modelOverride = null; // --model flag or AGENTAUDIT_MODEL env or config
77
+ let globalModelOverride = null; // same, but set early for resolveProvider
78
+
79
+ // ── ANSI Colors (respects NO_COLOR and --no-color) ───────
80
+
81
+ const noColor = !!(process.env.NO_COLOR || process.argv.includes('--no-color'));
82
+
83
+ const c = noColor ? {
84
+ reset: '', bold: '', dim: '', red: '', green: '', yellow: '',
85
+ blue: '', magenta: '', cyan: '', white: '', gray: '',
86
+ bgRed: '', bgGreen: '', bgYellow: '',
87
+ } : {
88
+ reset: '\x1b[0m',
89
+ bold: '\x1b[1m',
90
+ dim: '\x1b[2m',
91
+ red: '\x1b[31m',
92
+ green: '\x1b[32m',
93
+ yellow: '\x1b[33m',
94
+ blue: '\x1b[34m',
95
+ magenta: '\x1b[35m',
96
+ cyan: '\x1b[36m',
97
+ white: '\x1b[37m',
98
+ gray: '\x1b[90m',
99
+ bgRed: '\x1b[41m',
100
+ bgGreen: '\x1b[42m',
101
+ bgYellow: '\x1b[43m',
102
+ };
103
+
104
+ const icons = {
105
+ safe: `${c.green}✔${c.reset}`,
106
+ caution: `${c.yellow}⚠${c.reset}`,
107
+ unsafe: `${c.red}✖${c.reset}`,
108
+ info: `${c.blue}ℹ${c.reset}`,
109
+ scan: `${c.cyan}◉${c.reset}`,
110
+ tree: `${c.gray}├──${c.reset}`,
111
+ treeLast: `${c.gray}└──${c.reset}`,
112
+ pipe: `${c.gray}│${c.reset}`,
113
+ bullet: `${c.gray}•${c.reset}`,
114
+ };
115
+
116
+ // ── Credentials ─────────────────────────────────────────
117
+
118
+ const home = process.env.HOME || process.env.USERPROFILE || '';
119
+ const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
120
+ const USER_CRED_DIR = path.join(xdgConfig, 'agentaudit');
121
+ const USER_CRED_FILE = path.join(USER_CRED_DIR, 'credentials.json');
122
+ const SKILL_CRED_FILE = path.join(SKILL_DIR, 'config', 'credentials.json');
123
+
124
+ function loadCredentials() {
125
+ for (const f of [SKILL_CRED_FILE, USER_CRED_FILE]) {
126
+ if (fs.existsSync(f)) {
127
+ try {
128
+ const data = JSON.parse(fs.readFileSync(f, 'utf8'));
129
+ if (data.api_key) return data;
130
+ } catch {}
131
+ }
132
+ }
133
+ if (process.env.AGENTAUDIT_API_KEY) {
134
+ return { api_key: process.env.AGENTAUDIT_API_KEY, agent_name: 'env' };
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function saveCredentials(data) {
140
+ const json = JSON.stringify(data, null, 2);
141
+ fs.mkdirSync(USER_CRED_DIR, { recursive: true });
142
+ fs.writeFileSync(USER_CRED_FILE, json, { mode: 0o600 });
143
+ try {
144
+ fs.mkdirSync(path.dirname(SKILL_CRED_FILE), { recursive: true });
145
+ fs.writeFileSync(SKILL_CRED_FILE, json, { mode: 0o600 });
146
+ } catch {}
147
+ }
148
+
149
+ const USER_CONFIG_FILE = path.join(USER_CRED_DIR, 'config.json');
150
+ const USER_STATS_FILE = path.join(USER_CRED_DIR, 'stats-cache.json');
151
+
152
+ function loadStatsCache() {
153
+ try { return fs.existsSync(USER_STATS_FILE) ? JSON.parse(fs.readFileSync(USER_STATS_FILE, 'utf8')) : null; } catch { return null; }
154
+ }
155
+
156
+ function saveStatsCache(stats) {
157
+ try { fs.mkdirSync(USER_CRED_DIR, { recursive: true }); fs.writeFileSync(USER_STATS_FILE, JSON.stringify({ ...stats, _ts: Date.now() })); } catch {}
158
+ }
159
+
160
+ async function refreshStatsCache(agentName) {
161
+ try {
162
+ const lbRes = await fetch(`${REGISTRY_URL}/api/leaderboard`, { signal: AbortSignal.timeout(5000) });
163
+ if (!lbRes.ok) return null;
164
+ const agents = await lbRes.json();
165
+ const idx = Array.isArray(agents) ? agents.findIndex(a => (a.agent_name || '').toLowerCase() === agentName.toLowerCase()) : -1;
166
+ if (idx < 0) return null;
167
+ const me = agents[idx];
168
+ const stats = { rank: idx + 1, total: agents.length, pts: me.total_points || 0, reports: me.total_reports || 0, official: !!me.is_official };
169
+ saveStatsCache(stats);
170
+ return stats;
171
+ } catch { return null; }
172
+ }
173
+
174
+ function loadConfig() {
175
+ try {
176
+ if (fs.existsSync(USER_CONFIG_FILE)) {
177
+ return JSON.parse(fs.readFileSync(USER_CONFIG_FILE, 'utf8'));
178
+ }
179
+ } catch {}
180
+ return {};
181
+ }
182
+
183
+ function saveConfig(data) {
184
+ const existing = loadConfig();
185
+ const merged = { ...existing, ...data };
186
+ fs.mkdirSync(USER_CRED_DIR, { recursive: true });
187
+ fs.writeFileSync(USER_CONFIG_FILE, JSON.stringify(merged, null, 2), { mode: 0o600 });
188
+ }
189
+
190
+ function askQuestion(question) {
191
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
192
+ return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer.trim()); }));
193
+ }
194
+
195
+ /**
196
+ * Interactive multi-select in terminal. No dependencies.
197
+ * items: [{ label, sublabel?, value, checked? }]
198
+ * Returns: array of selected values
199
+ */
200
+ function multiSelect(items, { title = 'Select items', hint = 'Space=toggle ↑↓=move a=all n=none Enter=confirm' } = {}) {
201
+ return new Promise((resolve) => {
202
+ if (!process.stdin.isTTY) {
203
+ // Non-interactive: return all items
204
+ resolve(items.map(i => i.value));
205
+ return;
206
+ }
207
+
208
+ const selected = new Set(items.filter(i => i.checked).map((_, idx) => idx));
209
+ let cursor = 0;
210
+
211
+ const render = () => {
212
+ // Move cursor up to overwrite previous render
213
+ process.stdout.write(`\x1b[${items.length + 3}A\x1b[J`);
214
+ draw();
215
+ };
216
+
217
+ const draw = () => {
218
+ console.log(` ${c.bold}${title}${c.reset} ${c.dim}(${selected.size}/${items.length} selected)${c.reset}`);
219
+ console.log(` ${c.dim}${hint}${c.reset}`);
220
+ console.log();
221
+ for (let i = 0; i < items.length; i++) {
222
+ const item = items[i];
223
+ const isCursor = i === cursor;
224
+ const isSelected = selected.has(i);
225
+ const pointer = isCursor ? `${c.cyan}❯${c.reset}` : ' ';
226
+ const checkbox = isSelected ? `${c.green}◉${c.reset}` : `${c.dim}○${c.reset}`;
227
+ const label = isCursor ? `${c.bold}${item.label}${c.reset}` : item.label;
228
+ const sub = item.sublabel ? ` ${c.dim}${item.sublabel}${c.reset}` : '';
229
+ console.log(` ${pointer} ${checkbox} ${label}${sub}`);
230
+ }
231
+ };
232
+
233
+ // Initial draw
234
+ draw();
235
+
236
+ process.stdin.setRawMode(true);
237
+ process.stdin.resume();
238
+ process.stdin.setEncoding('utf8');
239
+
240
+ const onData = (key) => {
241
+ // Ctrl+C
242
+ if (key === '\x03') {
243
+ process.stdin.setRawMode(false);
244
+ process.stdin.pause();
245
+ process.stdin.removeListener('data', onData);
246
+ console.log();
247
+ process.exitCode = 0; return;
248
+ }
249
+
250
+ // Enter
251
+ if (key === '\r' || key === '\n') {
252
+ process.stdin.setRawMode(false);
253
+ process.stdin.pause();
254
+ process.stdin.removeListener('data', onData);
255
+ resolve(items.filter((_, i) => selected.has(i)).map(i => i.value));
256
+ return;
257
+ }
258
+
259
+ // Space toggle
260
+ if (key === ' ') {
261
+ if (selected.has(cursor)) selected.delete(cursor);
262
+ else selected.add(cursor);
263
+ render();
264
+ return;
265
+ }
266
+
267
+ // a select all
268
+ if (key === 'a') {
269
+ for (let i = 0; i < items.length; i++) selected.add(i);
270
+ render();
271
+ return;
272
+ }
273
+
274
+ // n — select none
275
+ if (key === 'n') {
276
+ selected.clear();
277
+ render();
278
+ return;
279
+ }
280
+
281
+ // Arrow up / k
282
+ if (key === '\x1b[A' || key === 'k') {
283
+ cursor = (cursor - 1 + items.length) % items.length;
284
+ render();
285
+ return;
286
+ }
287
+
288
+ // Arrow down / j
289
+ if (key === '\x1b[B' || key === 'j') {
290
+ cursor = (cursor + 1) % items.length;
291
+ render();
292
+ return;
293
+ }
294
+ };
295
+
296
+ process.stdin.on('data', onData);
297
+ });
298
+ }
299
+
300
+ async function registerAgent(agentName) {
301
+ const res = await fetch(`${REGISTRY_URL}/api/register`, {
302
+ method: 'POST',
303
+ headers: { 'Content-Type': 'application/json' },
304
+ body: JSON.stringify({ agent_name: agentName }),
305
+ signal: AbortSignal.timeout(15_000),
306
+ });
307
+ if (!res.ok) throw new Error(`Registration failed (HTTP ${res.status}): ${await res.text()}`);
308
+ return res.json();
309
+ }
310
+
311
+ async function setupCommand() {
312
+ console.log(` ${c.bold}Setup${c.reset}`);
313
+ console.log();
314
+
315
+ const existing = loadCredentials();
316
+ if (existing) {
317
+ console.log(` ${icons.safe} Already configured as ${c.bold}${existing.agent_name}${c.reset}`);
318
+ console.log(` ${c.dim}Key: ${existing.api_key.slice(0, 8)}...${c.reset}`);
319
+ console.log();
320
+ const answer = await askQuestion(` Reconfigure? ${c.dim}(y/N)${c.reset} `);
321
+ if (answer.toLowerCase() !== 'y') {
322
+ console.log(` ${c.dim}Keeping existing config.${c.reset}`);
323
+ return;
324
+ }
325
+ console.log();
326
+ }
327
+
328
+ console.log(` ${c.bold}1)${c.reset} Register new agent ${c.dim}(free, creates API key automatically)${c.reset}`);
329
+ console.log(` ${c.bold}2)${c.reset} Enter existing API key`);
330
+ console.log();
331
+ const choice = await askQuestion(` Choice ${c.dim}(1/2)${c.reset}: `);
332
+ console.log();
333
+
334
+ if (choice === '2') {
335
+ const key = await askQuestion(` API Key: `);
336
+ if (!key) { console.log(` ${c.red}No key entered.${c.reset}`); return; }
337
+ const name = await askQuestion(` Agent name ${c.dim}(optional)${c.reset}: `);
338
+ saveCredentials({ api_key: key, agent_name: name || 'custom' });
339
+ console.log();
340
+ console.log(` ${icons.safe} Saved! Key stored in ${c.dim}${USER_CRED_FILE}${c.reset}`);
341
+ } else {
342
+ const name = await askQuestion(` Agent name ${c.dim}(e.g. my-scanner, claude-desktop)${c.reset}: `);
343
+ if (!name || !/^[a-zA-Z0-9._-]{2,64}$/.test(name)) {
344
+ console.log(` ${c.red}Invalid name. Use 2-64 chars: letters, numbers, dash, underscore, dot.${c.reset}`);
345
+ return;
346
+ }
347
+ process.stdout.write(` Registering ${c.bold}${name}${c.reset}...`);
348
+ try {
349
+ const data = await registerAgent(name);
350
+ saveCredentials({ api_key: data.api_key, agent_name: data.agent_name });
351
+ console.log(` ${c.green}done!${c.reset}`);
352
+ console.log();
353
+ console.log(` ${icons.safe} Registered as ${c.bold}${data.agent_name}${c.reset}`);
354
+ console.log(` ${c.dim}Key: ${data.api_key.slice(0, 12)}...${c.reset}`);
355
+ console.log(` ${c.dim}Saved to: ${USER_CRED_FILE}${c.reset}`);
356
+ // Initialize stats cache
357
+ refreshStatsCache(data.agent_name).catch(() => {});
358
+ } catch (err) {
359
+ console.log(` ${c.red}failed${c.reset}`);
360
+ console.log(` ${c.red}${err.message}${c.reset}`);
361
+ return;
362
+ }
363
+ }
364
+
365
+ console.log();
366
+ console.log(` ${c.bold}Ready!${c.reset} You can now:`);
367
+ console.log(` ${c.dim}•${c.reset} Discover servers: ${c.cyan}agentaudit discover${c.reset}`);
368
+ console.log(` ${c.dim}•${c.reset} Audit packages: ${c.cyan}agentaudit audit <repo-url>${c.reset} ${c.dim}(deep LLM analysis)${c.reset}`);
369
+ console.log(` ${c.dim}•${c.reset} Quick scan: ${c.cyan}agentaudit scan <repo-url>${c.reset} ${c.dim}(regex-based)${c.reset}`);
370
+ console.log(` ${c.dim}•${c.reset} Check registry: ${c.cyan}agentaudit check <name>${c.reset}`);
371
+ console.log(` ${c.dim}•${c.reset} Submit reports via MCP in Claude/Cursor/Windsurf`);
372
+ console.log();
373
+ }
374
+
375
+ // ── Structured error output ─────────────────────────────
376
+
377
+ function emitError(code, message, hint, exitCode = 2) {
378
+ if (jsonMode) {
379
+ process.stderr.write(JSON.stringify({ error: true, code, message, hint: hint || undefined, exitCode }) + '\n');
380
+ }
381
+ process.exitCode = exitCode;
382
+ }
383
+
384
+ // ── Levenshtein distance for typo correction ────────────
385
+
386
+ function levenshtein(a, b) {
387
+ const m = a.length, n = b.length;
388
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
389
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
390
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
391
+ for (let i = 1; i <= m; i++)
392
+ for (let j = 1; j <= n; j++)
393
+ dp[i][j] = Math.min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + (a[i-1] !== b[j-1] ? 1 : 0));
394
+ return dp[m][n];
395
+ }
396
+
397
+ // ── Helpers ──────────────────────────────────────────────
398
+
399
+ function getVersion() {
400
+ try {
401
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
402
+ return pkg.version || '0.0.0';
403
+ } catch { return '0.0.0'; }
404
+ }
405
+
406
+ function banner() {
407
+ if (quietMode || jsonMode) return;
408
+ const creds = loadCredentials();
409
+ const agentInfo = creds?.agent_name ? ` ${c.dim}·${c.reset} ${c.green}${creds.agent_name}${c.reset}` : '';
410
+ console.log();
411
+ console.log(` 🛡 ${c.bold}${c.cyan}AgentAudit${c.reset} ${c.dim}v${getVersion()}${c.reset}${agentInfo}`);
412
+ // Show cached stats inline
413
+ if (creds?.agent_name) {
414
+ const cached = loadStatsCache();
415
+ if (cached && cached.pts !== undefined) {
416
+ const medal = cached.rank === 1 ? '🥇' : cached.rank === 2 ? '🥈' : cached.rank === 3 ? '🥉' : `#${cached.rank}`;
417
+ console.log(` ${c.dim}${medal} · ${cached.pts} pts · ${cached.reports} reports${cached.official ? ` · ${c.green}Official${c.reset}${c.dim}` : ''}${c.reset}`);
418
+ }
419
+ } else {
420
+ console.log(` ${c.dim}Security scanner for AI tools${c.reset}`);
421
+ }
422
+ console.log();
423
+ }
424
+
425
+ function slugFromUrl(url) {
426
+ const match = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
427
+ if (match) {
428
+ const owner = match[1].toLowerCase().replace(/[^a-z0-9-]/g, '-');
429
+ const repo = match[2].toLowerCase().replace(/[^a-z0-9-]/g, '-');
430
+ // Generic repo names get owner prefix to avoid collisions
431
+ const generic = ['mcp', 'server', 'plugin', 'tool', 'agent', 'sdk', 'api', 'app', 'cli', 'lib', 'core'];
432
+ if (generic.includes(repo)) return `${owner}-${repo}`;
433
+ return repo;
434
+ }
435
+ return url.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 60);
436
+ }
437
+
438
+ function elapsed(startMs) {
439
+ const ms = Date.now() - startMs;
440
+ if (ms < 1000) return `${ms}ms`;
441
+ return `${(ms / 1000).toFixed(1)}s`;
442
+ }
443
+
444
+ function riskBadge(score) {
445
+ if (score === 0) return `${c.bgGreen}${c.bold}${c.white} SAFE ${c.reset}`;
446
+ if (score <= 10) return `${c.bgGreen}${c.white} LOW ${c.reset}`;
447
+ if (score <= 30) return `${c.bgYellow}${c.bold} CAUTION ${c.reset}`;
448
+ return `${c.bgRed}${c.bold}${c.white} UNSAFE ${c.reset}`;
449
+ }
450
+
451
+ function severityColor(sev) {
452
+ switch (sev) {
453
+ case 'critical': return c.red;
454
+ case 'high': return c.red;
455
+ case 'medium': return c.yellow;
456
+ case 'low': return c.blue;
457
+ default: return c.gray;
458
+ }
459
+ }
460
+
461
+ function severityIcon(sev) {
462
+ switch (sev) {
463
+ case 'critical': return `${c.red}●${c.reset}`;
464
+ case 'high': return `${c.red}●${c.reset}`;
465
+ case 'medium': return `${c.yellow}●${c.reset}`;
466
+ case 'low': return `${c.blue}●${c.reset}`;
467
+ default: return `${c.green}●${c.reset}`;
468
+ }
469
+ }
470
+
471
+ // ── File Collection (same logic as MCP server) ──────────
472
+
473
+ function extractJSON(text) {
474
+ // 1. Try parsing the entire text as JSON directly
475
+ try { return JSON.parse(text.trim()); } catch {}
476
+
477
+ // 2. Strip markdown code fences — try last fence first (report is usually at the end)
478
+ const fenceMatches = [...text.matchAll(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/g)];
479
+ for (let i = fenceMatches.length - 1; i >= 0; i--) {
480
+ try {
481
+ const parsed = JSON.parse(fenceMatches[i][1].trim());
482
+ if (parsed && typeof parsed === 'object' && ('risk_score' in parsed || 'findings' in parsed || 'result' in parsed)) return parsed;
483
+ } catch {}
484
+ }
485
+ // Try any fence even without report keys
486
+ for (let i = fenceMatches.length - 1; i >= 0; i--) {
487
+ try { return JSON.parse(fenceMatches[i][1].trim()); } catch {}
488
+ }
489
+
490
+ // 3. Find ALL balanced top-level { ... } blocks, try each (prefer largest valid one)
491
+ const blocks = [];
492
+ let searchFrom = 0;
493
+ while (searchFrom < text.length) {
494
+ const start = text.indexOf('{', searchFrom);
495
+ if (start === -1) break;
496
+ let depth = 0, inStr = false, esc = false;
497
+ let end = -1;
498
+ for (let i = start; i < text.length; i++) {
499
+ const ch = text[i];
500
+ if (esc) { esc = false; continue; }
501
+ if (ch === '\\' && inStr) { esc = true; continue; }
502
+ if (ch === '"') { inStr = !inStr; continue; }
503
+ if (inStr) continue;
504
+ if (ch === '{') depth++;
505
+ else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } }
506
+ }
507
+ if (end > start) {
508
+ blocks.push(text.slice(start, end + 1));
509
+ searchFrom = end + 1;
510
+ } else {
511
+ searchFrom = start + 1;
512
+ }
513
+ }
514
+ // Try largest block first (the report JSON is usually the biggest)
515
+ blocks.sort((a, b) => b.length - a.length);
516
+ for (const block of blocks) {
517
+ try { return JSON.parse(block); } catch {}
518
+ }
519
+
520
+ return null;
521
+ }
522
+
523
+ const MAX_FILE_SIZE = 50_000;
524
+ const MAX_TOTAL_SIZE = 300_000;
525
+ const SKIP_DIRS = new Set([
526
+ 'node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build',
527
+ '.next', '.nuxt', 'coverage', '.pytest_cache', '.mypy_cache', 'vendor',
528
+ 'test', 'tests', '__tests__', 'spec', 'specs', 'docs', 'doc',
529
+ 'examples', 'example', 'fixtures', '.github', '.vscode', '.idea',
530
+ 'e2e', 'benchmark', 'benchmarks', '.tox', '.eggs', 'htmlcov',
531
+ ]);
532
+ const SKIP_EXTENSIONS = new Set([
533
+ '.lock', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff',
534
+ '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.zip', '.tar', '.gz',
535
+ '.map', '.min.js', '.min.css', '.d.ts', '.pyc', '.pyo', '.so',
536
+ '.dylib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite',
537
+ ]);
538
+
539
+ function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
540
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
541
+ let entries;
542
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
543
+ catch { return collected; }
544
+ entries.sort((a, b) => a.name.localeCompare(b.name));
545
+ for (const entry of entries) {
546
+ if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
547
+ const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
548
+ const fullPath = path.join(dir, entry.name);
549
+ if (entry.isDirectory()) {
550
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
551
+ collectFiles(fullPath, relPath, collected, totalSize);
552
+ } else {
553
+ const ext = path.extname(entry.name).toLowerCase();
554
+ if (SKIP_EXTENSIONS.has(ext)) continue;
555
+ try {
556
+ const stat = fs.statSync(fullPath);
557
+ if (stat.size > MAX_FILE_SIZE || stat.size === 0) continue;
558
+ const content = fs.readFileSync(fullPath, 'utf8');
559
+ totalSize.bytes += content.length;
560
+ collected.push({ path: relPath, content, size: stat.size });
561
+ } catch {}
562
+ }
563
+ }
564
+ return collected;
565
+ }
566
+
567
+ // ── Detect package properties ───────────────────────────
568
+
569
+ function detectPackageInfo(repoPath, files) {
570
+ const info = { type: 'unknown', tools: [], prompts: [], language: 'unknown', entrypoint: null };
571
+
572
+ // Detect language
573
+ const exts = files.map(f => path.extname(f.path).toLowerCase());
574
+ const extCounts = {};
575
+ exts.forEach(e => { extCounts[e] = (extCounts[e] || 0) + 1; });
576
+ const topExt = Object.entries(extCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
577
+
578
+ const langMap = { '.py': 'Python', '.js': 'JavaScript', '.ts': 'TypeScript', '.mjs': 'JavaScript', '.rs': 'Rust', '.go': 'Go', '.java': 'Java', '.rb': 'Ruby' };
579
+ info.language = langMap[topExt] || topExt || 'unknown';
580
+
581
+ // Detect package type
582
+ const allContent = files.map(f => f.content).join('\n');
583
+ if (allContent.includes('@modelcontextprotocol') || allContent.includes('FastMCP') || allContent.includes('mcp.server') || allContent.includes('mcp_server')) {
584
+ info.type = 'mcp-server';
585
+ } else if (files.some(f => f.path.toLowerCase() === 'skill.md')) {
586
+ info.type = 'agent-skill';
587
+ } else if (allContent.includes('#!/usr/bin/env') || allContent.includes('argparse') || allContent.includes('commander')) {
588
+ info.type = 'cli-tool';
589
+ } else {
590
+ info.type = 'library';
591
+ }
592
+
593
+ // Extract MCP tools (look for tool definitions)
594
+ const toolPatterns = [
595
+ // JS/TS: name: 'tool_name' or "tool_name" in tool definitions
596
+ /(?:name|tool_name)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
597
+ // Python: @mcp.tool() def func_name or Tool(name="...")
598
+ /(?:@(?:mcp|server)\.tool\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*))|(?:Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"])/gi,
599
+ // Direct: tool names in ListTools handlers
600
+ /['"]name['"]\s*:\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
601
+ ];
602
+
603
+ const toolSet = new Set();
604
+ for (const file of files) {
605
+ for (const pattern of toolPatterns) {
606
+ pattern.lastIndex = 0;
607
+ let m;
608
+ while ((m = pattern.exec(file.content)) !== null) {
609
+ const name = m[1] || m[2];
610
+ if (name && name.length > 2 && name.length < 50 && !['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none'].includes(name)) {
611
+ toolSet.add(name);
612
+ }
613
+ }
614
+ }
615
+ }
616
+ info.tools = [...toolSet];
617
+
618
+ // Extract prompts (look for prompt definitions)
619
+ const promptPatterns = [
620
+ /(?:prompt|PROMPT)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
621
+ /@(?:mcp|server)\.prompt\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*)/gi,
622
+ ];
623
+ const promptSet = new Set();
624
+ for (const file of files) {
625
+ for (const pattern of promptPatterns) {
626
+ pattern.lastIndex = 0;
627
+ let m;
628
+ while ((m = pattern.exec(file.content)) !== null) {
629
+ if (m[1] && m[1].length > 2) promptSet.add(m[1]);
630
+ }
631
+ }
632
+ }
633
+ info.prompts = [...promptSet];
634
+
635
+ // Detect entrypoint
636
+ const entryFiles = ['index.js', 'index.ts', 'index.mjs', 'main.py', 'server.py', 'app.py', 'src/index.ts', 'src/main.ts', 'src/index.js'];
637
+ for (const ef of entryFiles) {
638
+ if (files.some(f => f.path === ef)) { info.entrypoint = ef; break; }
639
+ }
640
+
641
+ return info;
642
+ }
643
+
644
+ // ── Quick static checks ─────────────────────────────────
645
+
646
+ function quickChecks(files) {
647
+ const findings = [];
648
+
649
+ const checks = [
650
+ {
651
+ id: 'EXEC_INJECTION',
652
+ title: 'Command injection risk',
653
+ severity: 'high',
654
+ pattern: /(?:exec(?:Sync)?|spawn|child_process|subprocess|os\.system|os\.popen|Popen)\s*\([^)]*(?:\$\{|`|\+\s*(?:req|input|args|param|user|query))/i,
655
+ category: 'injection',
656
+ },
657
+ {
658
+ id: 'EVAL_USAGE',
659
+ title: 'Dynamic code evaluation',
660
+ severity: 'high',
661
+ pattern: /(?:^|[^a-z])eval\s*\([^)]*(?:input|req|user|param|arg|query)/im,
662
+ category: 'injection',
663
+ },
664
+ {
665
+ id: 'HARDCODED_SECRET',
666
+ title: 'Potential hardcoded secret',
667
+ severity: 'medium',
668
+ pattern: /(?:api[_-]?key|password|secret|token)\s*[:=]\s*['"][A-Za-z0-9+/=_-]{16,}['"]/i,
669
+ category: 'secrets',
670
+ },
671
+ {
672
+ id: 'SSL_DISABLED',
673
+ title: 'SSL/TLS verification disabled',
674
+ severity: 'medium',
675
+ pattern: /(?:rejectUnauthorized\s*:\s*false|verify\s*=\s*False|VERIFY_SSL\s*=\s*false|NODE_TLS_REJECT_UNAUTHORIZED|InsecureRequestWarning)/i,
676
+ category: 'crypto',
677
+ },
678
+ {
679
+ id: 'PATH_TRAVERSAL',
680
+ title: 'Potential path traversal',
681
+ severity: 'medium',
682
+ pattern: /(?:\.\.\/|\.\.\\|path\.join|os\.path\.join)\s*\([^)]*(?:input|req|user|param|arg|query)/i,
683
+ category: 'filesystem',
684
+ },
685
+ {
686
+ id: 'CORS_WILDCARD',
687
+ title: 'Wildcard CORS origin',
688
+ severity: 'low',
689
+ pattern: /(?:Access-Control-Allow-Origin|cors)\s*[:({]\s*['"]\*/i,
690
+ category: 'network',
691
+ },
692
+ {
693
+ id: 'TELEMETRY',
694
+ title: 'Undisclosed telemetry',
695
+ severity: 'low',
696
+ pattern: /(?:posthog|mixpanel|analytics|telemetry|tracking|sentry).*(?:init|setup|track|capture)/i,
697
+ category: 'privacy',
698
+ },
699
+ {
700
+ id: 'SHELL_EXEC',
701
+ title: 'Shell command execution',
702
+ severity: 'high',
703
+ pattern: /(?:subprocess\.(?:run|call|Popen)|os\.system|os\.popen|execSync|child_process\.exec)\s*\(/i,
704
+ category: 'injection',
705
+ },
706
+ {
707
+ id: 'SQL_INJECTION',
708
+ title: 'Potential SQL injection',
709
+ severity: 'high',
710
+ pattern: /(?:execute|query|raw)\s*\(\s*(?:f['"]|['"].*?%s|['"].*?\{|['"].*?\+)/i,
711
+ category: 'injection',
712
+ },
713
+ {
714
+ id: 'YAML_UNSAFE',
715
+ title: 'Unsafe YAML loading',
716
+ severity: 'medium',
717
+ pattern: /yaml\.(?:load|unsafe_load)\s*\(/i,
718
+ category: 'deserialization',
719
+ },
720
+ {
721
+ id: 'PICKLE_LOAD',
722
+ title: 'Unsafe deserialization (pickle)',
723
+ severity: 'high',
724
+ pattern: /pickle\.loads?\s*\(/i,
725
+ category: 'deserialization',
726
+ },
727
+ {
728
+ id: 'PROMPT_INJECTION',
729
+ title: 'Prompt injection vector',
730
+ severity: 'high',
731
+ pattern: /(?:<IMPORTANT>|<SYSTEM>|ignore previous|you are now|new instructions)/i,
732
+ category: 'prompt-injection',
733
+ },
734
+ ];
735
+
736
+ for (const file of files) {
737
+ for (const check of checks) {
738
+ const match = check.pattern.exec(file.content);
739
+ if (match) {
740
+ // Find line number
741
+ const lines = file.content.slice(0, match.index).split('\n');
742
+ findings.push({
743
+ ...check,
744
+ file: file.path,
745
+ line: lines.length,
746
+ snippet: match[0].trim().slice(0, 80),
747
+ confidence: 'medium',
748
+ });
749
+ }
750
+ }
751
+ }
752
+
753
+ return findings;
754
+ }
755
+
756
+ // ── Registry check ──────────────────────────────────────
757
+
758
+ async function checkRegistry(slug) {
759
+ try {
760
+ const res = await fetch(`${REGISTRY_URL}/api/skills/${encodeURIComponent(slug)}`, {
761
+ signal: AbortSignal.timeout(5000),
762
+ });
763
+ if (res.ok) return await res.json();
764
+ } catch {}
765
+ return null;
766
+ }
767
+
768
+ // ── Print results ───────────────────────────────────────
769
+
770
+ function printScanResult(url, info, files, findings, registryData, duration) {
771
+ if (jsonMode) return; // JSON mode handles output separately
772
+
773
+ const slug = slugFromUrl(url);
774
+
775
+ // Quiet mode: compact one-line-per-package output
776
+ if (quietMode) {
777
+ if (findings.length > 0) {
778
+ const bySev = {};
779
+ for (const f of findings) { bySev[f.severity] = (bySev[f.severity] || 0) + 1; }
780
+ const sevStr = Object.entries(bySev).map(([s, n]) => {
781
+ const sc = severityColor(s);
782
+ return `${sc}${n} ${s}${c.reset}`;
783
+ }).join(', ');
784
+ console.log(`${icons.caution} ${c.bold}${slug}${c.reset} ${findings.length} findings (${sevStr}) ${c.dim}${duration}${c.reset}`);
785
+ for (const f of findings) {
786
+ const sc = severityColor(f.severity);
787
+ console.log(` ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title} ${c.dim}${f.file}:${f.line}${c.reset}`);
788
+ }
789
+ } else {
790
+ console.log(`${icons.safe} ${c.bold}${slug}${c.reset} ${c.green}clean${c.reset} ${c.dim}${files.length} files, ${duration}${c.reset}`);
791
+ }
792
+ return;
793
+ }
794
+
795
+ // Header
796
+ console.log(`${icons.scan} ${c.bold}${slug}${c.reset} ${c.dim}${url}${c.reset}`);
797
+ console.log(`${icons.pipe} ${c.dim}${info.language} ${info.type}${c.reset} ${c.dim}${files.length} files scanned in ${duration}${c.reset}`);
798
+
799
+ // Tools & prompts tree
800
+ const items = [
801
+ ...info.tools.map(t => ({ kind: 'tool', name: t })),
802
+ ...info.prompts.map(p => ({ kind: 'prompt', name: p })),
803
+ ];
804
+
805
+ if (items.length > 0) {
806
+ console.log(`${icons.pipe}`);
807
+ for (let i = 0; i < items.length; i++) {
808
+ const isLast = i === items.length - 1 && findings.length === 0;
809
+ const branch = isLast ? icons.treeLast : icons.tree;
810
+ const item = items[i];
811
+ const kindLabel = item.kind === 'tool' ? `${c.dim}tool${c.reset} ` : `${c.dim}prompt${c.reset}`;
812
+ const padName = item.name.padEnd(28);
813
+
814
+ // Check if this tool has a finding associated
815
+ const toolFinding = findings.find(f =>
816
+ f.snippet && f.snippet.toLowerCase().includes(item.name.toLowerCase())
817
+ );
818
+
819
+ if (toolFinding) {
820
+ const sc = severityColor(toolFinding.severity);
821
+ console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${sc}⚠ flagged${c.reset} — ${toolFinding.title}`);
822
+ } else {
823
+ console.log(`${branch} ${kindLabel} ${c.bold}${padName}${c.reset} ${c.green}✔ ok${c.reset}`);
824
+ }
825
+ }
826
+ } else {
827
+ console.log(`${icons.pipe} ${c.dim}(no tools or prompts detected)${c.reset}`);
828
+ }
829
+
830
+ // Findings
831
+ if (findings.length > 0) {
832
+ console.log(`${icons.pipe}`);
833
+ console.log(`${icons.pipe} ${c.bold}Findings (${findings.length})${c.reset} ${c.dim}static analysis — may include false positives${c.reset}`);
834
+ for (let i = 0; i < findings.length; i++) {
835
+ const f = findings[i];
836
+ const isLast = i === findings.length - 1;
837
+ const branch = isLast ? icons.treeLast : icons.tree;
838
+ const pipeOrSpace = isLast ? ' ' : `${icons.pipe} `;
839
+ const sc = severityColor(f.severity);
840
+ console.log(`${branch} ${severityIcon(f.severity)} ${sc}${f.severity.toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
841
+ console.log(`${pipeOrSpace} ${c.dim}${f.file}:${f.line}${c.reset} ${c.dim}${f.snippet || ''}${c.reset}`);
842
+ }
843
+ }
844
+
845
+ // Registry status
846
+ console.log(`${icons.pipe}`);
847
+ if (registryData) {
848
+ const rd = registryData;
849
+ const riskScore = rd.risk_score ?? rd.latest_risk_score ?? 0;
850
+ console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${riskBadge(riskScore)} Risk ${riskScore} ${c.dim}${REGISTRY_URL}/skills/${slug}${c.reset}`);
851
+ } else {
852
+ console.log(`${icons.treeLast} ${c.dim}registry${c.reset} ${c.dim}not audited yet${c.reset}`);
853
+ }
854
+
855
+ console.log();
856
+ }
857
+
858
+ function printSummary(results) {
859
+ const total = results.length;
860
+ const safe = results.filter(r => r.findings.length === 0).length;
861
+ const withFindings = total - safe;
862
+ const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
863
+
864
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
865
+ console.log(` ${c.bold}Summary${c.reset} ${total} packages scanned`);
866
+ console.log();
867
+ if (safe > 0) console.log(` ${icons.safe} ${c.green}${safe} clean${c.reset}`);
868
+ if (withFindings > 0) console.log(` ${icons.caution} ${c.yellow}${withFindings} with findings${c.reset} (${totalFindings} total)`);
869
+
870
+ // Breakdown by severity
871
+ const bySev = {};
872
+ results.forEach(r => r.findings.forEach(f => {
873
+ bySev[f.severity] = (bySev[f.severity] || 0) + 1;
874
+ }));
875
+ if (Object.keys(bySev).length > 0) {
876
+ console.log();
877
+ for (const sev of ['critical', 'high', 'medium', 'low']) {
878
+ if (bySev[sev]) {
879
+ console.log(` ${severityIcon(sev)} ${bySev[sev]}× ${severityColor(sev)}${sev}${c.reset}`);
880
+ }
881
+ }
882
+ }
883
+
884
+ console.log();
885
+ }
886
+
887
+ // ── Clone & Scan ────────────────────────────────────────
888
+
889
+ async function scanRepo(url) {
890
+ const start = Date.now();
891
+ const slug = slugFromUrl(url);
892
+
893
+ if (!jsonMode) process.stdout.write(`${icons.scan} Scanning ${c.bold}${slug}${c.reset} ${c.dim}...${c.reset}`);
894
+
895
+ // Clone
896
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
897
+ const repoPath = path.join(tmpDir, 'repo');
898
+ try {
899
+ execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
900
+ timeout: 30_000,
901
+ stdio: 'pipe',
902
+ });
903
+ } catch (err) {
904
+ if (!jsonMode) {
905
+ process.stdout.write(` ${c.red}✖ clone failed${c.reset}\n`);
906
+ const msg = err.stderr?.toString().trim() || err.message?.split('\n')[0] || '';
907
+ if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
908
+ console.log(` ${c.dim}Make sure git is installed and the URL is accessible.${c.reset}`);
909
+ }
910
+ return null;
911
+ }
912
+
913
+ // Collect files
914
+ const files = collectFiles(repoPath);
915
+
916
+ // Detect info
917
+ const info = detectPackageInfo(repoPath, files);
918
+
919
+ // Quick checks
920
+ const findings = quickChecks(files);
921
+
922
+ // Registry lookup
923
+ const registryData = await checkRegistry(slug);
924
+
925
+ // Cleanup
926
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
927
+
928
+ const duration = elapsed(start);
929
+
930
+ if (!jsonMode) {
931
+ // Clear the "Scanning..." line
932
+ process.stdout.write('\r\x1b[K');
933
+
934
+ // Print result
935
+ printScanResult(url, info, files, findings, registryData, duration);
936
+ }
937
+
938
+ return { slug, url, info, files: files.length, findings, registryData, duration };
939
+ }
940
+
941
+ // ── Discover local MCP configs ──────────────────────────
942
+
943
+ function findMcpConfigs() {
944
+ const home = process.env.HOME || process.env.USERPROFILE || '';
945
+ const platform = process.platform;
946
+
947
+ // All known MCP config locations
948
+ const candidates = [
949
+ // Claude Desktop
950
+ { name: 'Claude Desktop', path: path.join(home, '.claude', 'mcp.json') },
951
+ { name: 'Claude Desktop', path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') },
952
+ { name: 'Claude Desktop', path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json') },
953
+ { name: 'Claude Desktop', path: path.join(home, '.config', 'claude', 'claude_desktop_config.json') },
954
+ // Cursor
955
+ { name: 'Cursor', path: path.join(home, '.cursor', 'mcp.json') },
956
+ // Windsurf / Codeium
957
+ { name: 'Windsurf', path: path.join(home, '.codeium', 'windsurf', 'mcp_config.json') },
958
+ // VS Code
959
+ { name: 'VS Code', path: path.join(home, '.vscode', 'mcp.json') },
960
+ // Continue.dev
961
+ { name: 'Continue', path: path.join(home, '.continue', 'config.json') },
962
+ ];
963
+
964
+ // Also check AGENTAUDIT_TEST_CONFIG env for testing
965
+ if (process.env.AGENTAUDIT_TEST_CONFIG) {
966
+ candidates.push({ name: 'Test Config', path: process.env.AGENTAUDIT_TEST_CONFIG });
967
+ }
968
+
969
+ // Also scan workspace .cursor/mcp.json, .vscode/mcp.json in cwd
970
+ const cwd = process.cwd();
971
+ candidates.push(
972
+ { name: 'Cursor (project)', path: path.join(cwd, '.cursor', 'mcp.json') },
973
+ { name: 'VS Code (project)', path: path.join(cwd, '.vscode', 'mcp.json') },
974
+ );
975
+
976
+ const found = [];
977
+ for (const c of candidates) {
978
+ if (fs.existsSync(c.path)) {
979
+ try {
980
+ const content = JSON.parse(fs.readFileSync(c.path, 'utf8'));
981
+ found.push({ ...c, content });
982
+ } catch {}
983
+ }
984
+ }
985
+ return found;
986
+ }
987
+
988
+ function extractServersFromConfig(config) {
989
+ // Handle both { mcpServers: {...} } and { servers: {...} } formats
990
+ const servers = config.mcpServers || config.servers || {};
991
+ const result = [];
992
+
993
+ for (const [name, serverConfig] of Object.entries(servers)) {
994
+ const info = {
995
+ name,
996
+ command: serverConfig.command || null,
997
+ args: serverConfig.args || [],
998
+ url: serverConfig.url || null,
999
+ sourceUrl: null,
1000
+ };
1001
+
1002
+ // Try to extract source URL from args (common patterns)
1003
+ const allArgs = [info.command, ...info.args].filter(Boolean).join(' ');
1004
+
1005
+ // npx package-name → npm package
1006
+ const npxMatch = allArgs.match(/npx\s+(?:-y\s+)?(@?[a-z0-9][\w./-]*)/i);
1007
+ if (npxMatch) info.npmPackage = npxMatch[1];
1008
+
1009
+ // node /path/to/something try to find package.json
1010
+ const nodePathMatch = allArgs.match(/node\s+["']?([^"'\s]+)/);
1011
+ if (nodePathMatch) {
1012
+ const scriptPath = nodePathMatch[1];
1013
+ // Walk up to find package.json with repository
1014
+ let dir = path.dirname(path.resolve(scriptPath));
1015
+ for (let i = 0; i < 5; i++) {
1016
+ const pkgPath = path.join(dir, 'package.json');
1017
+ if (fs.existsSync(pkgPath)) {
1018
+ try {
1019
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1020
+ if (pkg.repository?.url) {
1021
+ info.sourceUrl = pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
1022
+ }
1023
+ if (pkg.name) info.npmPackage = pkg.name;
1024
+ } catch {}
1025
+ break;
1026
+ }
1027
+ const parent = path.dirname(dir);
1028
+ if (parent === dir) break;
1029
+ dir = parent;
1030
+ }
1031
+ }
1032
+
1033
+ // python/uvx with package name
1034
+ const pyMatch = allArgs.match(/(?:uvx|pip run|python -m)\s+(@?[a-z0-9][\w./-]*)/i);
1035
+ if (pyMatch) info.pyPackage = pyMatch[1];
1036
+
1037
+ // URL-based MCP server (remote HTTP)
1038
+ if (info.url && !info.npmPackage && !info.pyPackage) {
1039
+ try {
1040
+ const parsed = new URL(info.url);
1041
+ // Extract service name from hostname: mcp.supabase.com → supabase
1042
+ const hostParts = parsed.hostname.split('.');
1043
+ if (hostParts.length >= 2) {
1044
+ const serviceName = hostParts.length === 3 ? hostParts[1] : hostParts[0];
1045
+ info.remoteService = serviceName;
1046
+ }
1047
+ } catch {}
1048
+ }
1049
+
1050
+ result.push(info);
1051
+ }
1052
+ return result;
1053
+ }
1054
+
1055
+ function serverSlug(server) {
1056
+ // Try to derive a slug for registry lookup
1057
+ if (server.npmPackage) return server.npmPackage.replace(/^@/, '').replace(/\//g, '-');
1058
+ if (server.pyPackage) return server.pyPackage.replace(/[^a-z0-9-]/gi, '-');
1059
+ return server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
1060
+ }
1061
+
1062
+ async function searchGitHub(query) {
1063
+ try {
1064
+ const res = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=1`, {
1065
+ signal: AbortSignal.timeout(5000),
1066
+ headers: { 'Accept': 'application/vnd.github+json' },
1067
+ });
1068
+ if (res.ok) {
1069
+ const data = await res.json();
1070
+ if (data.items?.length > 0) {
1071
+ return data.items[0].html_url;
1072
+ }
1073
+ }
1074
+ } catch {}
1075
+ return null;
1076
+ }
1077
+
1078
+ async function resolveSourceUrl(server) {
1079
+ // Already have it
1080
+ if (server.sourceUrl) return server.sourceUrl;
1081
+
1082
+ // Try npm registry
1083
+ if (server.npmPackage) {
1084
+ try {
1085
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(server.npmPackage)}`, {
1086
+ signal: AbortSignal.timeout(5000),
1087
+ });
1088
+ if (res.ok) {
1089
+ const data = await res.json();
1090
+ let repoUrl = data.repository?.url;
1091
+ if (repoUrl) {
1092
+ repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
1093
+ if (repoUrl.startsWith('http')) return repoUrl;
1094
+ }
1095
+ }
1096
+ } catch {}
1097
+ // Fallback: try GitHub search for the package name
1098
+ const ghUrl = await searchGitHub(server.npmPackage);
1099
+ if (ghUrl) return ghUrl;
1100
+ return `https://www.npmjs.com/package/${server.npmPackage}`;
1101
+ }
1102
+
1103
+ // Try PyPI
1104
+ if (server.pyPackage) {
1105
+ try {
1106
+ const res = await fetch(`https://pypi.org/pypi/${encodeURIComponent(server.pyPackage)}/json`, {
1107
+ signal: AbortSignal.timeout(5000),
1108
+ });
1109
+ if (res.ok) {
1110
+ const data = await res.json();
1111
+ const urls = data.info?.project_urls || {};
1112
+ const source = urls.Source || urls.Repository || urls.Homepage || urls['Source Code'] || data.info?.home_page;
1113
+ if (source && source.startsWith('http')) return source;
1114
+ }
1115
+ } catch {}
1116
+ // Fallback: GitHub search
1117
+ const ghUrl = await searchGitHub(server.pyPackage);
1118
+ if (ghUrl) return ghUrl;
1119
+ return `https://pypi.org/project/${server.pyPackage}/`;
1120
+ }
1121
+
1122
+ // URL-based remote MCP server — try GitHub search by service name
1123
+ if (server.remoteService) {
1124
+ // Try npm registry with common MCP naming patterns
1125
+ for (const tryName of [
1126
+ `@${server.remoteService}/mcp-server-${server.remoteService}`,
1127
+ `${server.remoteService}-mcp`,
1128
+ `mcp-server-${server.remoteService}`,
1129
+ server.remoteService,
1130
+ ]) {
1131
+ try {
1132
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(tryName)}`, {
1133
+ signal: AbortSignal.timeout(3000),
1134
+ });
1135
+ if (res.ok) {
1136
+ const data = await res.json();
1137
+ let repoUrl = data.repository?.url;
1138
+ if (repoUrl) {
1139
+ repoUrl = repoUrl.replace(/^git\+/, '').replace(/\.git$/, '').replace(/^ssh:\/\/git@github\.com/, 'https://github.com');
1140
+ if (repoUrl.startsWith('http')) return repoUrl;
1141
+ }
1142
+ }
1143
+ } catch {}
1144
+ }
1145
+ }
1146
+
1147
+ // Last resort: if server has a url, show it as context
1148
+ if (server.url) {
1149
+ try {
1150
+ const parsed = new URL(server.url);
1151
+ return `https://github.com/search?q=${encodeURIComponent(parsed.hostname + ' MCP')}&type=repositories`;
1152
+ } catch {}
1153
+ }
1154
+
1155
+ return null;
1156
+ }
1157
+
1158
+ async function discoverCommand(options = {}) {
1159
+ const autoScan = options.scan || false;
1160
+ const interactiveAudit = options.audit || false;
1161
+
1162
+ if (!jsonMode) {
1163
+ console.log(` ${c.bold}Discovering MCP servers in your AI editors...${c.reset}`);
1164
+ console.log();
1165
+ }
1166
+
1167
+ const configs = findMcpConfigs();
1168
+
1169
+ if (configs.length === 0) {
1170
+ console.log(` ${c.yellow}No MCP configurations found.${c.reset}`);
1171
+ console.log(` ${c.dim}Searched: Claude Desktop, Cursor, Windsurf, VS Code${c.reset}`);
1172
+ console.log();
1173
+ console.log(` ${c.dim}MCP config locations:${c.reset}`);
1174
+ console.log(` ${c.dim} Claude: ~/.claude/mcp.json${c.reset}`);
1175
+ console.log(` ${c.dim} Cursor: ~/.cursor/mcp.json${c.reset}`);
1176
+ console.log(` ${c.dim} Windsurf: ~/.codeium/windsurf/mcp_config.json${c.reset}`);
1177
+ console.log(` ${c.dim} VS Code: ~/.vscode/mcp.json${c.reset}`);
1178
+ console.log();
1179
+ return;
1180
+ }
1181
+
1182
+ let totalServers = 0;
1183
+ let checkedServers = 0;
1184
+ let auditedServers = 0;
1185
+ let unauditedServers = 0;
1186
+ const unauditedWithUrls = [];
1187
+ const allServersWithUrls = []; // For --scan: all servers we can scan
1188
+
1189
+ for (const config of configs) {
1190
+ const servers = extractServersFromConfig(config.content);
1191
+ const serverCount = servers.length;
1192
+ totalServers += serverCount;
1193
+
1194
+ const countLabel = serverCount === 0
1195
+ ? `${c.dim}no servers${c.reset}`
1196
+ : `found ${c.bold}${serverCount}${c.reset} server${serverCount > 1 ? 's' : ''}`;
1197
+
1198
+ console.log(`${icons.bullet} Scanning ${c.bold}${config.name}${c.reset} ${c.dim}${config.path}${c.reset} ${countLabel}`);
1199
+
1200
+ if (serverCount === 0) {
1201
+ console.log();
1202
+ continue;
1203
+ }
1204
+
1205
+ console.log();
1206
+
1207
+ for (let i = 0; i < servers.length; i++) {
1208
+ const server = servers[i];
1209
+ const isLast = i === servers.length - 1;
1210
+ const branch = isLast ? icons.treeLast : icons.tree;
1211
+ const pipe = isLast ? ' ' : `${icons.pipe} `;
1212
+
1213
+ const slug = serverSlug(server);
1214
+ checkedServers++;
1215
+
1216
+ // Registry lookup
1217
+ const registryData = await checkRegistry(slug);
1218
+
1219
+ // Also try with server name directly
1220
+ let regData = registryData;
1221
+ if (!regData && slug !== server.name.toLowerCase()) {
1222
+ regData = await checkRegistry(server.name.toLowerCase());
1223
+ }
1224
+
1225
+ // Determine source display
1226
+ let sourceLabel = '';
1227
+ if (server.npmPackage) sourceLabel = `${c.dim}npm:${server.npmPackage}${c.reset}`;
1228
+ else if (server.pyPackage) sourceLabel = `${c.dim}pip:${server.pyPackage}${c.reset}`;
1229
+ else if (server.url) sourceLabel = `${c.dim}${server.url.length > 60 ? server.url.slice(0, 57) + '...' : server.url}${c.reset}`;
1230
+ else if (server.command) sourceLabel = `${c.dim}${[server.command, ...server.args.slice(0, 2)].join(' ')}${c.reset}`;
1231
+
1232
+ // Always resolve source URL (needed for --scan)
1233
+ const resolvedUrl = await resolveSourceUrl(server);
1234
+
1235
+ if (regData) {
1236
+ auditedServers++;
1237
+ const riskScore = regData.risk_score ?? regData.latest_risk_score ?? 0;
1238
+ const hasOfficial = regData.has_official_audit;
1239
+ console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1240
+ console.log(`${pipe} ${riskBadge(riskScore)} Risk ${riskScore} ${hasOfficial ? `${c.green}✔ official${c.reset} ` : ''}${c.dim}${REGISTRY_URL}/skills/${slug}${c.reset}`);
1241
+ if (resolvedUrl) allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: true, regData });
1242
+ } else {
1243
+ unauditedServers++;
1244
+ console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
1245
+ if (resolvedUrl) {
1246
+ console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Run: ${c.cyan}agentaudit audit ${resolvedUrl}${c.reset}`);
1247
+ unauditedWithUrls.push({ name: server.name, sourceUrl: resolvedUrl });
1248
+ allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: false });
1249
+ } else {
1250
+ console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Source URL unknown check the package's GitHub/npm page${c.reset}`);
1251
+ }
1252
+ }
1253
+
1254
+ if (server.sourceUrl && !server.sourceUrl.includes('npmjs.com')) {
1255
+ console.log(`${pipe} ${c.dim}source: ${server.sourceUrl}${c.reset}`);
1256
+ }
1257
+ }
1258
+
1259
+ console.log();
1260
+ }
1261
+
1262
+ // Summary
1263
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1264
+ console.log(` ${c.bold}Summary${c.reset} ${totalServers} server${totalServers !== 1 ? 's' : ''} across ${configs.length} config${configs.length !== 1 ? 's' : ''}`);
1265
+ console.log();
1266
+ if (auditedServers > 0) console.log(` ${icons.safe} ${c.green}${auditedServers} audited${c.reset}`);
1267
+ if (unauditedServers > 0) console.log(` ${icons.caution} ${c.yellow}${unauditedServers} not audited${c.reset}`);
1268
+ console.log();
1269
+
1270
+ // --scan: automatically scan all servers with resolved source URLs (git-cloneable only)
1271
+ if (autoScan) {
1272
+ const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1273
+ const scanTargets = allServersWithUrls.filter(s => s.sourceUrl && isCloneable(s.sourceUrl));
1274
+ // Deduplicate by sourceUrl
1275
+ const seen = new Set();
1276
+ const dedupedTargets = scanTargets.filter(s => {
1277
+ if (seen.has(s.sourceUrl)) return false;
1278
+ seen.add(s.sourceUrl);
1279
+ return true;
1280
+ });
1281
+ const skipped = allServersWithUrls.filter(s => s.sourceUrl && !isCloneable(s.sourceUrl));
1282
+ if (dedupedTargets.length > 0) {
1283
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1284
+ console.log(` ${c.bold}${icons.scan} Auto-scanning ${dedupedTargets.length} server${dedupedTargets.length !== 1 ? 's' : ''}...${c.reset}`);
1285
+ if (skipped.length > 0) {
1286
+ console.log(` ${c.dim}(${skipped.length} skipped — no cloneable source URL)${c.reset}`);
1287
+ }
1288
+ console.log();
1289
+
1290
+ const scanResults = [];
1291
+ for (const target of dedupedTargets) {
1292
+ const result = await scanRepo(target.sourceUrl);
1293
+ if (result) scanResults.push({ ...result, serverName: target.name });
1294
+ }
1295
+
1296
+ if (scanResults.length > 1) {
1297
+ // Print combined scan summary
1298
+ console.log(`${c.dim}${'─'.repeat(60)}${c.reset}`);
1299
+ console.log(` ${c.bold}Scan Summary${c.reset} ${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} scanned`);
1300
+ console.log();
1301
+
1302
+ let totalFindings = 0;
1303
+ let serversWithFindings = 0;
1304
+
1305
+ for (const r of scanResults) {
1306
+ const findingCount = r.findings ? r.findings.length : 0;
1307
+ totalFindings += findingCount;
1308
+ if (findingCount > 0) serversWithFindings++;
1309
+
1310
+ const status = findingCount === 0
1311
+ ? `${icons.safe} ${c.green}clean${c.reset}`
1312
+ : `${icons.caution} ${c.yellow}${findingCount} finding${findingCount !== 1 ? 's' : ''}${c.reset}`;
1313
+ console.log(` ${status} ${c.bold}${r.serverName || r.slug}${c.reset} ${c.dim}(${r.duration})${c.reset}`);
1314
+ }
1315
+
1316
+ console.log();
1317
+ if (serversWithFindings > 0) {
1318
+ console.log(` ${c.yellow}${serversWithFindings}/${scanResults.length} server${scanResults.length !== 1 ? 's' : ''} with findings (${totalFindings} total)${c.reset}`);
1319
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit scan <url> --deep${c.dim} for deep LLM analysis on flagged servers${c.reset}`);
1320
+ } else {
1321
+ console.log(` ${c.green}All servers passed quick scan${c.reset}`);
1322
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit scan <url> --deep${c.dim} for thorough LLM-powered analysis${c.reset}`);
1323
+ }
1324
+ console.log();
1325
+ }
1326
+ } else {
1327
+ console.log(` ${c.dim}No scannable source URLs found.${c.reset}`);
1328
+ console.log();
1329
+ }
1330
+ } else if (interactiveAudit && allServersWithUrls.length > 0) {
1331
+ // Interactive multi-select for audit
1332
+ const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
1333
+ const auditCandidates = [];
1334
+ const seen = new Set();
1335
+ for (const s of allServersWithUrls) {
1336
+ if (!s.sourceUrl || !isCloneable(s.sourceUrl)) continue;
1337
+ if (seen.has(s.sourceUrl)) continue;
1338
+ seen.add(s.sourceUrl);
1339
+ auditCandidates.push(s);
1340
+ }
1341
+
1342
+ if (auditCandidates.length > 0) {
1343
+ console.log();
1344
+ const items = auditCandidates.map(s => ({
1345
+ label: s.name,
1346
+ sublabel: s.hasAudit ? `${c.green} audited${c.reset} ${s.sourceUrl}` : s.sourceUrl,
1347
+ value: s,
1348
+ checked: !s.hasAudit, // Pre-select unaudited
1349
+ }));
1350
+
1351
+ const selected = await multiSelect(items, {
1352
+ title: 'Select servers to audit',
1353
+ hint: 'Space=toggle ↑↓=move a=all n=none Enter=confirm',
1354
+ });
1355
+
1356
+ if (selected.length > 0) {
1357
+ console.log();
1358
+ console.log(` ${c.bold}Auditing ${selected.length} server${selected.length !== 1 ? 's' : ''}...${c.reset}`);
1359
+ console.log();
1360
+ for (const s of selected) {
1361
+ await auditRepo(s.sourceUrl);
1362
+ console.log();
1363
+ }
1364
+ } else {
1365
+ console.log();
1366
+ console.log(` ${c.dim}No servers selected.${c.reset}`);
1367
+ }
1368
+ }
1369
+ } else if (unauditedServers > 0) {
1370
+ if (unauditedWithUrls.length > 0) {
1371
+ console.log(` ${c.dim}To audit unaudited servers:${c.reset}`);
1372
+ for (const { name, sourceUrl } of unauditedWithUrls) {
1373
+ console.log(` ${c.cyan}agentaudit audit ${sourceUrl}${c.reset} ${c.dim}(${name})${c.reset}`);
1374
+ }
1375
+ } else {
1376
+ console.log(` ${c.dim}To audit unaudited servers, run:${c.reset}`);
1377
+ console.log(` ${c.cyan}agentaudit audit <source-url>${c.reset}`);
1378
+ }
1379
+ console.log();
1380
+ console.log(` ${c.dim}Or run ${c.cyan}agentaudit discover --quick${c.dim} to quick-scan all servers${c.reset}`);
1381
+ console.log(` ${c.dim}Or run ${c.cyan}agentaudit discover --deep${c.dim} to select & deep-audit interactively${c.reset}`);
1382
+ console.log();
1383
+ }
1384
+
1385
+ if (!autoScan && !interactiveAudit && !jsonMode) {
1386
+ console.log(` ${c.dim}Looking for general package scanning? Try ${c.cyan}pip audit${c.dim} or ${c.cyan}npm audit${c.dim}.${c.reset}`);
1387
+ console.log();
1388
+ }
1389
+ }
1390
+
1391
+ // ── Audit command (deep LLM-powered) ────────────────────
1392
+
1393
+ function loadAuditPrompt() {
1394
+ const promptPath = path.join(SKILL_DIR, 'prompts', 'audit-prompt.md');
1395
+ if (fs.existsSync(promptPath)) return fs.readFileSync(promptPath, 'utf8');
1396
+ return null;
1397
+ }
1398
+
1399
+ async function auditRepo(url) {
1400
+ const start = Date.now();
1401
+ const slug = slugFromUrl(url);
1402
+
1403
+ console.log(`${icons.scan} ${c.bold}Auditing ${slug}${c.reset} ${c.dim}${url}${c.reset}`);
1404
+ console.log(`${icons.pipe} ${c.dim}Deep LLM-powered analysis (3-pass: UNDERSTAND → DETECT → CLASSIFY)${c.reset}`);
1405
+ console.log();
1406
+
1407
+ // Step 1: Clone
1408
+ process.stdout.write(` ${c.dim}[1/4]${c.reset} Cloning repository...`);
1409
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-'));
1410
+ const repoPath = path.join(tmpDir, 'repo');
1411
+ try {
1412
+ execSync(`git clone --depth 1 "${url}" "${repoPath}"`, {
1413
+ timeout: 30_000, stdio: 'pipe',
1414
+ });
1415
+ console.log(` ${c.green}done${c.reset}`);
1416
+ } catch (err) {
1417
+ console.log(` ${c.red}failed${c.reset}`);
1418
+ const msg = err.stderr?.toString().trim() || err.message?.split('\n')[0] || '';
1419
+ if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
1420
+ console.log(` ${c.dim}Make sure git is installed and the URL is accessible.${c.reset}`);
1421
+ return null;
1422
+ }
1423
+
1424
+ // Capture version + commit from cloned repo
1425
+ let repoCommitSha = null;
1426
+ let repoPackageVersion = null;
1427
+ try { repoCommitSha = execSync('git rev-parse HEAD', { cwd: repoPath, stdio: 'pipe' }).toString().trim(); } catch {}
1428
+ try {
1429
+ const pkgPath = path.join(repoPath, 'package.json');
1430
+ if (fs.existsSync(pkgPath)) {
1431
+ repoPackageVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version || null;
1432
+ }
1433
+ } catch {}
1434
+
1435
+ // Step 2: Collect files
1436
+ process.stdout.write(` ${c.dim}[2/4]${c.reset} Collecting source files...`);
1437
+ const files = collectFiles(repoPath);
1438
+ console.log(` ${c.green}${files.length} files${c.reset}`);
1439
+
1440
+ // Step 3: Build audit payload
1441
+ process.stdout.write(` ${c.dim}[3/4]${c.reset} Preparing audit payload...`);
1442
+ const auditPrompt = loadAuditPrompt();
1443
+
1444
+ let codeBlock = '';
1445
+ for (const file of files) {
1446
+ codeBlock += `\n### FILE: ${file.path}\n\`\`\`\n${file.content}\n\`\`\`\n`;
1447
+ }
1448
+ console.log(` ${c.green}done${c.reset}`);
1449
+
1450
+ // Step 4: LLM Analysis
1451
+ // Check for API keys to determine which LLM to use
1452
+ const anthropicKey = process.env.ANTHROPIC_API_KEY;
1453
+ const openaiKey = process.env.OPENAI_API_KEY;
1454
+ const openrouterKey = process.env.OPENROUTER_API_KEY;
1455
+ const openrouterModel = process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
1456
+
1457
+ // --provider flag overrides auto-detection
1458
+ const providerFlag = process.argv.find(a => a.startsWith('--provider='))?.split('=')[1]?.toLowerCase()
1459
+ || (process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1]?.toLowerCase() : null);
1460
+
1461
+ const resolvedProvider = resolveProvider(providerFlag, { anthropicKey, openaiKey, openrouterKey });
1462
+ const activeProvider = resolvedProvider?.label || null;
1463
+
1464
+ if (!resolvedProvider) {
1465
+ // No LLM API key — clear explanation
1466
+ console.log();
1467
+ console.log(` ${c.yellow}No LLM provider configured.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
1468
+ console.log();
1469
+ console.log(` ${c.bold}Option 1: Set an API key${c.reset} ${c.dim}(any one of these)${c.reset}`);
1470
+ console.log(` ${c.cyan}ANTHROPIC_API_KEY${c.reset} Anthropic Claude ${c.dim}(recommended)${c.reset}`);
1471
+ console.log(` ${c.cyan}OPENAI_API_KEY${c.reset} OpenAI GPT-4o`);
1472
+ console.log(` ${c.cyan}OPENROUTER_API_KEY${c.reset} OpenRouter ${c.dim}(200+ models)${c.reset}`);
1473
+ console.log(` ${c.cyan}OLLAMA_MODEL${c.reset} Ollama ${c.dim}(local, free, set model name)${c.reset}`);
1474
+ console.log(` ${c.cyan}LLM_API_URL${c.reset} Any OpenAI-compatible API ${c.dim}(+ LLM_API_KEY, LLM_MODEL)${c.reset}`);
1475
+ console.log();
1476
+ console.log(` ${c.dim}# Linux / macOS:${c.reset}`);
1477
+ console.log(` ${c.dim}export ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1478
+ console.log(` ${c.dim}export OPENAI_API_KEY=sk-...${c.reset}`);
1479
+ console.log();
1480
+ console.log(` ${c.dim}# Windows (PowerShell):${c.reset}`);
1481
+ console.log(` ${c.dim}$env:ANTHROPIC_API_KEY = "sk-ant-..."${c.reset}`);
1482
+ console.log(` ${c.dim}$env:OPENAI_API_KEY = "sk-..."${c.reset}`);
1483
+ console.log();
1484
+ console.log(` ${c.dim}# Windows (CMD):${c.reset}`);
1485
+ console.log(` ${c.dim}set ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
1486
+ console.log(` ${c.dim}set OPENAI_API_KEY=sk-...${c.reset}`);
1487
+ console.log();
1488
+ console.log(` ${c.bold}Option 2: Export for manual review${c.reset}`);
1489
+ console.log(` ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
1490
+ console.log(` ${c.dim}Creates a markdown file you can paste into any LLM (Claude, ChatGPT, etc.)${c.reset}`);
1491
+ console.log();
1492
+ console.log(` ${c.bold}Option 3: Use MCP in Claude/Cursor/Windsurf (no API key needed)${c.reset}`);
1493
+ console.log(` ${c.dim}Add AgentAudit as MCP server — your editor's agent runs the audit using its own LLM.${c.reset}`);
1494
+ console.log(` ${c.dim}Config: { "mcpServers": { "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } } }${c.reset}`);
1495
+ console.log();
1496
+
1497
+ // Check if --export flag
1498
+ if (process.argv.includes('--export')) {
1499
+ const exportPath = path.join(process.cwd(), `audit-${slug}.md`);
1500
+ const exportContent = [
1501
+ `# Security Audit: ${slug}`,
1502
+ `**Source:** ${url}`,
1503
+ `**Files:** ${files.length}`,
1504
+ ``,
1505
+ `## Audit Instructions`,
1506
+ ``,
1507
+ auditPrompt || '(audit prompt not found)',
1508
+ ``,
1509
+ `## Report Format`,
1510
+ ``,
1511
+ `After analysis, produce a JSON report:`,
1512
+ '```json',
1513
+ `{ "skill_slug": "${slug}", "source_url": "${url}", "risk_score": 0, "result": "safe", "findings": [] }`,
1514
+ '```',
1515
+ ``,
1516
+ `## Source Code`,
1517
+ ``,
1518
+ codeBlock,
1519
+ ].join('\n');
1520
+ fs.writeFileSync(exportPath, exportContent);
1521
+ console.log(` ${icons.safe} Exported to ${c.bold}${exportPath}${c.reset}`);
1522
+ console.log(` ${c.dim}Paste this into any LLM (Claude, ChatGPT, etc.) for analysis${c.reset}`);
1523
+ }
1524
+
1525
+ // Cleanup
1526
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1527
+ return null;
1528
+ }
1529
+
1530
+ // Determine actual model name for display
1531
+ let actualModel;
1532
+ if (resolvedProvider.id === 'anthropic') {
1533
+ actualModel = modelOverride || 'claude-sonnet-4-20250514';
1534
+ } else if (resolvedProvider.id === 'openrouter') {
1535
+ actualModel = modelOverride || process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
1536
+ } else if (resolvedProvider.id === 'openai') {
1537
+ actualModel = modelOverride || 'gpt-4o';
1538
+ } else if (resolvedProvider.id === 'ollama') {
1539
+ actualModel = modelOverride || resolvedProvider.model;
1540
+ } else {
1541
+ actualModel = modelOverride || resolvedProvider.model || 'unknown';
1542
+ }
1543
+
1544
+ // We have an API key — run LLM audit
1545
+ process.stdout.write(` ${c.dim}[4/4]${c.reset} Running LLM analysis ${c.dim}(${resolvedProvider.id}: ${actualModel})${c.reset}...`);
1546
+
1547
+ const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
1548
+ const userMessage = [
1549
+ `Audit this package: **${slug}** (${url})`,
1550
+ ``,
1551
+ `After analysis, respond with ONLY a valid JSON object. No markdown fences, no explanation, no text before or after. Just the raw JSON:`,
1552
+ `{ "skill_slug": "${slug}", "source_url": "${url}", "package_type": "<mcp-server|agent-skill|library|cli-tool>",`,
1553
+ ` "risk_score": <0-100>, "result": "<safe|caution|unsafe>", "max_severity": "<none|low|medium|high|critical>",`,
1554
+ ` "findings_count": <n>, "findings": [{ "id": "...", "title": "...", "severity": "...", "category": "...",`,
1555
+ ` "description": "...", "file": "...", "line": <n>, "remediation": "...", "confidence": "...", "is_by_design": false }] }`,
1556
+ ``,
1557
+ `## Source Code`,
1558
+ codeBlock,
1559
+ ].join('\n');
1560
+
1561
+ let report = null;
1562
+ let _lastLlmText = '';
1563
+ let providerMeta = {}; // Collect provider metadata for attestation
1564
+
1565
+ try {
1566
+ if (resolvedProvider.id === 'anthropic') {
1567
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
1568
+ method: 'POST',
1569
+ headers: {
1570
+ 'x-api-key': resolvedProvider.key,
1571
+ 'anthropic-version': '2023-06-01',
1572
+ 'content-type': 'application/json',
1573
+ },
1574
+ body: JSON.stringify({
1575
+ model: modelOverride || 'claude-sonnet-4-20250514',
1576
+ max_tokens: 8192,
1577
+ system: systemPrompt,
1578
+ messages: [{ role: 'user', content: userMessage }],
1579
+ }),
1580
+ signal: AbortSignal.timeout(120_000),
1581
+ });
1582
+ const data = await res.json();
1583
+ if (data.error) {
1584
+ console.log(` ${c.red}failed${c.reset}`);
1585
+ console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1586
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1587
+ return null;
1588
+ }
1589
+ const text = data.content?.[0]?.text || '';
1590
+ _lastLlmText = text;
1591
+ report = extractJSON(text);
1592
+ providerMeta = {
1593
+ provider_msg_id: data.id || null,
1594
+ input_tokens: data.usage?.input_tokens || null,
1595
+ output_tokens: data.usage?.output_tokens || null,
1596
+ reported_model: data.model || null,
1597
+ };
1598
+ } else {
1599
+ // OpenAI, OpenRouter, Ollama, or Custom (all use OpenAI-compatible chat completions API)
1600
+ let apiUrl, modelName, authHeaders;
1601
+ switch (resolvedProvider.id) {
1602
+ case 'openrouter':
1603
+ apiUrl = 'https://openrouter.ai/api/v1/chat/completions';
1604
+ modelName = modelOverride || process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4';
1605
+ authHeaders = { 'Authorization': `Bearer ${resolvedProvider.key}`, 'HTTP-Referer': 'https://agentaudit.dev', 'X-Title': 'AgentAudit' };
1606
+ break;
1607
+ case 'ollama':
1608
+ apiUrl = `${resolvedProvider.host}/v1/chat/completions`;
1609
+ modelName = modelOverride || resolvedProvider.model;
1610
+ authHeaders = {};
1611
+ break;
1612
+ case 'custom':
1613
+ apiUrl = resolvedProvider.url.endsWith('/chat/completions') ? resolvedProvider.url : `${resolvedProvider.url.replace(/\/$/, '')}/chat/completions`;
1614
+ modelName = modelOverride || resolvedProvider.model;
1615
+ authHeaders = resolvedProvider.key ? { 'Authorization': `Bearer ${resolvedProvider.key}` } : {};
1616
+ break;
1617
+ default: // openai
1618
+ apiUrl = 'https://api.openai.com/v1/chat/completions';
1619
+ modelName = modelOverride || 'gpt-4o';
1620
+ authHeaders = { 'Authorization': `Bearer ${resolvedProvider.key}` };
1621
+ }
1622
+
1623
+ const res = await fetch(apiUrl, {
1624
+ method: 'POST',
1625
+ headers: { 'Content-Type': 'application/json', ...authHeaders },
1626
+ body: JSON.stringify({
1627
+ model: modelName,
1628
+ max_tokens: 8192,
1629
+ messages: [
1630
+ { role: 'system', content: systemPrompt },
1631
+ { role: 'user', content: userMessage },
1632
+ ],
1633
+ }),
1634
+ signal: AbortSignal.timeout(resolvedProvider.id === 'ollama' ? 300_000 : 120_000), // Ollama: 5min (local can be slow)
1635
+ });
1636
+ const data = await res.json();
1637
+ if (data.error) {
1638
+ console.log(` ${c.red}failed${c.reset}`);
1639
+ console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
1640
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1641
+ return null;
1642
+ }
1643
+ const text = data.choices?.[0]?.message?.content || '';
1644
+ _lastLlmText = text;
1645
+ report = extractJSON(text);
1646
+ providerMeta = {
1647
+ provider_msg_id: data.id || null,
1648
+ provider_fingerprint: data.system_fingerprint || null,
1649
+ input_tokens: data.usage?.prompt_tokens || null,
1650
+ output_tokens: data.usage?.completion_tokens || null,
1651
+ reported_model: data.model || null,
1652
+ };
1653
+ }
1654
+
1655
+ console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
1656
+ } catch (err) {
1657
+ console.log(` ${c.red}failed${c.reset}`);
1658
+ console.log(` ${c.red}${err.message}${c.reset}`);
1659
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1660
+ return null;
1661
+ }
1662
+
1663
+ // Cleanup repo
1664
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
1665
+
1666
+ if (!report) {
1667
+ console.log(` ${c.red}Could not parse LLM response as JSON${c.reset}`);
1668
+ console.log(` ${c.dim}Hint: run with --debug to see the raw LLM response${c.reset}`);
1669
+ if (process.argv.includes('--debug')) {
1670
+ console.log(` ${c.dim}--- Raw LLM response (first 2000 chars) ---${c.reset}`);
1671
+ console.log((typeof _lastLlmText === 'string' ? _lastLlmText : '(empty)').slice(0, 2000));
1672
+ console.log(` ${c.dim}--- end ---${c.reset}`);
1673
+ }
1674
+ return null;
1675
+ }
1676
+
1677
+ // Display results
1678
+ console.log();
1679
+ const riskScore = report.risk_score || 0;
1680
+ const trustScore = 100 - riskScore;
1681
+ const trustColor = trustScore >= 70 ? c.green : trustScore >= 40 ? c.yellow : c.red;
1682
+ const trustLabel = trustScore >= 70 ? 'SAFE' : trustScore >= 40 ? 'CAUTION' : 'UNSAFE';
1683
+ console.log(` ${trustColor}${c.bold}${trustLabel}${c.reset} ${trustColor}Trust Score: ${trustScore}/100${c.reset} ${c.dim}(Risk: ${riskScore}/100)${c.reset}`);
1684
+ console.log(` ${c.dim}Model: ${resolvedProvider.id}/${actualModel} Duration: ${elapsed(start)}${c.reset}`);
1685
+ console.log();
1686
+
1687
+ if (report.findings && report.findings.length > 0) {
1688
+ console.log(` ${c.bold}Findings (${report.findings.length})${c.reset}`);
1689
+ console.log();
1690
+ for (const f of report.findings) {
1691
+ const sc = severityColor(f.severity);
1692
+ console.log(` ${severityIcon(f.severity)} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${f.title}`);
1693
+ if (f.file) console.log(` ${c.dim}${f.file}${f.line ? ':' + f.line : ''}${c.reset}`);
1694
+ if (f.description) console.log(` ${c.dim}${f.description.slice(0, 120)}${c.reset}`);
1695
+ console.log();
1696
+ }
1697
+ } else {
1698
+ console.log(` ${c.green}No findings — package looks clean.${c.reset}`);
1699
+ console.log();
1700
+ }
1701
+
1702
+ // Upload to registry
1703
+ const creds = loadCredentials();
1704
+ if (creds) {
1705
+ process.stdout.write(` Uploading report to registry...`);
1706
+ try {
1707
+ const res = await fetch(`${REGISTRY_URL}/api/reports`, {
1708
+ method: 'POST',
1709
+ headers: {
1710
+ 'Authorization': `Bearer ${creds.api_key}`,
1711
+ 'Content-Type': 'application/json',
1712
+ },
1713
+ body: JSON.stringify({
1714
+ ...report,
1715
+ commit_sha: report.commit_sha || repoCommitSha || undefined,
1716
+ package_version: report.package_version || repoPackageVersion || undefined,
1717
+ audit_model: providerMeta.reported_model || actualModel,
1718
+ audit_provider: resolvedProvider.id,
1719
+ provider_msg_id: providerMeta.provider_msg_id || undefined,
1720
+ provider_fingerprint: providerMeta.provider_fingerprint || undefined,
1721
+ input_tokens: providerMeta.input_tokens || undefined,
1722
+ output_tokens: providerMeta.output_tokens || undefined,
1723
+ audit_duration_ms: Date.now() - start,
1724
+ }),
1725
+ signal: AbortSignal.timeout(15_000),
1726
+ });
1727
+ if (res.ok) {
1728
+ const data = await res.json();
1729
+ const reportSlug = data?.skill_slug || data?.slug || slug;
1730
+ console.log(` ${c.green}done${c.reset}`);
1731
+ console.log(` ${c.dim}Report: ${REGISTRY_URL}/skills/${reportSlug}${c.reset}`);
1732
+ // Refresh stats cache in background
1733
+ if (creds.agent_name) refreshStatsCache(creds.agent_name).catch(() => {});
1734
+ } else {
1735
+ console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
1736
+ }
1737
+ } catch (err) {
1738
+ console.log(` ${c.yellow}failed${c.reset}`);
1739
+ }
1740
+ } else {
1741
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to upload reports to the registry${c.reset}`);
1742
+ }
1743
+
1744
+ console.log();
1745
+ return report;
1746
+ }
1747
+
1748
+ // ── Check command ───────────────────────────────────────
1749
+
1750
+ async function checkPackage(name, { autoAudit = false } = {}) {
1751
+ if (!jsonMode) {
1752
+ console.log(`${icons.info} Looking up ${c.bold}${name}${c.reset} in registry...`);
1753
+ console.log();
1754
+ }
1755
+
1756
+ const data = await checkRegistry(name);
1757
+ if (!data) {
1758
+ if (!jsonMode) {
1759
+ // Auto-audit: only when called from 'check' command AND input looks like a URL
1760
+ if (autoAudit && (name.includes('github.com') || name.includes('://'))) {
1761
+ console.log(` ${c.yellow}Not found in registry.${c.reset}`);
1762
+ console.log(` ${c.dim}Starting audit for ${name}...${c.reset}`);
1763
+ console.log();
1764
+ return await auditRepo(name);
1765
+ }
1766
+ console.log(` ${c.yellow}✖ Not found${c.reset} — "${name}" hasn't been audited yet.`);
1767
+ console.log();
1768
+ console.log(` ${c.dim}Next steps:${c.reset}`);
1769
+ console.log(` ${c.cyan}agentaudit check <repo-url>${c.reset} ${c.dim}Auto-lookup + audit if not found${c.reset}`);
1770
+ console.log(` ${c.cyan}agentaudit audit <repo-url>${c.reset} ${c.dim}Deep LLM audit${c.reset}`);
1771
+ console.log(` ${c.cyan}agentaudit scan <repo-url>${c.reset} ${c.dim}Quick static check (no API key)${c.reset}`);
1772
+ }
1773
+ return null;
1774
+ }
1775
+
1776
+ if (!jsonMode) {
1777
+ const riskScore = data.risk_score ?? data.latest_risk_score ?? 0;
1778
+ const trustScore = data.trust_score ?? (100 - riskScore);
1779
+ const totalFindings = data.total_findings ?? 0;
1780
+ const totalReports = data.total_reports ?? 0;
1781
+
1782
+ // Package name + verdict
1783
+ console.log(` ${c.bold}${data.display_name || name}${c.reset} ${riskBadge(riskScore)}`);
1784
+ if (data.description) console.log(` ${c.dim}${data.description}${c.reset}`);
1785
+ console.log();
1786
+
1787
+ // Trust Score (the main metric)
1788
+ const trustColor = trustScore >= 70 ? c.green : trustScore >= 40 ? c.yellow : c.red;
1789
+ const trustLabel = trustScore >= 70 ? 'SAFE' : trustScore >= 40 ? 'CAUTION' : 'UNSAFE';
1790
+ console.log(` ${trustColor}${c.bold}${trustLabel}${c.reset} ${trustColor}Trust Score: ${trustScore}/100${c.reset} ${c.dim}(Risk: ${riskScore}/100)${c.reset}`);
1791
+
1792
+ // Findings summary
1793
+ if (totalFindings > 0) {
1794
+ const maxSev = data.latest_max_severity;
1795
+ const sevStr = maxSev ? `max severity: ${severityColor(maxSev)}${maxSev}${c.reset}` : '';
1796
+ console.log(` ${c.dim}Findings: ${totalFindings}${sevStr ? ` (${sevStr}${c.dim})` : ''}${c.reset}`);
1797
+ } else {
1798
+ console.log(` ${c.dim}Findings: 0 (clean)${c.reset}`);
1799
+ }
1800
+
1801
+ // Consensus / Confidence
1802
+ const uniqueAgents = data.unique_agents ?? 0;
1803
+ const confidence = data.confidence ?? 'unverified';
1804
+ const confidenceDisplay = {
1805
+ consensus: { icon: '🟢', label: 'Consensus Certified', color: c.green, desc: `${totalReports} reports from ${uniqueAgents} independent auditors agree` },
1806
+ verified: { icon: '🟢', label: 'Verified', color: c.green, desc: `${totalReports} reports from ${uniqueAgents} auditors` },
1807
+ low: { icon: '🟡', label: 'Low Confidence', color: c.yellow, desc: `${totalReports} reports but ${uniqueAgents <= 1 ? 'only 1 auditor' : `only ${uniqueAgents} auditors`}` },
1808
+ unverified: { icon: '🔴', label: 'Unverified', color: c.yellow, desc: 'Single audit, no independent confirmation' },
1809
+ }[confidence] || { icon: '⚪', label: confidence, color: c.dim, desc: '' };
1810
+ console.log(` ${confidenceDisplay.icon} ${confidenceDisplay.color}${confidenceDisplay.label}${c.reset} ${c.dim}${confidenceDisplay.desc}${c.reset}`);
1811
+
1812
+ // Audit info
1813
+ console.log(` ${c.dim}Reports: ${totalReports} | Auditors: ${uniqueAgents} | Last: ${data.last_audited_at ? new Date(data.last_audited_at).toLocaleDateString() : 'unknown'}${c.reset}`);
1814
+ if (data.has_official_audit) console.log(` ${c.green}✔ Officially audited${c.reset}`);
1815
+
1816
+ // Recommendation
1817
+ if (confidence === 'unverified' && trustScore >= 70) {
1818
+ console.log();
1819
+ console.log(` ${c.yellow}⚠ Score looks good but only 1 audit exists.${c.reset}`);
1820
+ console.log(` ${c.dim} Consider running your own audit: agentaudit audit ${data.source_url || name}${c.reset}`);
1821
+ } else if (confidence === 'low') {
1822
+ console.log();
1823
+ console.log(` ${c.yellow}⚠ Limited independent verification.${c.reset}`);
1824
+ console.log(` ${c.dim} More auditors needed for consensus. Run: agentaudit audit ${data.source_url || name}${c.reset}`);
1825
+ }
1826
+
1827
+ // Links
1828
+ console.log();
1829
+ if (data.source_url) console.log(` ${c.dim}Source: ${data.source_url}${c.reset}`);
1830
+ console.log(` ${c.dim}Registry: ${REGISTRY_URL}/skills/${encodeURIComponent(name)}${c.reset}`);
1831
+ console.log();
1832
+ }
1833
+ return data;
1834
+ }
1835
+
1836
+ // ── Main ────────────────────────────────────────────────
1837
+
1838
+ async function main() {
1839
+ const rawArgs = process.argv.slice(2);
1840
+
1841
+ // MCP server mode: launched by an editor (no TTY + no args) or explicit --stdio flag
1842
+ if (rawArgs.includes('--stdio') || (!process.stdin.isTTY && rawArgs.length === 0)) {
1843
+ await import('./index.mjs');
1844
+ return;
1845
+ }
1846
+
1847
+ // Parse global flags early
1848
+ jsonMode = rawArgs.includes('--json');
1849
+ quietMode = rawArgs.includes('--quiet') || rawArgs.includes('-q');
1850
+ // --no-color already handled at top level for `c` object
1851
+
1852
+ // --model flag: --model=<name> or --model <name>
1853
+ const modelFlagIdx = rawArgs.findIndex(a => a === '--model');
1854
+ const modelFlagEq = rawArgs.find(a => a.startsWith('--model='));
1855
+ modelOverride = modelFlagEq?.split('=')[1]
1856
+ || (modelFlagIdx >= 0 ? rawArgs[modelFlagIdx + 1] : null)
1857
+ || process.env.AGENTAUDIT_MODEL
1858
+ || loadConfig()?.preferred_model
1859
+ || null;
1860
+ globalModelOverride = modelOverride;
1861
+
1862
+ // Strip global flags from args
1863
+ const globalFlags = new Set(['--json', '--quiet', '-q', '--no-color']);
1864
+ let args = rawArgs.filter(a => !globalFlags.has(a));
1865
+ // Strip --model and its value
1866
+ args = args.filter((a, i, arr) => {
1867
+ if (a.startsWith('--model=')) return false;
1868
+ if (a === '--model') { arr[i + 1] = '__skip__'; return false; }
1869
+ if (a === '__skip__') return false;
1870
+ return true;
1871
+ });
1872
+
1873
+ if (args[0] === '-v' || args[0] === '--version') {
1874
+ console.log(`agentaudit ${getVersion()}`);
1875
+ process.exitCode = 0; return;
1876
+ }
1877
+
1878
+ if (args[0] === '--help' || args[0] === '-h') {
1879
+ banner();
1880
+ console.log(` ${c.bold}USAGE${c.reset}`);
1881
+ console.log(` ${c.cyan}agentaudit${c.reset} <command> [options]`);
1882
+ console.log();
1883
+ console.log(` ${c.bold}SCAN & AUDIT${c.reset}`);
1884
+ console.log(` ${c.cyan}scan${c.reset} <url> [url...] Quick static analysis ${c.dim}(~2s, no API key)${c.reset}`);
1885
+ console.log(` ${c.cyan}audit${c.reset} <url> [url...] Deep LLM security audit ${c.dim}(~30s)${c.reset}`);
1886
+ console.log(` ${c.cyan}discover${c.reset} Find MCP servers in your editors`);
1887
+ console.log();
1888
+ console.log(` ${c.bold}REGISTRY${c.reset}`);
1889
+ console.log(` ${c.cyan}check${c.reset} <name|url> Look up or auto-audit package`);
1890
+ console.log(` ${c.cyan}lookup${c.reset} <name> Look up package in registry`);
1891
+ console.log();
1892
+ console.log(` ${c.bold}SETUP${c.reset}`);
1893
+ console.log(` ${c.cyan}status${c.reset} Check providers & API keys`);
1894
+ console.log(` ${c.cyan}setup${c.reset} Register & configure`);
1895
+ console.log(` ${c.cyan}models${c.reset} List available LLM models`);
1896
+ console.log(` ${c.cyan}config set${c.reset} <key> <value> Set default provider/options`);
1897
+ console.log();
1898
+ console.log(` ${c.bold}OPTIONS${c.reset}`);
1899
+ console.log(` ${c.dim}--json Machine-readable JSON output${c.reset}`);
1900
+ console.log(` ${c.dim}--quiet Suppress banner${c.reset}`);
1901
+ console.log(` ${c.dim}--no-color Disable colors ${c.reset}${c.dim}(also: NO_COLOR=1)${c.reset}`);
1902
+ console.log(` ${c.dim}--provider <p> Force provider ${c.reset}${c.dim}(anthropic|openai|openrouter|ollama|custom)${c.reset}`);
1903
+ console.log(` ${c.dim}--model <m> Override model ${c.reset}${c.dim}(e.g. gpt-4o-mini, claude-3.5-sonnet)${c.reset}`);
1904
+ console.log(` ${c.dim}--export Export audit payload to markdown${c.reset}`);
1905
+ console.log(` ${c.dim}--debug Show raw LLM response on errors${c.reset}`);
1906
+ console.log();
1907
+ console.log(` ${c.bold}EXAMPLES${c.reset}`);
1908
+ console.log(` ${c.dim}$${c.reset} agentaudit scan https://github.com/owner/repo`);
1909
+ console.log(` ${c.dim}$${c.reset} agentaudit audit https://github.com/owner/repo`);
1910
+ console.log(` ${c.dim}$${c.reset} agentaudit check fastmcp`);
1911
+ console.log(` ${c.dim}$${c.reset} agentaudit status`);
1912
+ console.log();
1913
+ console.log(` ${c.bold}PROVIDERS${c.reset} ${c.dim}(set any one for deep audits)${c.reset}`);
1914
+ console.log(` ${c.dim}ANTHROPIC_API_KEY · OPENAI_API_KEY · OPENROUTER_API_KEY · OLLAMA_MODEL · LLM_API_URL${c.reset}`);
1915
+ console.log(` ${c.dim}Set default: AGENTAUDIT_PROVIDER=openai AGENTAUDIT_MODEL=gpt-4o-mini${c.reset}`);
1916
+ console.log(` ${c.dim}Or persist: agentaudit config set provider openai${c.reset}`);
1917
+ console.log(` ${c.dim} agentaudit config set model gpt-4o-mini${c.reset}`);
1918
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit status${c.dim} to check configuration.${c.reset}`);
1919
+ console.log();
1920
+ process.exitCode = 0; return;
1921
+ }
1922
+
1923
+ // Default no-arg discover
1924
+ const command = args.length === 0 ? 'discover' : args[0];
1925
+ const targets = args.slice(1);
1926
+
1927
+ banner();
1928
+
1929
+ if (command === 'setup') {
1930
+ await setupCommand();
1931
+ return;
1932
+ }
1933
+
1934
+ if (command === 'status') {
1935
+ console.log(` ${c.bold}LLM Providers:${c.reset}`);
1936
+ console.log();
1937
+ const keys = {
1938
+ anthropicKey: process.env.ANTHROPIC_API_KEY,
1939
+ openaiKey: process.env.OPENAI_API_KEY,
1940
+ openrouterKey: process.env.OPENROUTER_API_KEY,
1941
+ };
1942
+ const ollamaHost = process.env.OLLAMA_HOST || 'http://localhost:11434';
1943
+ const ollamaModel = process.env.OLLAMA_MODEL;
1944
+ const customUrl = process.env.LLM_API_URL;
1945
+
1946
+ const checks = [
1947
+ { name: 'Anthropic', env: 'ANTHROPIC_API_KEY', key: keys.anthropicKey, testUrl: 'https://api.anthropic.com/v1/messages', testHeaders: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }), testBody: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
1948
+ { name: 'OpenAI', env: 'OPENAI_API_KEY', key: keys.openaiKey, testUrl: 'https://api.openai.com/v1/chat/completions', testHeaders: (k) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json' }), testBody: JSON.stringify({ model: 'gpt-4o-mini', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
1949
+ { name: 'OpenRouter', env: 'OPENROUTER_API_KEY', key: keys.openrouterKey, testUrl: 'https://openrouter.ai/api/v1/chat/completions', testHeaders: (k) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://agentaudit.dev', 'X-Title': 'AgentAudit' }), testBody: JSON.stringify({ model: 'openai/gpt-4o-mini', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }) },
1950
+ { name: 'Ollama', env: 'OLLAMA_MODEL', key: ollamaModel, testUrl: `${ollamaHost}/api/tags`, testHeaders: () => ({}), testBody: null },
1951
+ { name: 'Custom', env: 'LLM_API_URL', key: customUrl, testUrl: customUrl ? `${customUrl.replace(/\/$/, '')}/models` : null, testHeaders: (k) => process.env.LLM_API_KEY ? ({ 'Authorization': `Bearer ${process.env.LLM_API_KEY}` }) : {}, testBody: null },
1952
+ ];
1953
+
1954
+ for (const p of checks) {
1955
+ if (!p.key) {
1956
+ console.log(` ${c.dim}○${c.reset} ${p.name.padEnd(12)} ${c.dim}not set${c.reset} ${c.dim}(${p.env})${c.reset}`);
1957
+ continue;
1958
+ }
1959
+ const masked = p.key.substring(0, 8) + '...' + p.key.substring(p.key.length - 4);
1960
+ process.stdout.write(` ${c.yellow}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} checking...`);
1961
+ try {
1962
+ const res = await fetch(p.testUrl, {
1963
+ method: p.testBody ? 'POST' : 'GET',
1964
+ headers: p.testHeaders(p.key),
1965
+ ...(p.testBody ? { body: p.testBody } : {}),
1966
+ signal: AbortSignal.timeout(10_000),
1967
+ });
1968
+ if (res.ok || res.status === 200 || res.status === 201) {
1969
+ process.stdout.write(`\r ${c.green}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} ${c.green}valid ✓${c.reset} \n`);
1970
+ } else {
1971
+ const body = await res.json().catch(() => ({}));
1972
+ const rawMsg = body?.error?.message || body?.message || `HTTP ${res.status}`;
1973
+ // Detect specific error types for clearer messages
1974
+ const lcMsg = rawMsg.toLowerCase();
1975
+ let errMsg = rawMsg;
1976
+ let hint = '';
1977
+ if (lcMsg.includes('credit') || lcMsg.includes('balance') || lcMsg.includes('quota') || lcMsg.includes('billing') || lcMsg.includes('exceeded') || lcMsg.includes('insufficient')) {
1978
+ errMsg = 'no credits';
1979
+ if (p.name === 'Anthropic') hint = `\n ${c.dim}└─ Add credits: console.anthropic.com/settings/plans${c.reset}`;
1980
+ else if (p.name === 'OpenAI') hint = `\n ${c.dim}└─ Check usage: platform.openai.com/usage${c.reset}`;
1981
+ else if (p.name === 'OpenRouter') hint = `\n ${c.dim}└─ Check balance: openrouter.ai/credits${c.reset}`;
1982
+ } else if (res.status === 401 || lcMsg.includes('invalid') || lcMsg.includes('unauthorized') || lcMsg.includes('authentication')) {
1983
+ errMsg = 'invalid key';
1984
+ if (p.name === 'Anthropic') hint = `\n ${c.dim}└─ Check key: console.anthropic.com/settings/keys${c.reset}`;
1985
+ else if (p.name === 'OpenAI') hint = `\n ${c.dim}└─ Check key: platform.openai.com/api-keys${c.reset}`;
1986
+ } else if (res.status === 429) {
1987
+ errMsg = 'rate limited';
1988
+ hint = `\n ${c.dim}└─ Try again in a moment${c.reset}`;
1989
+ }
1990
+ process.stdout.write(`\r ${c.red}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} ${c.red} ${errMsg}${c.reset}${hint} \n`);
1991
+ }
1992
+ } catch (e) {
1993
+ process.stdout.write(`\r ${c.red}●${c.reset} ${p.name.padEnd(12)} ${c.dim}${masked}${c.reset} ${c.red}error ✗${c.reset} ${c.dim}(${e.message})${c.reset} \n`);
1994
+ }
1995
+ }
1996
+
1997
+ const resolved = resolveProvider(null, keys);
1998
+ console.log();
1999
+ if (resolved) {
2000
+ const activeModel = modelOverride || process.env.AGENTAUDIT_MODEL || loadConfig()?.preferred_model;
2001
+ console.log(` ${c.bold}Active:${c.reset} ${c.green}${resolved.label}${c.reset}${activeModel ? ` ${c.dim}model: ${activeModel}${c.reset}` : ''}`);
2002
+ console.log(` ${c.dim}Override: --provider=<name> --model=<name>${c.reset}`);
2003
+ console.log(` ${c.dim}Set default: agentaudit config set provider <name>${c.reset}`);
2004
+ console.log(` ${c.dim} agentaudit config set model <name>${c.reset}`);
2005
+ } else {
2006
+ console.log(` ${c.yellow}⚠ No working LLM provider.${c.reset} Deep audits require one.`);
2007
+ console.log(` ${c.dim}Set a key: export ANTHROPIC_API_KEY=sk-ant-...${c.reset}`);
2008
+ console.log(` ${c.dim}Or scan without LLM: agentaudit scan <url>${c.reset}`);
2009
+ }
2010
+
2011
+ // AgentAudit registry key
2012
+ console.log();
2013
+ console.log(` ${c.bold}Registry:${c.reset}`);
2014
+ const creds = loadCredentials();
2015
+ if (creds?.api_key) {
2016
+ const masked = creds.api_key.substring(0, 8) + '...' + creds.api_key.substring(creds.api_key.length - 4);
2017
+ console.log(` ${c.green}●${c.reset} AgentAudit ${c.dim}${masked}${c.reset} ${c.dim}(${creds.agent_name || 'unknown'})${c.reset}`);
2018
+ // Fetch agent stats from leaderboard
2019
+ try {
2020
+ const lbRes = await fetch(`${REGISTRY_URL}/api/leaderboard`, { signal: AbortSignal.timeout(5000) });
2021
+ if (lbRes.ok) {
2022
+ const agents = await lbRes.json();
2023
+ const myName = creds.agent_name?.toLowerCase();
2024
+ const idx = Array.isArray(agents) ? agents.findIndex((a) => (a.agent_name || '').toLowerCase() === myName) : -1;
2025
+ if (idx >= 0) {
2026
+ const me = agents[idx];
2027
+ const pts = me.total_points || 0;
2028
+ const reports = me.total_reports || 0;
2029
+ const rank = idx + 1;
2030
+ const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : ' ';
2031
+ console.log();
2032
+ console.log(` ${c.bold}Your Stats:${c.reset}`);
2033
+ console.log(` ${medal} Rank #${rank} of ${agents.length} ${c.dim}│${c.reset} ${c.cyan}${pts}${c.reset} points ${c.dim}│${c.reset} ${reports} reports`);
2034
+ if (me.is_official) console.log(` ${c.green} Official Auditor${c.reset}`);
2035
+ // Update stats cache
2036
+ saveStatsCache({ rank, total: agents.length, pts, reports, official: !!me.is_official });
2037
+ }
2038
+ }
2039
+ } catch {}
2040
+ } else {
2041
+ console.log(` ${c.dim}○${c.reset} AgentAudit ${c.dim}not set${c.reset} ${c.dim}(run: agentaudit setup)${c.reset}`);
2042
+ }
2043
+ console.log();
2044
+ return;
2045
+ }
2046
+
2047
+ if (command === 'discover') {
2048
+ const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
2049
+ const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
2050
+ await discoverCommand({ scan: scanFlag, audit: auditFlag });
2051
+ return;
2052
+ }
2053
+
2054
+ if (command === 'models') {
2055
+ const anthropicKey = process.env.ANTHROPIC_API_KEY;
2056
+ const openaiKey = process.env.OPENAI_API_KEY;
2057
+ const openrouterKey = process.env.OPENROUTER_API_KEY;
2058
+
2059
+ console.log(` ${c.bold}Available models by provider:${c.reset}`);
2060
+ console.log();
2061
+
2062
+ // Static lists for Anthropic (no list API)
2063
+ console.log(` ${c.bold}Anthropic${c.reset}${anthropicKey ? ` ${c.green}(configured)${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2064
+ console.log(` ${c.dim}claude-sonnet-4-20250514${c.reset} ${c.dim}(default)${c.reset}`);
2065
+ console.log(` ${c.dim}claude-opus-4-20250514${c.reset}`);
2066
+ console.log(` ${c.dim}claude-haiku-3-20250514${c.reset}`);
2067
+ console.log();
2068
+
2069
+ // Static list for OpenAI
2070
+ console.log(` ${c.bold}OpenAI${c.reset}${openaiKey ? ` ${c.green}(configured)${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2071
+ console.log(` ${c.dim}gpt-4o${c.reset} ${c.dim}(default)${c.reset}`);
2072
+ console.log(` ${c.dim}gpt-4o-mini${c.reset}`);
2073
+ console.log(` ${c.dim}gpt-4.1${c.reset}`);
2074
+ console.log(` ${c.dim}gpt-4.1-mini${c.reset}`);
2075
+ console.log(` ${c.dim}o3${c.reset}`);
2076
+ console.log(` ${c.dim}o4-mini${c.reset}`);
2077
+ console.log();
2078
+
2079
+ // OpenRouter fetch from API
2080
+ console.log(` ${c.bold}OpenRouter${c.reset}${openrouterKey ? ` ${c.green}(configured)${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2081
+ if (openrouterKey || targets.includes('--all')) {
2082
+ process.stdout.write(` ${c.dim}Fetching models...${c.reset}`);
2083
+ try {
2084
+ const res = await fetch('https://openrouter.ai/api/v1/models', {
2085
+ headers: openrouterKey ? { 'Authorization': `Bearer ${openrouterKey}` } : {},
2086
+ signal: AbortSignal.timeout(10_000),
2087
+ });
2088
+ const data = await res.json();
2089
+ const models = (data.data || [])
2090
+ .filter(m => m.id && !m.id.includes(':free') && !m.id.includes('/extended'))
2091
+ .sort((a, b) => (a.id || '').localeCompare(b.id || ''));
2092
+
2093
+ // Group by provider prefix
2094
+ const groups = {};
2095
+ for (const m of models) {
2096
+ const [prefix] = m.id.split('/');
2097
+ if (!groups[prefix]) groups[prefix] = [];
2098
+ groups[prefix].push(m);
2099
+ }
2100
+
2101
+ // Show popular ones first
2102
+ const popular = ['anthropic', 'openai', 'google', 'meta-llama', 'mistralai', 'deepseek'];
2103
+ const shown = new Set();
2104
+ process.stdout.write(`\r ${c.green}${models.length} models available${c.reset} \n`);
2105
+ console.log();
2106
+
2107
+ for (const prefix of popular) {
2108
+ if (!groups[prefix]) continue;
2109
+ shown.add(prefix);
2110
+ console.log(` ${c.bold}${prefix}${c.reset}`);
2111
+ for (const m of groups[prefix].slice(0, 5)) {
2112
+ console.log(` ${c.dim}${m.id}${c.reset}`);
2113
+ }
2114
+ if (groups[prefix].length > 5) {
2115
+ console.log(` ${c.dim}... and ${groups[prefix].length - 5} more${c.reset}`);
2116
+ }
2117
+ }
2118
+
2119
+ const otherCount = Object.keys(groups).filter(k => !shown.has(k)).length;
2120
+ if (otherCount > 0) {
2121
+ console.log();
2122
+ console.log(` ${c.dim}+ ${otherCount} more providers. Use --model=<provider/model>${c.reset}`);
2123
+ console.log(` ${c.dim}Full list: https://openrouter.ai/models${c.reset}`);
2124
+ }
2125
+ } catch (e) {
2126
+ process.stdout.write(`\r ${c.red}Failed to fetch: ${e.message}${c.reset} \n`);
2127
+ }
2128
+ } else {
2129
+ console.log(` ${c.dim}anthropic/claude-sonnet-4${c.reset} ${c.dim}(default)${c.reset}`);
2130
+ console.log(` ${c.dim}Set OPENROUTER_API_KEY to see all ${c.bold}200+${c.reset}${c.dim} models${c.reset}`);
2131
+ console.log(` ${c.dim}Or browse: https://openrouter.ai/models${c.reset}`);
2132
+ }
2133
+ console.log();
2134
+
2135
+ // Ollama
2136
+ const ollamaModel = process.env.OLLAMA_MODEL;
2137
+ const ollamaHost = process.env.OLLAMA_HOST || 'http://localhost:11434';
2138
+ console.log(` ${c.bold}Ollama${c.reset}${ollamaModel ? ` ${c.green}(configured: ${ollamaModel})${c.reset}` : ` ${c.dim}(not configured)${c.reset}`}`);
2139
+ if (ollamaModel || process.env.OLLAMA_HOST) {
2140
+ try {
2141
+ const res = await fetch(`${ollamaHost}/api/tags`, { signal: AbortSignal.timeout(5_000) });
2142
+ const data = await res.json();
2143
+ for (const m of (data.models || []).slice(0, 10)) {
2144
+ console.log(` ${c.dim}${m.name}${c.reset}`);
2145
+ }
2146
+ } catch {
2147
+ console.log(` ${c.dim}(Ollama not running at ${ollamaHost})${c.reset}`);
2148
+ }
2149
+ } else {
2150
+ console.log(` ${c.dim}Set OLLAMA_MODEL to use local models${c.reset}`);
2151
+ }
2152
+ console.log();
2153
+
2154
+ console.log(` ${c.bold}Set model:${c.reset}`);
2155
+ console.log(` ${c.cyan}agentaudit config set model <name>${c.reset}`);
2156
+ console.log(` ${c.cyan}agentaudit audit <url> --model <name>${c.reset}`);
2157
+ console.log(` ${c.dim}Or env: AGENTAUDIT_MODEL=<name>${c.reset}`);
2158
+ return;
2159
+ }
2160
+
2161
+ if (command === 'config') {
2162
+ const subCmd = targets[0];
2163
+ if (subCmd === 'set' && targets[1] === 'provider' && targets[2]) {
2164
+ const validProviders = ['anthropic', 'openai', 'openrouter', 'ollama', 'custom', 'claude', 'gpt'];
2165
+ const val = targets[2].toLowerCase();
2166
+ if (!validProviders.includes(val)) {
2167
+ console.log(` ${c.red}✖ Unknown provider: ${val}${c.reset}`);
2168
+ console.log(` ${c.dim}Valid: anthropic, openai, openrouter, ollama, custom${c.reset}`);
2169
+ process.exitCode = 2; return;
2170
+ }
2171
+ saveConfig({ preferred_provider: val });
2172
+ console.log(` ${c.green}✔${c.reset} Default provider set to: ${c.bold}${val}${c.reset}`);
2173
+ console.log(` ${c.dim}Override per-command: --provider=<name>${c.reset}`);
2174
+ console.log(` ${c.dim}Or env: AGENTAUDIT_PROVIDER=<name>${c.reset}`);
2175
+ } else if (subCmd === 'set' && targets[1] === 'model' && targets[2]) {
2176
+ const val = targets[2];
2177
+ saveConfig({ preferred_model: val });
2178
+ console.log(` ${c.green}✔${c.reset} Default model set to: ${c.bold}${val}${c.reset}`);
2179
+ console.log(` ${c.dim}Override per-command: --model=<name>${c.reset}`);
2180
+ console.log(` ${c.dim}Or env: AGENTAUDIT_MODEL=<name>${c.reset}`);
2181
+ } else if (subCmd === 'get' || !subCmd) {
2182
+ const cfg = loadConfig();
2183
+ console.log(` ${c.bold}Config:${c.reset} ${USER_CONFIG_FILE}`);
2184
+ if (Object.keys(cfg).length === 0) {
2185
+ console.log(` ${c.dim}(empty using defaults)${c.reset}`);
2186
+ } else {
2187
+ for (const [k, v] of Object.entries(cfg)) {
2188
+ console.log(` ${c.dim}${k}:${c.reset} ${v}`);
2189
+ }
2190
+ }
2191
+ } else if (subCmd === 'reset') {
2192
+ try { fs.unlinkSync(USER_CONFIG_FILE); } catch {}
2193
+ console.log(` ${c.green}✔${c.reset} Config reset to defaults.`);
2194
+ } else {
2195
+ console.log(` ${c.red}✖ Unknown config command${c.reset}`);
2196
+ console.log(` ${c.dim}Usage: agentaudit config set provider <name>${c.reset}`);
2197
+ console.log(` ${c.dim} agentaudit config get${c.reset}`);
2198
+ console.log(` ${c.dim} agentaudit config reset${c.reset}`);
2199
+ }
2200
+ return;
2201
+ }
2202
+
2203
+ if (command === 'lookup' || command === 'check') {
2204
+ const names = targets.filter(t => !t.startsWith('--'));
2205
+ if (names.length === 0) {
2206
+ console.log(` ${c.red}✖ Package name or URL required${c.reset}`);
2207
+ console.log(` ${c.dim}Usage: agentaudit check <name|url>${c.reset}`);
2208
+ process.exitCode = 2;
2209
+ return;
2210
+ }
2211
+ const results = [];
2212
+ const allowAutoAudit = command === 'check'; // only 'check' auto-audits, 'lookup' never does
2213
+ for (const t of names) {
2214
+ const data = await checkPackage(t, { autoAudit: allowAutoAudit });
2215
+ results.push(data);
2216
+ }
2217
+ if (jsonMode) {
2218
+ console.log(JSON.stringify(results.length === 1 ? (results[0] || { error: 'not_found' }) : results, null, 2));
2219
+ }
2220
+ process.exitCode = 0; return;
2221
+ }
2222
+
2223
+ if (command === 'scan') {
2224
+ const urls = targets.filter(t => !t.startsWith('--'));
2225
+ if (urls.length === 0) {
2226
+ console.log(` ${c.red}✖ Repository URL required${c.reset}`);
2227
+ console.log(` ${c.dim}Usage: agentaudit scan <url>${c.reset}`);
2228
+ console.log(` ${c.dim}Or discover local servers: ${c.cyan}agentaudit discover${c.reset}`);
2229
+ process.exitCode = 2;
2230
+ return;
2231
+ }
2232
+
2233
+ const results = [];
2234
+ let hadErrors = false;
2235
+ for (const url of urls) {
2236
+ const result = await scanRepo(url);
2237
+ if (result) results.push(result);
2238
+ else hadErrors = true;
2239
+ }
2240
+
2241
+ if (jsonMode) {
2242
+ const jsonOut = results.map(r => ({
2243
+ slug: r.slug,
2244
+ url: r.url,
2245
+ findings: r.findings.map(f => ({
2246
+ severity: f.severity,
2247
+ title: f.title,
2248
+ file: f.file,
2249
+ line: f.line,
2250
+ snippet: f.snippet,
2251
+ })),
2252
+ fileCount: r.files,
2253
+ duration: r.duration,
2254
+ }));
2255
+ console.log(JSON.stringify(jsonOut.length === 1 ? jsonOut[0] : jsonOut, null, 2));
2256
+ } else if (results.length > 1) {
2257
+ printSummary(results);
2258
+ }
2259
+
2260
+ if (hadErrors && results.length === 0) { process.exitCode = 2; return; }
2261
+ const totalFindings = results.reduce((sum, r) => sum + r.findings.length, 0);
2262
+ process.exitCode = totalFindings > 0 ? 1 : 0;
2263
+ return;
2264
+ }
2265
+
2266
+ if (command === 'audit') {
2267
+ const urls = targets.filter(t => !t.startsWith('--'));
2268
+ if (urls.length === 0) {
2269
+ console.log(` ${c.red}✖ Repository URL required${c.reset}`);
2270
+ console.log(` ${c.dim}Usage: agentaudit audit <url>${c.reset}`);
2271
+ process.exitCode = 2;
2272
+ return;
2273
+ }
2274
+
2275
+ let hasFindings = false;
2276
+ for (const url of urls) {
2277
+ const report = await auditRepo(url);
2278
+ if (report?.findings?.length > 0) hasFindings = true;
2279
+ }
2280
+ process.exitCode = hasFindings ? 1 : 0;
2281
+ return;
2282
+ }
2283
+
2284
+ // Typo correction via Levenshtein distance
2285
+ const knownCommands = ['discover', 'scan', 'audit', 'check', 'lookup', 'status', 'setup', 'config', 'models'];
2286
+ const suggestion = knownCommands
2287
+ .map(cmd => ({ cmd, dist: levenshtein(command, cmd) }))
2288
+ .filter(x => x.dist <= 3)
2289
+ .sort((a, b) => a.dist - b.dist)[0];
2290
+
2291
+ console.log(` ${c.red}✖ Unknown command: ${command}${c.reset}`);
2292
+ if (suggestion) {
2293
+ console.log(` ${c.dim}Did you mean: ${c.cyan}agentaudit ${suggestion.cmd}${c.reset}${c.dim}?${c.reset}`);
2294
+ }
2295
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit --help${c.dim} for usage${c.reset}`);
2296
+ process.exitCode = 2;
2297
+ }
2298
+
2299
+ main().catch(err => {
2300
+ console.error(`${c.red}Error: ${err.message}${c.reset}`);
2301
+ process.exitCode = 2;
2302
+ });