agentaudit 3.12.0 → 3.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +1234 -329
- package/index.mjs +1 -1
- package/package.json +1 -1
package/cli.mjs
CHANGED
|
@@ -150,6 +150,36 @@ const USER_CRED_DIR = path.join(xdgConfig, 'agentaudit');
|
|
|
150
150
|
const USER_CRED_FILE = path.join(USER_CRED_DIR, 'credentials.json');
|
|
151
151
|
const SKILL_CRED_FILE = path.join(SKILL_DIR, 'config', 'credentials.json');
|
|
152
152
|
const PROFILE_CACHE_FILE = path.join(USER_CRED_DIR, 'profile-cache.json');
|
|
153
|
+
const HISTORY_DIR = path.join(USER_CRED_DIR, 'history');
|
|
154
|
+
|
|
155
|
+
function saveHistory(report) {
|
|
156
|
+
try {
|
|
157
|
+
fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
|
158
|
+
const slug = report.skill_slug || 'unknown';
|
|
159
|
+
const model = (report.audit_model || 'unknown').replace(/[^a-z0-9-]/gi, '-').slice(0, 30);
|
|
160
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
161
|
+
const filename = `${ts}_${slug}_${model}.json`;
|
|
162
|
+
fs.writeFileSync(path.join(HISTORY_DIR, filename), JSON.stringify(report, null, 2));
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function loadHistory(limit = 20) {
|
|
167
|
+
try {
|
|
168
|
+
if (!fs.existsSync(HISTORY_DIR)) return [];
|
|
169
|
+
const files = fs.readdirSync(HISTORY_DIR)
|
|
170
|
+
.filter(f => f.endsWith('.json'))
|
|
171
|
+
.sort()
|
|
172
|
+
.reverse()
|
|
173
|
+
.slice(0, limit);
|
|
174
|
+
return files.map(f => {
|
|
175
|
+
try {
|
|
176
|
+
const data = JSON.parse(fs.readFileSync(path.join(HISTORY_DIR, f), 'utf8'));
|
|
177
|
+
data._file = f;
|
|
178
|
+
return data;
|
|
179
|
+
} catch { return null; }
|
|
180
|
+
}).filter(Boolean);
|
|
181
|
+
} catch { return []; }
|
|
182
|
+
}
|
|
153
183
|
|
|
154
184
|
function loadCredentials() {
|
|
155
185
|
for (const f of [SKILL_CRED_FILE, USER_CRED_FILE]) {
|
|
@@ -222,6 +252,37 @@ function resolveProvider() {
|
|
|
222
252
|
return LLM_PROVIDERS.find(p => process.env[p.key]) || null;
|
|
223
253
|
}
|
|
224
254
|
|
|
255
|
+
function resolveModel(modelName) {
|
|
256
|
+
// model with '/' → OpenRouter
|
|
257
|
+
if (modelName.includes('/')) {
|
|
258
|
+
const p = LLM_PROVIDERS.find(p => p.provider === 'openrouter' && process.env[p.key]);
|
|
259
|
+
if (p) return { ...p, model: modelName };
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
// Known prefix → native provider
|
|
263
|
+
const prefixes = [
|
|
264
|
+
['claude', 'anthropic'], ['gemini', 'google'], ['gpt', 'openai'],
|
|
265
|
+
['deepseek', 'deepseek'], ['mistral', 'mistral'], ['grok', 'xai'], ['glm', 'zhipu'],
|
|
266
|
+
];
|
|
267
|
+
for (const [prefix, prov] of prefixes) {
|
|
268
|
+
if (modelName.toLowerCase().startsWith(prefix)) {
|
|
269
|
+
const p = LLM_PROVIDERS.find(p => p.provider === prov && process.env[p.key]);
|
|
270
|
+
if (p) return { ...p, model: modelName };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Check PROVIDER_MODELS for exact match
|
|
274
|
+
for (const [prov, models] of Object.entries(PROVIDER_MODELS)) {
|
|
275
|
+
if (models.some(m => m.value === modelName)) {
|
|
276
|
+
const p = LLM_PROVIDERS.find(p => p.provider === prov && process.env[p.key]);
|
|
277
|
+
if (p) return { ...p, model: modelName };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Last resort: OpenRouter
|
|
281
|
+
const or = LLM_PROVIDERS.find(p => p.provider === 'openrouter' && process.env[p.key]);
|
|
282
|
+
if (or) return { ...or, model: modelName };
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
225
286
|
function saveCredentials(data) {
|
|
226
287
|
const json = JSON.stringify(data, null, 2);
|
|
227
288
|
fs.mkdirSync(USER_CRED_DIR, { recursive: true });
|
|
@@ -643,15 +704,17 @@ function padLeft(str, len) {
|
|
|
643
704
|
|
|
644
705
|
function drawBox(title, contentLines, width) {
|
|
645
706
|
const inner = width - 4; // 2 for "│ " + 2 for " │"
|
|
707
|
+
const totalDash = inner + 2; // total horizontal line chars between corners
|
|
646
708
|
const lines = [];
|
|
647
709
|
const titleStr = title ? ` ${title} ` : '';
|
|
648
710
|
const titleLen = visLen(titleStr);
|
|
649
|
-
|
|
650
|
-
|
|
711
|
+
// Top: ╭─ Title ────────────╮ (1 dash before title + title + remaining dashes)
|
|
712
|
+
const topDash = BOX.h.repeat(Math.max(1, totalDash - 1 - titleLen));
|
|
713
|
+
lines.push(` ${BOX.tl}${c.dim}${BOX.h}${c.reset}${c.bold}${titleStr}${c.reset}${c.dim}${topDash}${c.reset}${BOX.tr}`);
|
|
651
714
|
for (const line of contentLines) {
|
|
652
715
|
lines.push(` ${BOX.v} ${padRight(line, inner + 1)}${BOX.v}`);
|
|
653
716
|
}
|
|
654
|
-
lines.push(` ${BOX.bl}${c.dim}${BOX.h.repeat(
|
|
717
|
+
lines.push(` ${BOX.bl}${c.dim}${BOX.h.repeat(totalDash)}${c.reset}${BOX.br}`);
|
|
655
718
|
return lines;
|
|
656
719
|
}
|
|
657
720
|
|
|
@@ -929,24 +992,38 @@ function detectPackageInfo(repoPath, files) {
|
|
|
929
992
|
info.type = 'library';
|
|
930
993
|
}
|
|
931
994
|
|
|
932
|
-
// Extract MCP tools
|
|
995
|
+
// Extract MCP tools — only from files that reference MCP SDK
|
|
996
|
+
const mcpKeywords = ['modelcontextprotocol', 'FastMCP', 'mcp.server', 'mcp_server', '@mcp.tool', '@server.tool', '.tool(', 'ListTools', 'CallTool'];
|
|
997
|
+
const mcpFiles = files.filter(f => mcpKeywords.some(kw => f.content.includes(kw)));
|
|
998
|
+
// Fallback: if no MCP-specific files found, try entrypoint files
|
|
999
|
+
if (mcpFiles.length === 0) {
|
|
1000
|
+
const entryNames = ['index.js', 'index.ts', 'index.mjs', 'main.py', 'server.py', 'app.py', 'src/index.ts', 'src/main.ts', 'src/index.js'];
|
|
1001
|
+
for (const f of files) {
|
|
1002
|
+
if (entryNames.includes(f.path)) mcpFiles.push(f);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
933
1006
|
const toolPatterns = [
|
|
934
|
-
// JS/TS
|
|
935
|
-
|
|
936
|
-
// Python: @mcp.tool()
|
|
937
|
-
|
|
938
|
-
//
|
|
939
|
-
/
|
|
1007
|
+
// JS/TS MCP SDK: server.tool('name', ...) or .setTool('name', ...)
|
|
1008
|
+
/\.tool\s*\(\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
|
|
1009
|
+
// Python: @mcp.tool() / @server.tool() followed by def name
|
|
1010
|
+
/@(?:mcp|server)\.tool\s*\(.*?\)[\s\S]*?def\s+([a-z_][a-z0-9_]*)/gi,
|
|
1011
|
+
// Python: Tool(name="xxx")
|
|
1012
|
+
/Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
|
|
1013
|
+
// ListTools handler: { name: "tool_name", description: ... }
|
|
1014
|
+
/{\s*(?:['"]?)name(?:['"]?)\s*:\s*['"]([a-z_][a-z0-9_]*)['"]\s*,\s*(?:['"]?)description(?:['"]?)\s*:/gi,
|
|
940
1015
|
];
|
|
941
|
-
|
|
1016
|
+
|
|
1017
|
+
const toolBlacklist = new Set(['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none', 'test', 'self', 'args', 'kwargs', 'input', 'output', 'result', 'data', 'error', 'value', 'index', 'item', 'list', 'dict', 'set', 'map', 'key', 'url', 'env', 'config', 'options']);
|
|
1018
|
+
|
|
942
1019
|
const toolSet = new Set();
|
|
943
|
-
for (const file of
|
|
1020
|
+
for (const file of mcpFiles) {
|
|
944
1021
|
for (const pattern of toolPatterns) {
|
|
945
1022
|
pattern.lastIndex = 0;
|
|
946
1023
|
let m;
|
|
947
1024
|
while ((m = pattern.exec(file.content)) !== null) {
|
|
948
1025
|
const name = m[1] || m[2];
|
|
949
|
-
if (name && name.length > 2 && name.length < 50 && !
|
|
1026
|
+
if (name && name.length > 2 && name.length < 50 && !toolBlacklist.has(name)) {
|
|
950
1027
|
toolSet.add(name);
|
|
951
1028
|
}
|
|
952
1029
|
}
|
|
@@ -1566,6 +1643,235 @@ function findMcpConfigs() {
|
|
|
1566
1643
|
return found;
|
|
1567
1644
|
}
|
|
1568
1645
|
|
|
1646
|
+
// ── Skill Discovery & Validation ─────────────────────────
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Parse YAML frontmatter from a SKILL.md file.
|
|
1650
|
+
* Returns { meta: {...}, body: string, errors: string[] }
|
|
1651
|
+
*/
|
|
1652
|
+
function parseSkillFrontmatter(content) {
|
|
1653
|
+
const errors = [];
|
|
1654
|
+
const lines = content.split('\n');
|
|
1655
|
+
|
|
1656
|
+
// Must start with ---
|
|
1657
|
+
if (lines[0].trim() !== '---') {
|
|
1658
|
+
return { meta: null, body: content, errors: ['Missing YAML frontmatter (file must start with ---)'] };
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Find closing ---
|
|
1662
|
+
let endIdx = -1;
|
|
1663
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1664
|
+
if (lines[i].trim() === '---') { endIdx = i; break; }
|
|
1665
|
+
}
|
|
1666
|
+
if (endIdx === -1) {
|
|
1667
|
+
return { meta: null, body: content, errors: ['Unclosed frontmatter (missing closing ---)'] };
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Parse YAML-like key: value pairs
|
|
1671
|
+
const meta = {};
|
|
1672
|
+
const yamlLines = lines.slice(1, endIdx);
|
|
1673
|
+
for (let i = 0; i < yamlLines.length; i++) {
|
|
1674
|
+
const line = yamlLines[i];
|
|
1675
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue;
|
|
1676
|
+
|
|
1677
|
+
// Check for tabs
|
|
1678
|
+
if (line.includes('\t')) {
|
|
1679
|
+
errors.push(`Line ${i + 2}: Tab character found (use spaces)`);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const match = line.match(/^([a-z][a-z0-9_-]*):\s*(.*)/i);
|
|
1683
|
+
if (!match) {
|
|
1684
|
+
// Could be a continuation line (YAML multiline)
|
|
1685
|
+
continue;
|
|
1686
|
+
}
|
|
1687
|
+
const key = match[1].toLowerCase();
|
|
1688
|
+
let value = match[2].trim();
|
|
1689
|
+
|
|
1690
|
+
// Handle YAML lists on next lines
|
|
1691
|
+
if (value === '' && i + 1 < yamlLines.length && yamlLines[i + 1].match(/^\s+-\s/)) {
|
|
1692
|
+
const items = [];
|
|
1693
|
+
let j = i + 1;
|
|
1694
|
+
while (j < yamlLines.length && yamlLines[j].match(/^\s+-\s/)) {
|
|
1695
|
+
items.push(yamlLines[j].replace(/^\s+-\s*/, '').trim());
|
|
1696
|
+
j++;
|
|
1697
|
+
}
|
|
1698
|
+
value = items;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Strip surrounding quotes
|
|
1702
|
+
if (typeof value === 'string' && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
1703
|
+
value = value.slice(1, -1);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
meta[key] = value;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
const body = lines.slice(endIdx + 1).join('\n').trim();
|
|
1710
|
+
return { meta, body, errors };
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Validate a parsed skill against the Claude Code SKILL.md spec.
|
|
1715
|
+
* Returns { errors: [...], warnings: [...], info: {...} }
|
|
1716
|
+
*/
|
|
1717
|
+
function validateSkill(parsed) {
|
|
1718
|
+
const { meta, body, errors: parseErrors } = parsed;
|
|
1719
|
+
const errors = [...parseErrors];
|
|
1720
|
+
const warnings = [];
|
|
1721
|
+
const info = {};
|
|
1722
|
+
|
|
1723
|
+
if (!meta) return { errors, warnings, info };
|
|
1724
|
+
|
|
1725
|
+
// Known fields
|
|
1726
|
+
const knownFields = new Set([
|
|
1727
|
+
'name', 'description', 'allowed-tools', 'user-invocable', 'user-invokable',
|
|
1728
|
+
'disable-model-invocation', 'license', 'metadata', 'argument-hint',
|
|
1729
|
+
'compatibility', 'version', 'author',
|
|
1730
|
+
]);
|
|
1731
|
+
|
|
1732
|
+
// Check for unknown fields
|
|
1733
|
+
for (const key of Object.keys(meta)) {
|
|
1734
|
+
if (!knownFields.has(key)) {
|
|
1735
|
+
warnings.push(`Unknown frontmatter field: "${key}"`);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Required: name
|
|
1740
|
+
if (!meta.name) {
|
|
1741
|
+
errors.push('Missing required field: name');
|
|
1742
|
+
} else {
|
|
1743
|
+
info.name = meta.name;
|
|
1744
|
+
if (meta.name.length > 64) errors.push(`name exceeds 64 chars (${meta.name.length})`);
|
|
1745
|
+
if (/<[^>]+>/.test(meta.name)) errors.push('name contains XML/HTML tags');
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// Required: description
|
|
1749
|
+
if (!meta.description) {
|
|
1750
|
+
errors.push('Missing required field: description');
|
|
1751
|
+
} else {
|
|
1752
|
+
info.description = typeof meta.description === 'string' ? meta.description.slice(0, 120) : String(meta.description).slice(0, 120);
|
|
1753
|
+
if (typeof meta.description === 'string' && meta.description.length > 1024) {
|
|
1754
|
+
warnings.push(`description is ${meta.description.length} chars (recommended max: 1024)`);
|
|
1755
|
+
}
|
|
1756
|
+
if (/<[^>]+>/.test(meta.description)) warnings.push('description contains XML/HTML tags');
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Security: allowed-tools
|
|
1760
|
+
if (!meta['allowed-tools']) {
|
|
1761
|
+
warnings.push('No allowed-tools set — skill has access to ALL tools (security risk)');
|
|
1762
|
+
info.allowedTools = null;
|
|
1763
|
+
} else {
|
|
1764
|
+
const tools = typeof meta['allowed-tools'] === 'string'
|
|
1765
|
+
? meta['allowed-tools'].split(',').map(t => t.trim()).filter(Boolean)
|
|
1766
|
+
: Array.isArray(meta['allowed-tools']) ? meta['allowed-tools'] : [];
|
|
1767
|
+
info.allowedTools = tools;
|
|
1768
|
+
// Check for wildcard/dangerous patterns
|
|
1769
|
+
if (tools.some(t => t === '*' || t === 'Bash' || t === 'Bash(*)')) {
|
|
1770
|
+
warnings.push('allowed-tools includes unrestricted Bash access');
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// Boolean fields
|
|
1775
|
+
for (const boolField of ['user-invocable', 'user-invokable', 'disable-model-invocation']) {
|
|
1776
|
+
if (meta[boolField] !== undefined) {
|
|
1777
|
+
const val = String(meta[boolField]).toLowerCase();
|
|
1778
|
+
if (!['true', 'false'].includes(val)) {
|
|
1779
|
+
errors.push(`${boolField} must be true or false (got: "${meta[boolField]}")`);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// Typo detection
|
|
1785
|
+
if (meta['user-invokable'] && !meta['user-invocable']) {
|
|
1786
|
+
warnings.push('Using "user-invokable" (known typo variant) — both spellings work');
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Body checks
|
|
1790
|
+
if (body) {
|
|
1791
|
+
const bodyLines = body.split('\n').length;
|
|
1792
|
+
info.bodyLines = bodyLines;
|
|
1793
|
+
if (bodyLines > 500) warnings.push(`Body is ${bodyLines} lines (recommended max: 500)`);
|
|
1794
|
+
|
|
1795
|
+
// Check for potential prompt injection patterns in body
|
|
1796
|
+
const injectionPatterns = [
|
|
1797
|
+
{ pattern: /ignore\s+(all\s+)?previous\s+(instructions|rules)/i, label: 'Prompt injection pattern' },
|
|
1798
|
+
{ pattern: /<IMPORTANT>/i, label: 'Suspicious <IMPORTANT> tag' },
|
|
1799
|
+
{ pattern: /system\s*:\s*you\s+are/i, label: 'System prompt override attempt' },
|
|
1800
|
+
];
|
|
1801
|
+
for (const { pattern, label } of injectionPatterns) {
|
|
1802
|
+
if (pattern.test(body)) {
|
|
1803
|
+
warnings.push(`${label} detected in body`);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// Extract MCP tool references
|
|
1809
|
+
const mcpRefs = [];
|
|
1810
|
+
const mcpPattern = /mcp__([a-z0-9_-]+)__([a-z0-9_]+)/gi;
|
|
1811
|
+
const fullText = (meta.description || '') + ' ' + (typeof meta['allowed-tools'] === 'string' ? meta['allowed-tools'] : '') + ' ' + (body || '');
|
|
1812
|
+
let mcpMatch;
|
|
1813
|
+
while ((mcpMatch = mcpPattern.exec(fullText)) !== null) {
|
|
1814
|
+
mcpRefs.push({ server: mcpMatch[1], tool: mcpMatch[2] });
|
|
1815
|
+
}
|
|
1816
|
+
info.mcpRefs = mcpRefs;
|
|
1817
|
+
|
|
1818
|
+
// Deduplicate MCP server names
|
|
1819
|
+
info.mcpServers = [...new Set(mcpRefs.map(r => r.server))];
|
|
1820
|
+
|
|
1821
|
+
return { errors, warnings, info };
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Find all SKILL.md files in known skill directories.
|
|
1826
|
+
*/
|
|
1827
|
+
function findSkills() {
|
|
1828
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1829
|
+
const cwd = process.cwd();
|
|
1830
|
+
const found = [];
|
|
1831
|
+
|
|
1832
|
+
const skillDirs = [
|
|
1833
|
+
// Global skill dirs
|
|
1834
|
+
{ name: 'Claude Code (global)', base: path.join(home, '.claude', 'skills') },
|
|
1835
|
+
{ name: 'Cursor (global)', base: path.join(home, '.cursor', 'skills') },
|
|
1836
|
+
{ name: 'Antigravity (global)', base: path.join(home, '.agent', 'skills') },
|
|
1837
|
+
// Project-level skill dirs
|
|
1838
|
+
{ name: 'Claude Code (project)', base: path.join(cwd, '.claude', 'skills') },
|
|
1839
|
+
{ name: 'Cursor (project)', base: path.join(cwd, '.cursor', 'skills') },
|
|
1840
|
+
{ name: 'GitHub Skills (project)', base: path.join(cwd, '.github', 'skills') },
|
|
1841
|
+
{ name: 'Antigravity (project)', base: path.join(cwd, '.agent', 'skills') },
|
|
1842
|
+
];
|
|
1843
|
+
|
|
1844
|
+
for (const dir of skillDirs) {
|
|
1845
|
+
if (!fs.existsSync(dir.base)) continue;
|
|
1846
|
+
try {
|
|
1847
|
+
const entries = fs.readdirSync(dir.base, { withFileTypes: true });
|
|
1848
|
+
for (const entry of entries) {
|
|
1849
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
1850
|
+
const skillPath = path.join(dir.base, entry.name, 'SKILL.md');
|
|
1851
|
+
if (!fs.existsSync(skillPath)) continue;
|
|
1852
|
+
try {
|
|
1853
|
+
const content = fs.readFileSync(skillPath, 'utf8');
|
|
1854
|
+
const parsed = parseSkillFrontmatter(content);
|
|
1855
|
+
const validation = validateSkill(parsed);
|
|
1856
|
+
found.push({
|
|
1857
|
+
source: dir.name,
|
|
1858
|
+
dir: path.join(dir.base, entry.name),
|
|
1859
|
+
path: skillPath,
|
|
1860
|
+
dirName: entry.name,
|
|
1861
|
+
parsed,
|
|
1862
|
+
validation,
|
|
1863
|
+
isSymlink: entry.isSymbolicLink(),
|
|
1864
|
+
});
|
|
1865
|
+
} catch {}
|
|
1866
|
+
}
|
|
1867
|
+
} catch {}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
return found;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
// ── Server Config Extraction ─────────────────────────────
|
|
1874
|
+
|
|
1569
1875
|
function extractServersFromConfig(config) {
|
|
1570
1876
|
// Handle both { mcpServers: {...} } and { servers: {...} } formats
|
|
1571
1877
|
const servers = config.mcpServers || config.servers || {};
|
|
@@ -1627,7 +1933,10 @@ function extractServersFromConfig(config) {
|
|
|
1627
1933
|
}
|
|
1628
1934
|
} catch {}
|
|
1629
1935
|
}
|
|
1630
|
-
|
|
1936
|
+
|
|
1937
|
+
// Resolve local installation directory
|
|
1938
|
+
info.localDir = resolveLocalDir(info);
|
|
1939
|
+
|
|
1631
1940
|
result.push(info);
|
|
1632
1941
|
}
|
|
1633
1942
|
return result;
|
|
@@ -1640,6 +1949,196 @@ function serverSlug(server) {
|
|
|
1640
1949
|
return server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
|
|
1641
1950
|
}
|
|
1642
1951
|
|
|
1952
|
+
/**
|
|
1953
|
+
* Resolve the local installation directory for a discovered MCP server.
|
|
1954
|
+
* Returns an absolute path or null if not found.
|
|
1955
|
+
*/
|
|
1956
|
+
function resolveLocalDir(server) {
|
|
1957
|
+
const home = os.homedir();
|
|
1958
|
+
const isWin = process.platform === 'win32';
|
|
1959
|
+
|
|
1960
|
+
// node /path/to/file → walk up to project root (package.json or .git)
|
|
1961
|
+
const allArgs = [server.command, ...server.args].filter(Boolean).join(' ');
|
|
1962
|
+
const nodePathMatch = allArgs.match(/node\s+["']?([^"'\s]+)/);
|
|
1963
|
+
if (nodePathMatch) {
|
|
1964
|
+
let dir = path.dirname(path.resolve(nodePathMatch[1]));
|
|
1965
|
+
for (let i = 0; i < 5; i++) {
|
|
1966
|
+
if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
1967
|
+
const parent = path.dirname(dir);
|
|
1968
|
+
if (parent === dir) break;
|
|
1969
|
+
dir = parent;
|
|
1970
|
+
}
|
|
1971
|
+
// Fallback: use the script's directory
|
|
1972
|
+
return path.dirname(path.resolve(nodePathMatch[1]));
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// python /path/to/file → same approach
|
|
1976
|
+
const pyPathMatch = allArgs.match(/python[3]?\s+["']?([^"'\s]+\.py)/);
|
|
1977
|
+
if (pyPathMatch) {
|
|
1978
|
+
let dir = path.dirname(path.resolve(pyPathMatch[1]));
|
|
1979
|
+
for (let i = 0; i < 5; i++) {
|
|
1980
|
+
if (fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'setup.py')) || fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
1981
|
+
const parent = path.dirname(dir);
|
|
1982
|
+
if (parent === dir) break;
|
|
1983
|
+
dir = parent;
|
|
1984
|
+
}
|
|
1985
|
+
return path.dirname(path.resolve(pyPathMatch[1]));
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// npm/npx package → check global node_modules
|
|
1989
|
+
if (server.npmPackage) {
|
|
1990
|
+
const pkgName = server.npmPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
|
|
1991
|
+
const candidates = [];
|
|
1992
|
+
// Global npm
|
|
1993
|
+
try {
|
|
1994
|
+
const globalRoot = execFileSync('npm', ['root', '-g'], { timeout: 5000, stdio: 'pipe' }).toString().trim();
|
|
1995
|
+
candidates.push(path.join(globalRoot, pkgName));
|
|
1996
|
+
} catch {}
|
|
1997
|
+
// Local node_modules (cwd)
|
|
1998
|
+
candidates.push(path.join(process.cwd(), 'node_modules', pkgName));
|
|
1999
|
+
for (const dir of candidates) {
|
|
2000
|
+
if (fs.existsSync(dir)) return dir;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// uvx/pip package → check uv tools cache and site-packages
|
|
2005
|
+
if (server.pyPackage) {
|
|
2006
|
+
const pkgName = server.pyPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
|
|
2007
|
+
const candidates = [];
|
|
2008
|
+
if (isWin) {
|
|
2009
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
2010
|
+
candidates.push(path.join(localAppData, 'uv', 'tools', pkgName));
|
|
2011
|
+
} else {
|
|
2012
|
+
candidates.push(path.join(home, '.local', 'share', 'uv', 'tools', pkgName));
|
|
2013
|
+
}
|
|
2014
|
+
// Also try pip show
|
|
2015
|
+
try {
|
|
2016
|
+
const pipOut = execFileSync('pip', ['show', pkgName, '-f'], { timeout: 5000, stdio: 'pipe' }).toString();
|
|
2017
|
+
const locMatch = pipOut.match(/Location:\s*(.+)/);
|
|
2018
|
+
if (locMatch) {
|
|
2019
|
+
const normalized = pkgName.replace(/-/g, '_');
|
|
2020
|
+
const pkgDir = path.join(locMatch[1].trim(), normalized);
|
|
2021
|
+
if (fs.existsSync(pkgDir)) candidates.push(pkgDir);
|
|
2022
|
+
}
|
|
2023
|
+
} catch {}
|
|
2024
|
+
for (const dir of candidates) {
|
|
2025
|
+
if (fs.existsSync(dir)) return dir;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
return null;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Scan a local directory (like scanRepo but without cloning).
|
|
2034
|
+
*/
|
|
2035
|
+
async function scanLocalDir(localDir, serverName) {
|
|
2036
|
+
const start = Date.now();
|
|
2037
|
+
const slug = serverName.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
|
|
2038
|
+
|
|
2039
|
+
if (!jsonMode) process.stdout.write(`${icons.scan} Scanning ${c.bold}${slug}${c.reset} ${c.dim}(local)${c.reset} ${c.dim}...${c.reset}`);
|
|
2040
|
+
|
|
2041
|
+
// Collect files from local dir
|
|
2042
|
+
const files = collectFiles(localDir);
|
|
2043
|
+
if (files.length === 0) {
|
|
2044
|
+
if (!jsonMode) process.stdout.write(` ${c.yellow}no scannable files found${c.reset}\n`);
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// Detect info
|
|
2049
|
+
const info = detectPackageInfo(localDir, files);
|
|
2050
|
+
|
|
2051
|
+
// Quick checks
|
|
2052
|
+
const findings = quickChecks(files);
|
|
2053
|
+
|
|
2054
|
+
// Registry lookup
|
|
2055
|
+
const registryData = await checkRegistry(slug);
|
|
2056
|
+
|
|
2057
|
+
const duration = elapsed(start);
|
|
2058
|
+
|
|
2059
|
+
if (!jsonMode) {
|
|
2060
|
+
process.stdout.write('\r\x1b[K');
|
|
2061
|
+
printScanResult(`local://${localDir}`, info, files, findings, registryData, duration);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
return { slug, url: `local://${localDir}`, info, files: files.length, findings, registryData, duration };
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* Download package source from PyPI or npm to a temp dir and scan it.
|
|
2069
|
+
* Used as last resort when git clone fails and no local install exists.
|
|
2070
|
+
*/
|
|
2071
|
+
async function downloadAndScan(server) {
|
|
2072
|
+
const start = Date.now();
|
|
2073
|
+
const slug = server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
|
|
2074
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-pkg-'));
|
|
2075
|
+
|
|
2076
|
+
try {
|
|
2077
|
+
if (server.pyPackage) {
|
|
2078
|
+
const pkgName = server.pyPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
|
|
2079
|
+
if (!jsonMode) process.stdout.write(`${icons.scan} Downloading ${c.bold}${pkgName}${c.reset} ${c.dim}from PyPI...${c.reset}`);
|
|
2080
|
+
// Download sdist/wheel without installing
|
|
2081
|
+
execFileSync('pip', ['download', '--no-deps', '-d', tmpDir, pkgName], { timeout: 30000, stdio: 'pipe' });
|
|
2082
|
+
// Extract any .tar.gz or .whl (zip) files
|
|
2083
|
+
const downloaded = fs.readdirSync(tmpDir);
|
|
2084
|
+
const extractDir = path.join(tmpDir, 'src');
|
|
2085
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
2086
|
+
for (const f of downloaded) {
|
|
2087
|
+
const fp = path.join(tmpDir, f);
|
|
2088
|
+
if (f.endsWith('.whl') || f.endsWith('.zip')) {
|
|
2089
|
+
execFileSync('python', ['-m', 'zipfile', '-e', fp, extractDir], { timeout: 10000, stdio: 'pipe' });
|
|
2090
|
+
} else if (f.endsWith('.tar.gz') || f.endsWith('.tgz')) {
|
|
2091
|
+
execFileSync('tar', ['xzf', fp, '-C', extractDir], { timeout: 10000, stdio: 'pipe' });
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
const files = collectFiles(extractDir);
|
|
2095
|
+
if (files.length === 0) return null;
|
|
2096
|
+
const info = detectPackageInfo(extractDir, files);
|
|
2097
|
+
const findings = quickChecks(files);
|
|
2098
|
+
const registryData = await checkRegistry(slug);
|
|
2099
|
+
const duration = elapsed(start);
|
|
2100
|
+
if (!jsonMode) {
|
|
2101
|
+
process.stdout.write('\r\x1b[K');
|
|
2102
|
+
printScanResult(`pypi://${pkgName}`, info, files, findings, registryData, duration);
|
|
2103
|
+
}
|
|
2104
|
+
return { slug, url: `pypi://${pkgName}`, info, files: files.length, findings, registryData, duration };
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
if (server.npmPackage) {
|
|
2108
|
+
const pkgName = server.npmPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
|
|
2109
|
+
if (!jsonMode) process.stdout.write(`${icons.scan} Downloading ${c.bold}${pkgName}${c.reset} ${c.dim}from npm...${c.reset}`);
|
|
2110
|
+
// npm pack downloads tarball without installing
|
|
2111
|
+
execFileSync('npm', ['pack', pkgName, '--pack-destination', tmpDir], { timeout: 30000, stdio: 'pipe' });
|
|
2112
|
+
const tarballs = fs.readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
|
|
2113
|
+
if (tarballs.length === 0) return null;
|
|
2114
|
+
const extractDir = path.join(tmpDir, 'src');
|
|
2115
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
2116
|
+
execFileSync('tar', ['xzf', path.join(tmpDir, tarballs[0]), '-C', extractDir], { timeout: 10000, stdio: 'pipe' });
|
|
2117
|
+
const files = collectFiles(extractDir);
|
|
2118
|
+
if (files.length === 0) return null;
|
|
2119
|
+
const info = detectPackageInfo(extractDir, files);
|
|
2120
|
+
const findings = quickChecks(files);
|
|
2121
|
+
const registryData = await checkRegistry(slug);
|
|
2122
|
+
const duration = elapsed(start);
|
|
2123
|
+
if (!jsonMode) {
|
|
2124
|
+
process.stdout.write('\r\x1b[K');
|
|
2125
|
+
printScanResult(`npm://${pkgName}`, info, files, findings, registryData, duration);
|
|
2126
|
+
}
|
|
2127
|
+
return { slug, url: `npm://${pkgName}`, info, files: files.length, findings, registryData, duration };
|
|
2128
|
+
}
|
|
2129
|
+
} catch (err) {
|
|
2130
|
+
if (!jsonMode) {
|
|
2131
|
+
process.stdout.write('\r\x1b[K');
|
|
2132
|
+
process.stdout.write(`${icons.scan} ${c.bold}${slug}${c.reset} ${c.yellow}download failed${c.reset}\n`);
|
|
2133
|
+
const msg = err.stderr?.toString().trim().split('\n')[0] || err.message?.split('\n')[0] || '';
|
|
2134
|
+
if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
|
|
2135
|
+
}
|
|
2136
|
+
} finally {
|
|
2137
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
2138
|
+
}
|
|
2139
|
+
return null;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
1643
2142
|
async function searchGitHub(query) {
|
|
1644
2143
|
try {
|
|
1645
2144
|
const res = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=1`, {
|
|
@@ -1822,14 +2321,17 @@ async function discoverCommand(options = {}) {
|
|
|
1822
2321
|
const hasOfficial = regData.has_official_audit;
|
|
1823
2322
|
console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
|
|
1824
2323
|
console.log(`${pipe} ${riskBadge(riskScore)} ${hasOfficial ? `${c.green}✔ official${c.reset} ` : ''}${c.dim}${REGISTRY_URL}/packages/${slug}${c.reset}`);
|
|
1825
|
-
if (resolvedUrl) allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: true, regData });
|
|
2324
|
+
if (resolvedUrl || server.localDir || server.pyPackage || server.npmPackage) allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: true, regData });
|
|
1826
2325
|
} else {
|
|
1827
2326
|
unauditedServers++;
|
|
1828
2327
|
console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
|
|
1829
2328
|
if (resolvedUrl) {
|
|
1830
2329
|
console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Run: ${c.cyan}agentaudit audit ${resolvedUrl}${c.reset}`);
|
|
1831
2330
|
unauditedWithUrls.push({ name: server.name, sourceUrl: resolvedUrl });
|
|
1832
|
-
allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: false });
|
|
2331
|
+
allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: false });
|
|
2332
|
+
} else if (server.localDir || server.pyPackage || server.npmPackage) {
|
|
2333
|
+
console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}${server.localDir ? 'local install found' : 'package registry available'} — will scan${c.reset}`);
|
|
2334
|
+
allServersWithUrls.push({ name: server.name, sourceUrl: null, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: false });
|
|
1833
2335
|
} else {
|
|
1834
2336
|
console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Source URL unknown — check the package's GitHub/npm page${c.reset}`);
|
|
1835
2337
|
}
|
|
@@ -1854,29 +2356,115 @@ async function discoverCommand(options = {}) {
|
|
|
1854
2356
|
}
|
|
1855
2357
|
console.log();
|
|
1856
2358
|
|
|
1857
|
-
//
|
|
2359
|
+
// ── Skill Discovery ──────────────────────────────────
|
|
2360
|
+
const skills = findSkills();
|
|
2361
|
+
if (skills.length > 0) {
|
|
2362
|
+
console.log(sectionHeader(`Skills — ${skills.length} found`));
|
|
2363
|
+
console.log();
|
|
2364
|
+
|
|
2365
|
+
// Group by source
|
|
2366
|
+
const bySource = {};
|
|
2367
|
+
for (const skill of skills) {
|
|
2368
|
+
(bySource[skill.source] || (bySource[skill.source] = [])).push(skill);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
for (const [source, sourceSkills] of Object.entries(bySource)) {
|
|
2372
|
+
console.log(`${icons.bullet} ${c.bold}${source}${c.reset}`);
|
|
2373
|
+
console.log();
|
|
2374
|
+
|
|
2375
|
+
for (let i = 0; i < sourceSkills.length; i++) {
|
|
2376
|
+
const skill = sourceSkills[i];
|
|
2377
|
+
const isLast = i === sourceSkills.length - 1;
|
|
2378
|
+
const branch = isLast ? icons.treeLast : icons.tree;
|
|
2379
|
+
const pipe = isLast ? ' ' : `${icons.pipe} `;
|
|
2380
|
+
const { errors, warnings, info } = skill.validation;
|
|
2381
|
+
const name = info.name || skill.dirName;
|
|
2382
|
+
const hasErrors = errors.length > 0;
|
|
2383
|
+
const hasWarnings = warnings.length > 0;
|
|
2384
|
+
|
|
2385
|
+
// Status indicator
|
|
2386
|
+
let status;
|
|
2387
|
+
if (hasErrors) status = `${c.red}✖ ${errors.length} error${errors.length !== 1 ? 's' : ''}${c.reset}`;
|
|
2388
|
+
else if (hasWarnings) status = `${c.yellow}⚠ ${warnings.length} warning${warnings.length !== 1 ? 's' : ''}${c.reset}`;
|
|
2389
|
+
else status = `${c.green}✔ valid${c.reset}`;
|
|
2390
|
+
|
|
2391
|
+
console.log(`${branch} ${c.bold}${name}${c.reset} ${status}`);
|
|
2392
|
+
|
|
2393
|
+
// Description (truncated)
|
|
2394
|
+
if (info.description) {
|
|
2395
|
+
const desc = info.description.length > 70 ? info.description.slice(0, 67) + '...' : info.description;
|
|
2396
|
+
console.log(`${pipe} ${c.dim}${desc}${c.reset}`);
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
// MCP tool references
|
|
2400
|
+
if (info.mcpServers && info.mcpServers.length > 0) {
|
|
2401
|
+
const serverList = info.mcpServers.map(s => `${c.cyan}${s}${c.reset}`).join(', ');
|
|
2402
|
+
console.log(`${pipe} ${c.dim}uses MCP:${c.reset} ${serverList}`);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
// Allowed tools summary
|
|
2406
|
+
if (info.allowedTools === null) {
|
|
2407
|
+
console.log(`${pipe} ${c.yellow}⚠ no allowed-tools — unrestricted access${c.reset}`);
|
|
2408
|
+
} else if (info.allowedTools && info.allowedTools.length > 0) {
|
|
2409
|
+
const toolCount = info.allowedTools.length;
|
|
2410
|
+
console.log(`${pipe} ${c.dim}${toolCount} allowed tool${toolCount !== 1 ? 's' : ''}${c.reset}`);
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// Show errors/warnings inline
|
|
2414
|
+
if (hasErrors) {
|
|
2415
|
+
for (const err of errors.slice(0, 3)) {
|
|
2416
|
+
console.log(`${pipe} ${c.red} ✖ ${err}${c.reset}`);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
if (hasWarnings && !hasErrors) {
|
|
2420
|
+
for (const warn of warnings.slice(0, 2)) {
|
|
2421
|
+
console.log(`${pipe} ${c.yellow} ⚠ ${warn}${c.reset}`);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
console.log();
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// --scan: automatically scan all servers (git clone + local fallback)
|
|
1858
2430
|
if (autoScan) {
|
|
1859
2431
|
const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
|
|
1860
|
-
|
|
1861
|
-
|
|
2432
|
+
// Include servers that are cloneable OR have a local dir OR a known package
|
|
2433
|
+
const scanTargets = allServersWithUrls.filter(s =>
|
|
2434
|
+
(s.sourceUrl && isCloneable(s.sourceUrl)) || s.localDir || s.pyPackage || s.npmPackage
|
|
2435
|
+
);
|
|
2436
|
+
// Deduplicate by sourceUrl or localDir
|
|
1862
2437
|
const seen = new Set();
|
|
1863
2438
|
const dedupedTargets = scanTargets.filter(s => {
|
|
1864
|
-
|
|
1865
|
-
seen.
|
|
2439
|
+
const key = (s.sourceUrl && isCloneable(s.sourceUrl)) ? s.sourceUrl : s.localDir;
|
|
2440
|
+
if (!key || seen.has(key)) return false;
|
|
2441
|
+
seen.add(key);
|
|
1866
2442
|
return true;
|
|
1867
2443
|
});
|
|
1868
|
-
const
|
|
2444
|
+
const skippedCount = allServersWithUrls.length - scanTargets.length;
|
|
1869
2445
|
if (dedupedTargets.length > 0) {
|
|
1870
2446
|
console.log(sectionHeader(`Auto-scanning ${dedupedTargets.length} server${dedupedTargets.length !== 1 ? 's' : ''}`));
|
|
1871
2447
|
console.log(` ${c.bold}${icons.scan} Starting scans...${c.reset}`);
|
|
1872
|
-
if (
|
|
1873
|
-
console.log(` ${c.dim}(${
|
|
2448
|
+
if (skippedCount > 0) {
|
|
2449
|
+
console.log(` ${c.dim}(${skippedCount} skipped — remote-only, no local source)${c.reset}`);
|
|
1874
2450
|
}
|
|
1875
2451
|
console.log();
|
|
1876
|
-
|
|
2452
|
+
|
|
1877
2453
|
const scanResults = [];
|
|
1878
2454
|
for (const target of dedupedTargets) {
|
|
1879
|
-
|
|
2455
|
+
let result = null;
|
|
2456
|
+
// Try git clone first if URL is cloneable
|
|
2457
|
+
if (target.sourceUrl && isCloneable(target.sourceUrl)) {
|
|
2458
|
+
result = await scanRepo(target.sourceUrl);
|
|
2459
|
+
}
|
|
2460
|
+
// Fallback 1: scan local installation
|
|
2461
|
+
if (!result && target.localDir) {
|
|
2462
|
+
result = await scanLocalDir(target.localDir, target.name);
|
|
2463
|
+
}
|
|
2464
|
+
// Fallback 2: download from PyPI/npm and scan
|
|
2465
|
+
if (!result && (target.pyPackage || target.npmPackage)) {
|
|
2466
|
+
result = await downloadAndScan(target);
|
|
2467
|
+
}
|
|
1880
2468
|
if (result) scanResults.push({ ...result, serverName: target.name });
|
|
1881
2469
|
}
|
|
1882
2470
|
|
|
@@ -1969,7 +2557,7 @@ async function discoverCommand(options = {}) {
|
|
|
1969
2557
|
}
|
|
1970
2558
|
|
|
1971
2559
|
if (!autoScan && !interactiveAudit && !jsonMode) {
|
|
1972
|
-
console.log(` ${c.dim}
|
|
2560
|
+
console.log(` ${c.dim}Run ${c.cyan}agentaudit discover --quick${c.dim} to auto-scan all servers${c.reset}`);
|
|
1973
2561
|
console.log();
|
|
1974
2562
|
}
|
|
1975
2563
|
}
|
|
@@ -1982,6 +2570,91 @@ function loadAuditPrompt() {
|
|
|
1982
2570
|
return null;
|
|
1983
2571
|
}
|
|
1984
2572
|
|
|
2573
|
+
async function callLlm(llmConfig, systemPrompt, userMessage) {
|
|
2574
|
+
const apiKey = process.env[llmConfig.key];
|
|
2575
|
+
if (!apiKey) return { error: `Missing API key: ${llmConfig.key}` };
|
|
2576
|
+
const start = Date.now();
|
|
2577
|
+
let _text = '';
|
|
2578
|
+
try {
|
|
2579
|
+
let data;
|
|
2580
|
+
if (llmConfig.type === 'anthropic') {
|
|
2581
|
+
const res = await fetch(llmConfig.url, {
|
|
2582
|
+
method: 'POST',
|
|
2583
|
+
headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
2584
|
+
body: JSON.stringify({ model: llmConfig.model, max_tokens: 8192, system: systemPrompt, messages: [{ role: 'user', content: userMessage }] }),
|
|
2585
|
+
signal: AbortSignal.timeout(120_000),
|
|
2586
|
+
});
|
|
2587
|
+
data = await res.json();
|
|
2588
|
+
if (data.error) {
|
|
2589
|
+
const friendly = formatApiError(data.error, llmConfig.provider, res.status);
|
|
2590
|
+
return { error: friendly?.text || data.error.message || JSON.stringify(data.error), hint: friendly?.hint, duration: Date.now() - start };
|
|
2591
|
+
}
|
|
2592
|
+
_text = data.content?.[0]?.text || '';
|
|
2593
|
+
const report = extractJSON(_text);
|
|
2594
|
+
if (report) {
|
|
2595
|
+
report.audit_model = data.model || llmConfig.model;
|
|
2596
|
+
report.audit_provider = llmConfig.provider;
|
|
2597
|
+
if (data.id) report.provider_msg_id = data.id;
|
|
2598
|
+
if (data.usage) { report.input_tokens = data.usage.input_tokens; report.output_tokens = data.usage.output_tokens; }
|
|
2599
|
+
}
|
|
2600
|
+
return { report, text: _text, duration: Date.now() - start };
|
|
2601
|
+
} else if (llmConfig.type === 'gemini') {
|
|
2602
|
+
const res = await fetch(`${llmConfig.url}/${llmConfig.model}:generateContent?key=${apiKey}`, {
|
|
2603
|
+
method: 'POST',
|
|
2604
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2605
|
+
body: JSON.stringify({
|
|
2606
|
+
systemInstruction: { parts: [{ text: systemPrompt }] },
|
|
2607
|
+
contents: [{ role: 'user', parts: [{ text: userMessage }] }],
|
|
2608
|
+
generationConfig: { maxOutputTokens: 8192 },
|
|
2609
|
+
}),
|
|
2610
|
+
signal: AbortSignal.timeout(120_000),
|
|
2611
|
+
});
|
|
2612
|
+
data = await res.json();
|
|
2613
|
+
if (data.error) {
|
|
2614
|
+
const friendly = formatApiError(data.error, llmConfig.provider, res.status);
|
|
2615
|
+
return { error: friendly?.text || data.error.message || JSON.stringify(data.error), hint: friendly?.hint, duration: Date.now() - start };
|
|
2616
|
+
}
|
|
2617
|
+
_text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
2618
|
+
const report = extractJSON(_text);
|
|
2619
|
+
if (report) {
|
|
2620
|
+
report.audit_model = data.modelVersion || llmConfig.model;
|
|
2621
|
+
report.audit_provider = llmConfig.provider;
|
|
2622
|
+
if (data.usageMetadata) { report.input_tokens = data.usageMetadata.promptTokenCount; report.output_tokens = data.usageMetadata.candidatesTokenCount; }
|
|
2623
|
+
}
|
|
2624
|
+
return { report, text: _text, duration: Date.now() - start };
|
|
2625
|
+
} else {
|
|
2626
|
+
const headers = { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' };
|
|
2627
|
+
if (llmConfig.provider === 'openrouter') { headers['HTTP-Referer'] = 'https://agentaudit.dev'; headers['X-Title'] = 'AgentAudit CLI'; }
|
|
2628
|
+
const res = await fetch(llmConfig.url, {
|
|
2629
|
+
method: 'POST',
|
|
2630
|
+
headers,
|
|
2631
|
+
body: JSON.stringify({ model: llmConfig.model, max_tokens: 8192, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userMessage }] }),
|
|
2632
|
+
signal: AbortSignal.timeout(120_000),
|
|
2633
|
+
});
|
|
2634
|
+
data = await res.json();
|
|
2635
|
+
if (data.error) {
|
|
2636
|
+
const friendly = formatApiError(data.error, llmConfig.provider, res.status);
|
|
2637
|
+
return { error: friendly?.text || data.error.message || JSON.stringify(data.error), hint: friendly?.hint, duration: Date.now() - start };
|
|
2638
|
+
}
|
|
2639
|
+
_text = data.choices?.[0]?.message?.content || '';
|
|
2640
|
+
const report = extractJSON(_text);
|
|
2641
|
+
if (report) {
|
|
2642
|
+
report.audit_model = data.model || llmConfig.model;
|
|
2643
|
+
report.audit_provider = llmConfig.provider;
|
|
2644
|
+
if (data.id) report.provider_msg_id = data.id;
|
|
2645
|
+
if (data.system_fingerprint) report.provider_fingerprint = data.system_fingerprint;
|
|
2646
|
+
if (data.usage) { report.input_tokens = data.usage.prompt_tokens; report.output_tokens = data.usage.completion_tokens; }
|
|
2647
|
+
}
|
|
2648
|
+
return { report, text: _text, duration: Date.now() - start };
|
|
2649
|
+
}
|
|
2650
|
+
} catch (err) {
|
|
2651
|
+
const dur = Date.now() - start;
|
|
2652
|
+
if (err.name === 'TimeoutError' || err.message?.includes('timeout')) return { error: 'Request timed out (120s)', hint: 'Try again or use a faster model', duration: dur };
|
|
2653
|
+
if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED' || err.message?.includes('fetch failed')) return { error: `Network error: could not reach ${llmConfig.provider}`, hint: 'Check your internet connection', duration: dur };
|
|
2654
|
+
return { error: err.message, duration: dur };
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
|
|
1985
2658
|
async function auditRepo(url) {
|
|
1986
2659
|
const start = Date.now();
|
|
1987
2660
|
const slug = slugFromUrl(url);
|
|
@@ -2020,72 +2693,24 @@ async function auditRepo(url) {
|
|
|
2020
2693
|
}
|
|
2021
2694
|
console.log(` ${c.green}done${c.reset}`);
|
|
2022
2695
|
|
|
2023
|
-
// Step 4:
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
const
|
|
2031
|
-
const
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
if (
|
|
2036
|
-
activeLlm.model = modelOverride;
|
|
2037
|
-
}
|
|
2038
|
-
|
|
2039
|
-
if (!activeLlm) {
|
|
2040
|
-
// No LLM API key — compact explanation
|
|
2041
|
-
console.log();
|
|
2042
|
-
console.log(` ${c.yellow}No LLM API key found.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
|
|
2043
|
-
console.log();
|
|
2044
|
-
console.log(` ${c.bold}Set an API key${c.reset} (e.g. ${c.cyan}export OPENROUTER_API_KEY=sk-or-...${c.reset})`);
|
|
2045
|
-
console.log(` ${c.dim}Run "agentaudit model" to configure provider + model interactively${c.reset}`);
|
|
2046
|
-
console.log();
|
|
2047
|
-
console.log(` ${c.bold}Or export for manual review:${c.reset} ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
|
|
2048
|
-
console.log(` ${c.bold}Or use as MCP server${c.reset} in Cursor/Claude ${c.dim}(no extra API key needed)${c.reset}`);
|
|
2049
|
-
console.log(` ${c.dim}{ "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } }${c.reset}`);
|
|
2050
|
-
console.log();
|
|
2051
|
-
|
|
2052
|
-
// Check if --export flag
|
|
2053
|
-
if (process.argv.includes('--export')) {
|
|
2054
|
-
const exportPath = path.join(process.cwd(), `audit-${slug}.md`);
|
|
2055
|
-
const exportContent = [
|
|
2056
|
-
`# Security Audit: ${slug}`,
|
|
2057
|
-
`**Source:** ${url}`,
|
|
2058
|
-
`**Files:** ${files.length}`,
|
|
2059
|
-
``,
|
|
2060
|
-
`## Audit Instructions`,
|
|
2061
|
-
``,
|
|
2062
|
-
auditPrompt || '(audit prompt not found)',
|
|
2063
|
-
``,
|
|
2064
|
-
`## Report Format`,
|
|
2065
|
-
``,
|
|
2066
|
-
`After analysis, produce a JSON report:`,
|
|
2067
|
-
'```json',
|
|
2068
|
-
`{ "skill_slug": "${slug}", "source_url": "${url}", "risk_score": 0, "result": "safe", "findings": [] }`,
|
|
2069
|
-
'```',
|
|
2070
|
-
``,
|
|
2071
|
-
`## Source Code`,
|
|
2072
|
-
``,
|
|
2073
|
-
codeBlock,
|
|
2074
|
-
].join('\n');
|
|
2075
|
-
fs.writeFileSync(exportPath, exportContent);
|
|
2076
|
-
console.log(` ${icons.safe} Exported to ${c.bold}${exportPath}${c.reset}`);
|
|
2077
|
-
console.log(` ${c.dim}Paste this into any LLM (Claude, ChatGPT, etc.) for analysis${c.reset}`);
|
|
2078
|
-
}
|
|
2079
|
-
|
|
2080
|
-
// Cleanup
|
|
2081
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
2082
|
-
return null;
|
|
2083
|
-
}
|
|
2084
|
-
|
|
2085
|
-
// We have an API key — run LLM audit
|
|
2086
|
-
const modelLabel = modelOverride ? `${activeProvider} → ${activeLlm.model}` : activeProvider;
|
|
2087
|
-
process.stdout.write(` ${stepProgress(4, 4)} Running LLM analysis ${c.dim}(${modelLabel})${c.reset}...`);
|
|
2696
|
+
// Step 4: Provenance + type detection (needs repoPath on disk)
|
|
2697
|
+
let commitSha = '';
|
|
2698
|
+
try { commitSha = execSync('git rev-parse HEAD', { cwd: repoPath, encoding: 'utf8' }).trim(); } catch {}
|
|
2699
|
+
const sourceHash = crypto.createHash('sha256').update(
|
|
2700
|
+
files.slice().sort((a, b) => a.path.localeCompare(b.path))
|
|
2701
|
+
.map(f => f.path + '\n' + f.content).join('\n')
|
|
2702
|
+
).digest('hex');
|
|
2703
|
+
const pkgInfo = detectPackageInfo(repoPath, files);
|
|
2704
|
+
const KNOWN_MCP_LIBS = new Set(['fastmcp', 'jlowin-fastmcp', 'mcp-go', 'fastapi-mcp', 'fastapi_mcp', 'mcp-use', 'mcp-agent']);
|
|
2705
|
+
const KNOWN_CLI = new Set(['mcp-cli', 'mcp-scan', 'inspector']);
|
|
2706
|
+
let detectedType = pkgInfo.type === 'unknown' ? 'other' : pkgInfo.type;
|
|
2707
|
+
if (KNOWN_MCP_LIBS.has(slug)) detectedType = 'library';
|
|
2708
|
+
if (KNOWN_CLI.has(slug)) detectedType = 'cli-tool';
|
|
2088
2709
|
|
|
2710
|
+
// Cleanup repo (files in memory, provenance captured)
|
|
2711
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
2712
|
+
|
|
2713
|
+
// Build prompts
|
|
2089
2714
|
const systemPrompt = auditPrompt || 'You are a security auditor. Analyze the code and report findings as JSON.';
|
|
2090
2715
|
const userMessage = [
|
|
2091
2716
|
`Audit this package: **${slug}** (${url})`,
|
|
@@ -2101,205 +2726,278 @@ async function auditRepo(url) {
|
|
|
2101
2726
|
codeBlock,
|
|
2102
2727
|
].join('\n');
|
|
2103
2728
|
|
|
2104
|
-
|
|
2105
|
-
|
|
2729
|
+
// Helper: add provenance to a report
|
|
2730
|
+
const enrichReport = (report, duration) => {
|
|
2731
|
+
report.skill_slug = slug;
|
|
2732
|
+
report.package_type = detectedType;
|
|
2733
|
+
report.audit_duration_ms = duration || (Date.now() - start);
|
|
2734
|
+
report.files_scanned = files.length;
|
|
2735
|
+
if (commitSha) report.commit_sha = commitSha;
|
|
2736
|
+
report.source_hash = sourceHash;
|
|
2737
|
+
};
|
|
2106
2738
|
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
if (
|
|
2110
|
-
|
|
2111
|
-
|
|
2739
|
+
// Helper: upload one report
|
|
2740
|
+
const uploadReport = async (report, creds) => {
|
|
2741
|
+
if (!creds) return;
|
|
2742
|
+
process.stdout.write(` Uploading report${report.audit_model ? ` (${report.audit_model})` : ''}...`);
|
|
2743
|
+
try {
|
|
2744
|
+
const res = await fetch(`${REGISTRY_URL}/api/reports`, {
|
|
2112
2745
|
method: 'POST',
|
|
2113
|
-
headers: {
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
'content-type': 'application/json',
|
|
2117
|
-
},
|
|
2118
|
-
body: JSON.stringify({
|
|
2119
|
-
model: activeLlm.model,
|
|
2120
|
-
max_tokens: 8192,
|
|
2121
|
-
system: systemPrompt,
|
|
2122
|
-
messages: [{ role: 'user', content: userMessage }],
|
|
2123
|
-
}),
|
|
2124
|
-
signal: AbortSignal.timeout(120_000),
|
|
2746
|
+
headers: { 'Authorization': `Bearer ${creds.api_key}`, 'Content-Type': 'application/json' },
|
|
2747
|
+
body: JSON.stringify(report),
|
|
2748
|
+
signal: AbortSignal.timeout(15_000),
|
|
2125
2749
|
});
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
console.log(` ${c.dim}${friendly.hint}${c.reset}`);
|
|
2133
|
-
} else {
|
|
2134
|
-
console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
|
|
2135
|
-
}
|
|
2136
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
2137
|
-
return null;
|
|
2750
|
+
if (res.ok) {
|
|
2751
|
+
console.log(` ${c.green}done${c.reset}`);
|
|
2752
|
+
} else {
|
|
2753
|
+
let errBody = ''; try { errBody = await res.text(); } catch {}
|
|
2754
|
+
console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
|
|
2755
|
+
if (errBody && process.argv.includes('--debug')) console.log(` ${c.dim}Server: ${errBody.slice(0, 300)}${c.reset}`);
|
|
2138
2756
|
}
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2757
|
+
} catch { console.log(` ${c.yellow}failed${c.reset}`); }
|
|
2758
|
+
};
|
|
2759
|
+
|
|
2760
|
+
// Step 5: Resolve models
|
|
2761
|
+
const modelsArgIdx = process.argv.indexOf('--models');
|
|
2762
|
+
const modelsFlag = modelsArgIdx !== -1 ? process.argv[modelsArgIdx + 1] : null;
|
|
2763
|
+
const modelNames = modelsFlag ? modelsFlag.split(',').map(m => m.trim()).filter(Boolean) : [];
|
|
2764
|
+
const isMultiModel = modelNames.length > 1;
|
|
2765
|
+
|
|
2766
|
+
// ── Multi-Model Path ─────────────────────────────────────
|
|
2767
|
+
if (isMultiModel) {
|
|
2768
|
+
const resolvedModels = [];
|
|
2769
|
+
const failedModels = [];
|
|
2770
|
+
for (const name of modelNames) {
|
|
2771
|
+
const config = resolveModel(name);
|
|
2772
|
+
if (!config) { failedModels.push(name); continue; }
|
|
2773
|
+
resolvedModels.push({ name, config });
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
if (resolvedModels.length === 0) {
|
|
2777
|
+
console.log();
|
|
2778
|
+
console.log(` ${c.red}No API keys available for requested models${c.reset}`);
|
|
2779
|
+
for (const name of failedModels) console.log(` ${c.dim}${name}: no matching API key${c.reset}`);
|
|
2780
|
+
console.log(` ${c.dim}Run "agentaudit model" to configure providers${c.reset}`);
|
|
2781
|
+
return null;
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// Progress
|
|
2785
|
+
const totalSteps = resolvedModels.length;
|
|
2786
|
+
console.log(` ${stepProgress(4, 4)} Running LLM analysis ${c.dim}(${totalSteps} models in parallel)${c.reset}`);
|
|
2787
|
+
if (failedModels.length > 0) {
|
|
2788
|
+
for (const name of failedModels) console.log(` ${c.yellow}⚠${c.reset} ${name.padEnd(30)} ${c.dim}skipped (no API key)${c.reset}`);
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
// Parallel LLM calls
|
|
2792
|
+
const results = await Promise.allSettled(
|
|
2793
|
+
resolvedModels.map(async ({ name, config }) => {
|
|
2794
|
+
const result = await callLlm(config, systemPrompt, userMessage);
|
|
2795
|
+
return { name, ...result };
|
|
2796
|
+
})
|
|
2797
|
+
);
|
|
2798
|
+
|
|
2799
|
+
// Process results
|
|
2800
|
+
const reports = [];
|
|
2801
|
+
for (let i = 0; i < results.length; i++) {
|
|
2802
|
+
const name = resolvedModels[i].name;
|
|
2803
|
+
const r = results[i];
|
|
2804
|
+
if (r.status === 'rejected') {
|
|
2805
|
+
console.log(` ${c.red}✗${c.reset} ${name.padEnd(30)} ${c.red}error${c.reset}`);
|
|
2806
|
+
continue;
|
|
2149
2807
|
}
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
signal: AbortSignal.timeout(120_000),
|
|
2161
|
-
});
|
|
2162
|
-
data = await res.json();
|
|
2163
|
-
if (data.error) {
|
|
2164
|
-
console.log(` ${c.red}failed${c.reset}`);
|
|
2165
|
-
const friendly = formatApiError(data.error, activeLlm.provider, res.status);
|
|
2166
|
-
if (friendly) {
|
|
2167
|
-
console.log(` ${c.red}${friendly.text}${c.reset}`);
|
|
2168
|
-
console.log(` ${c.dim}${friendly.hint}${c.reset}`);
|
|
2169
|
-
} else {
|
|
2170
|
-
console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
|
|
2808
|
+
const { report, text, error, hint, duration } = r.value;
|
|
2809
|
+
if (error) {
|
|
2810
|
+
console.log(` ${c.red}✗${c.reset} ${name.padEnd(30)} ${c.red}${error}${c.reset}`);
|
|
2811
|
+
if (hint) console.log(` ${c.dim}${hint}${c.reset}`);
|
|
2812
|
+
continue;
|
|
2813
|
+
}
|
|
2814
|
+
if (!report) {
|
|
2815
|
+
console.log(` ${c.yellow}✗${c.reset} ${name.padEnd(30)} ${c.yellow}JSON parse failed${c.reset}`);
|
|
2816
|
+
if (process.argv.includes('--debug') && text) {
|
|
2817
|
+
console.log(` ${c.dim}${text.slice(0, 200)}...${c.reset}`);
|
|
2171
2818
|
}
|
|
2172
|
-
|
|
2173
|
-
return null;
|
|
2819
|
+
continue;
|
|
2174
2820
|
}
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2821
|
+
const durSec = Math.round((duration || 0) / 1000);
|
|
2822
|
+
console.log(` ${c.green}✓${c.reset} ${name.padEnd(30)} ${c.green}done${c.reset} ${c.dim}(${durSec}s)${c.reset}`);
|
|
2823
|
+
enrichReport(report, duration);
|
|
2824
|
+
saveHistory(report);
|
|
2825
|
+
reports.push({ name, report });
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
if (reports.length === 0) {
|
|
2829
|
+
console.log();
|
|
2830
|
+
console.log(` ${c.red}No models returned valid results${c.reset}`);
|
|
2831
|
+
return null;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// Display per-model results
|
|
2835
|
+
console.log();
|
|
2836
|
+
for (const { name, report } of reports) {
|
|
2837
|
+
console.log(sectionHeader(name));
|
|
2838
|
+
console.log(` ${riskBadge(report.risk_score || 0)}`);
|
|
2839
|
+
const fc = report.findings?.length || 0;
|
|
2840
|
+
if (fc > 0) {
|
|
2841
|
+
const counts = {};
|
|
2842
|
+
for (const f of report.findings) { const s = (f.severity || 'info').toLowerCase(); counts[s] = (counts[s] || 0) + 1; }
|
|
2843
|
+
const parts = [];
|
|
2844
|
+
for (const sev of ['critical', 'high', 'medium', 'low', 'info']) { if (counts[sev]) parts.push(`${counts[sev]} ${sev}`); }
|
|
2845
|
+
console.log(` ${c.dim}${fc} findings: ${parts.join(', ')}${c.reset}`);
|
|
2846
|
+
} else {
|
|
2847
|
+
console.log(` ${c.green}No findings${c.reset}`);
|
|
2848
|
+
}
|
|
2849
|
+
console.log();
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// Consensus comparison
|
|
2853
|
+
if (reports.length > 1) {
|
|
2854
|
+
console.log(sectionHeader('Consensus'));
|
|
2855
|
+
|
|
2856
|
+
// Risk range
|
|
2857
|
+
const risks = reports.map(r => r.report.risk_score || 0);
|
|
2858
|
+
const minRisk = Math.min(...risks);
|
|
2859
|
+
const maxRisk = Math.max(...risks);
|
|
2860
|
+
const avgRisk = Math.round(risks.reduce((a, b) => a + b, 0) / risks.length);
|
|
2861
|
+
console.log(` Risk: ${riskBadge(avgRisk)} ${c.dim}(range ${minRisk}–${maxRisk})${c.reset}`);
|
|
2862
|
+
console.log();
|
|
2863
|
+
|
|
2864
|
+
// Severity agreement
|
|
2865
|
+
const severities = reports.map(r => (r.report.max_severity || 'none').toLowerCase());
|
|
2866
|
+
const allSameSev = severities.every(s => s === severities[0]);
|
|
2867
|
+
if (allSameSev) {
|
|
2868
|
+
console.log(` ${c.green}${reports.length}/${reports.length} models agree:${c.reset} ${severities[0].toUpperCase()}`);
|
|
2869
|
+
} else {
|
|
2870
|
+
console.log(` ${c.yellow}Models disagree on severity:${c.reset}`);
|
|
2871
|
+
for (const { name, report } of reports) {
|
|
2872
|
+
const sev = (report.max_severity || 'none').toUpperCase();
|
|
2873
|
+
const sc = severityColor(report.max_severity);
|
|
2874
|
+
console.log(` ${sc}${sev.padEnd(10)}${c.reset} ${c.dim}${name}${c.reset}`);
|
|
2183
2875
|
}
|
|
2184
2876
|
}
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2877
|
+
console.log();
|
|
2878
|
+
|
|
2879
|
+
// Finding intersection (match by normalized title)
|
|
2880
|
+
const findingsByTitle = new Map();
|
|
2881
|
+
for (const { name, report } of reports) {
|
|
2882
|
+
for (const f of (report.findings || [])) {
|
|
2883
|
+
const key = (f.title || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
2884
|
+
if (!key) continue;
|
|
2885
|
+
if (!findingsByTitle.has(key)) findingsByTitle.set(key, { title: f.title, severity: f.severity, models: [] });
|
|
2886
|
+
findingsByTitle.get(key).models.push(name);
|
|
2887
|
+
}
|
|
2195
2888
|
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
],
|
|
2206
|
-
}),
|
|
2207
|
-
signal: AbortSignal.timeout(120_000),
|
|
2208
|
-
});
|
|
2209
|
-
data = await res.json();
|
|
2210
|
-
if (data.error) {
|
|
2211
|
-
console.log(` ${c.red}failed${c.reset}`);
|
|
2212
|
-
const friendly = formatApiError(data.error, activeLlm.provider, res.status);
|
|
2213
|
-
if (friendly) {
|
|
2214
|
-
console.log(` ${c.red}${friendly.text}${c.reset}`);
|
|
2215
|
-
console.log(` ${c.dim}${friendly.hint}${c.reset}`);
|
|
2216
|
-
} else {
|
|
2217
|
-
console.log(` ${c.red}API error: ${data.error.message || JSON.stringify(data.error)}${c.reset}`);
|
|
2889
|
+
|
|
2890
|
+
const shared = [...findingsByTitle.values()].filter(f => f.models.length > 1);
|
|
2891
|
+
const unique = [...findingsByTitle.values()].filter(f => f.models.length === 1);
|
|
2892
|
+
|
|
2893
|
+
if (shared.length > 0) {
|
|
2894
|
+
console.log(` ${c.bold}Shared findings (${shared.length}):${c.reset}`);
|
|
2895
|
+
for (const f of shared) {
|
|
2896
|
+
const sc = severityColor(f.severity);
|
|
2897
|
+
console.log(` ${sc}┃${c.reset} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${f.title} ${c.dim}(${f.models.length}/${reports.length})${c.reset}`);
|
|
2218
2898
|
}
|
|
2219
|
-
|
|
2220
|
-
return null;
|
|
2899
|
+
console.log();
|
|
2221
2900
|
}
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
if (data.system_fingerprint) report.provider_fingerprint = data.system_fingerprint;
|
|
2229
|
-
if (data.usage) {
|
|
2230
|
-
report.input_tokens = data.usage.prompt_tokens;
|
|
2231
|
-
report.output_tokens = data.usage.completion_tokens;
|
|
2901
|
+
|
|
2902
|
+
if (unique.length > 0) {
|
|
2903
|
+
console.log(` ${c.bold}Unique findings (${unique.length}):${c.reset}`);
|
|
2904
|
+
for (const f of unique) {
|
|
2905
|
+
const sc = severityColor(f.severity);
|
|
2906
|
+
console.log(` ${sc}┃${c.reset} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${f.title} ${c.dim}(${f.models[0]} only)${c.reset}`);
|
|
2232
2907
|
}
|
|
2908
|
+
console.log();
|
|
2233
2909
|
}
|
|
2234
2910
|
}
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
if (
|
|
2240
|
-
|
|
2241
|
-
console.log(` ${c.dim}
|
|
2242
|
-
} else if (
|
|
2243
|
-
console.log(` ${c.
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2911
|
+
|
|
2912
|
+
// Upload each report
|
|
2913
|
+
const noUpload = process.argv.includes('--no-upload');
|
|
2914
|
+
const creds = loadCredentials();
|
|
2915
|
+
if (!noUpload && creds) {
|
|
2916
|
+
for (const { report } of reports) await uploadReport(report, creds);
|
|
2917
|
+
console.log(` ${c.dim}Reports: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
|
|
2918
|
+
} else if (!noUpload && !creds) {
|
|
2919
|
+
console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to upload reports to agentaudit.dev${c.reset}`);
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
console.log();
|
|
2923
|
+
return reports.map(r => r.report);
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
// ── Single-Model Path ────────────────────────────────────
|
|
2927
|
+
// If --models has exactly 1 model, use it; otherwise resolve via --model / config / env
|
|
2928
|
+
let activeLlm;
|
|
2929
|
+
if (modelNames.length === 1) {
|
|
2930
|
+
activeLlm = resolveModel(modelNames[0]);
|
|
2931
|
+
} else {
|
|
2932
|
+
activeLlm = resolveProvider();
|
|
2933
|
+
// Model override: --model flag > AGENTAUDIT_MODEL env > credentials.json > provider default
|
|
2934
|
+
const modelArgIdx2 = process.argv.indexOf('--model');
|
|
2935
|
+
const modelFlag2 = modelArgIdx2 !== -1 ? process.argv[modelArgIdx2 + 1] : null;
|
|
2936
|
+
const modelOverride = modelFlag2 || process.env.AGENTAUDIT_MODEL || loadLlmConfig()?.llm_model || null;
|
|
2937
|
+
if (activeLlm && modelOverride) activeLlm.model = modelOverride;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
if (!activeLlm) {
|
|
2941
|
+
console.log();
|
|
2942
|
+
console.log(` ${c.yellow}No LLM API key found.${c.reset} The ${c.bold}audit${c.reset} command needs an LLM to analyze code.`);
|
|
2943
|
+
console.log();
|
|
2944
|
+
console.log(` ${c.bold}Set an API key${c.reset} (e.g. ${c.cyan}export OPENROUTER_API_KEY=sk-or-...${c.reset})`);
|
|
2945
|
+
console.log(` ${c.dim}Run "agentaudit model" to configure provider + model interactively${c.reset}`);
|
|
2946
|
+
console.log();
|
|
2947
|
+
console.log(` ${c.bold}Or export for manual review:${c.reset} ${c.cyan}agentaudit audit ${url} --export${c.reset}`);
|
|
2948
|
+
console.log(` ${c.bold}Or use as MCP server${c.reset} in Cursor/Claude ${c.dim}(no extra API key needed)${c.reset}`);
|
|
2949
|
+
console.log(` ${c.dim}{ "agentaudit": { "command": "npx", "args": ["-y", "agentaudit"] } }${c.reset}`);
|
|
2950
|
+
console.log();
|
|
2951
|
+
if (process.argv.includes('--export')) {
|
|
2952
|
+
const exportPath = path.join(process.cwd(), `audit-${slug}.md`);
|
|
2953
|
+
const exportContent = [
|
|
2954
|
+
`# Security Audit: ${slug}`, `**Source:** ${url}`, `**Files:** ${files.length}`, ``,
|
|
2955
|
+
`## Audit Instructions`, ``, auditPrompt || '(audit prompt not found)', ``,
|
|
2956
|
+
`## Report Format`, ``, `After analysis, produce a JSON report:`,
|
|
2957
|
+
'```json', `{ "skill_slug": "${slug}", "source_url": "${url}", "risk_score": 0, "result": "safe", "findings": [] }`, '```',
|
|
2958
|
+
``, `## Source Code`, ``, codeBlock,
|
|
2959
|
+
].join('\n');
|
|
2960
|
+
fs.writeFileSync(exportPath, exportContent);
|
|
2961
|
+
console.log(` ${icons.safe} Exported to ${c.bold}${exportPath}${c.reset}`);
|
|
2962
|
+
console.log(` ${c.dim}Paste this into any LLM (Claude, ChatGPT, etc.) for analysis${c.reset}`);
|
|
2247
2963
|
}
|
|
2248
|
-
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
2249
2964
|
return null;
|
|
2250
2965
|
}
|
|
2251
|
-
|
|
2252
|
-
// Provenance: compute BEFORE cleanup (needs repoPath on disk)
|
|
2253
|
-
let commitSha = '';
|
|
2254
|
-
try {
|
|
2255
|
-
commitSha = execSync('git rev-parse HEAD', { cwd: repoPath, encoding: 'utf8' }).trim();
|
|
2256
|
-
} catch { /* shallow clone without HEAD — unlikely but safe */ }
|
|
2257
|
-
const sourceHash = crypto.createHash('sha256').update(
|
|
2258
|
-
files.slice().sort((a, b) => a.path.localeCompare(b.path))
|
|
2259
|
-
.map(f => f.path + '\n' + f.content).join('\n')
|
|
2260
|
-
).digest('hex');
|
|
2261
|
-
// Code-based type detection (uses files array in memory + repoPath for context)
|
|
2262
|
-
const pkgInfo = detectPackageInfo(repoPath, files);
|
|
2263
|
-
// Known MCP frameworks are libraries, not servers (they contain MCP patterns but ARE the SDK)
|
|
2264
|
-
const KNOWN_MCP_LIBS = new Set(['fastmcp', 'jlowin-fastmcp', 'mcp-go', 'fastapi-mcp', 'fastapi_mcp', 'mcp-use', 'mcp-agent']);
|
|
2265
|
-
const KNOWN_CLI = new Set(['mcp-cli', 'mcp-scan', 'inspector']);
|
|
2266
|
-
let detectedType = pkgInfo.type === 'unknown' ? 'other' : pkgInfo.type;
|
|
2267
|
-
if (KNOWN_MCP_LIBS.has(slug)) detectedType = 'library';
|
|
2268
|
-
if (KNOWN_CLI.has(slug)) detectedType = 'cli-tool';
|
|
2269
2966
|
|
|
2270
|
-
//
|
|
2271
|
-
|
|
2967
|
+
// Single LLM call via callLlm()
|
|
2968
|
+
const modelLabel = `${activeLlm.name} → ${activeLlm.model}`;
|
|
2969
|
+
process.stdout.write(` ${stepProgress(4, 4)} Running LLM analysis ${c.dim}(${modelLabel})${c.reset}...`);
|
|
2970
|
+
|
|
2971
|
+
const llmResult = await callLlm(activeLlm, systemPrompt, userMessage);
|
|
2972
|
+
|
|
2973
|
+
if (llmResult.error) {
|
|
2974
|
+
console.log(` ${c.red}failed${c.reset}`);
|
|
2975
|
+
console.log(` ${c.red}${llmResult.error}${c.reset}`);
|
|
2976
|
+
if (llmResult.hint) console.log(` ${c.dim}${llmResult.hint}${c.reset}`);
|
|
2977
|
+
return null;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
console.log(` ${c.green}done${c.reset} ${c.dim}(${elapsed(start)})${c.reset}`);
|
|
2272
2981
|
|
|
2982
|
+
const report = llmResult.report;
|
|
2273
2983
|
if (!report) {
|
|
2274
2984
|
console.log(` ${c.red}Could not parse LLM response as JSON${c.reset}`);
|
|
2275
2985
|
console.log(` ${c.dim}Hint: run with --debug to see the raw LLM response${c.reset}`);
|
|
2276
2986
|
if (process.argv.includes('--debug')) {
|
|
2277
2987
|
console.log(` ${c.dim}--- Raw LLM response (first 2000 chars) ---${c.reset}`);
|
|
2278
|
-
console.log((
|
|
2988
|
+
console.log((llmResult.text || '(empty)').slice(0, 2000));
|
|
2279
2989
|
console.log(` ${c.dim}--- end ---${c.reset}`);
|
|
2280
2990
|
}
|
|
2281
2991
|
return null;
|
|
2282
2992
|
}
|
|
2283
2993
|
|
|
2284
|
-
|
|
2285
|
-
report
|
|
2286
|
-
|
|
2287
|
-
// Force package_type from code detection — never trust LLM-provided type
|
|
2288
|
-
report.package_type = detectedType;
|
|
2289
|
-
|
|
2290
|
-
// Add scan metadata for benchmarking
|
|
2291
|
-
report.audit_duration_ms = Date.now() - start;
|
|
2292
|
-
report.files_scanned = files.length;
|
|
2293
|
-
|
|
2294
|
-
// Set provenance data
|
|
2295
|
-
if (commitSha) report.commit_sha = commitSha;
|
|
2296
|
-
report.source_hash = sourceHash;
|
|
2994
|
+
enrichReport(report);
|
|
2995
|
+
saveHistory(report);
|
|
2297
2996
|
|
|
2298
2997
|
// Display results
|
|
2299
2998
|
console.log();
|
|
2300
|
-
const riskScore = report.risk_score || 0;
|
|
2301
2999
|
console.log(sectionHeader('Result'));
|
|
2302
|
-
console.log(` ${riskBadge(
|
|
3000
|
+
console.log(` ${riskBadge(report.risk_score || 0)}`);
|
|
2303
3001
|
console.log();
|
|
2304
3002
|
|
|
2305
3003
|
if (report.findings && report.findings.length > 0) {
|
|
@@ -2312,8 +3010,6 @@ async function auditRepo(url) {
|
|
|
2312
3010
|
if (f.description) console.log(` ${sc}┃${c.reset} ${c.dim}${f.description.slice(0, 120)}${c.reset}`);
|
|
2313
3011
|
console.log();
|
|
2314
3012
|
}
|
|
2315
|
-
|
|
2316
|
-
// Severity histogram
|
|
2317
3013
|
const histLines = severityHistogram(report.findings);
|
|
2318
3014
|
if (histLines.length > 1) {
|
|
2319
3015
|
console.log(sectionHeader('Severity'));
|
|
@@ -2324,41 +3020,16 @@ async function auditRepo(url) {
|
|
|
2324
3020
|
console.log(` ${c.green}No findings — package looks clean.${c.reset}`);
|
|
2325
3021
|
console.log();
|
|
2326
3022
|
}
|
|
2327
|
-
|
|
2328
|
-
// Upload to registry
|
|
3023
|
+
|
|
3024
|
+
// Upload to registry
|
|
2329
3025
|
const noUpload = process.argv.includes('--no-upload');
|
|
2330
3026
|
let creds = loadCredentials();
|
|
2331
3027
|
if (noUpload) {
|
|
2332
3028
|
// Skip silently
|
|
2333
3029
|
} else if (creds) {
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
const res = await fetch(`${REGISTRY_URL}/api/reports`, {
|
|
2337
|
-
method: 'POST',
|
|
2338
|
-
headers: {
|
|
2339
|
-
'Authorization': `Bearer ${creds.api_key}`,
|
|
2340
|
-
'Content-Type': 'application/json',
|
|
2341
|
-
},
|
|
2342
|
-
body: JSON.stringify(report),
|
|
2343
|
-
signal: AbortSignal.timeout(15_000),
|
|
2344
|
-
});
|
|
2345
|
-
if (res.ok) {
|
|
2346
|
-
const data = await res.json();
|
|
2347
|
-
console.log(` ${c.green}done${c.reset}`);
|
|
2348
|
-
console.log(` ${c.dim}Report: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
|
|
2349
|
-
} else {
|
|
2350
|
-
let errBody = '';
|
|
2351
|
-
try { errBody = await res.text(); } catch {}
|
|
2352
|
-
console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
|
|
2353
|
-
if (errBody && process.argv.includes('--debug')) {
|
|
2354
|
-
console.log(` ${c.dim}Server: ${errBody.slice(0, 300)}${c.reset}`);
|
|
2355
|
-
}
|
|
2356
|
-
}
|
|
2357
|
-
} catch (err) {
|
|
2358
|
-
console.log(` ${c.yellow}failed${c.reset}`);
|
|
2359
|
-
}
|
|
3030
|
+
await uploadReport(report, creds);
|
|
3031
|
+
console.log(` ${c.dim}Report: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
|
|
2360
3032
|
} else if (process.stdin.isTTY) {
|
|
2361
|
-
// No credentials — prompt to paste key or set up
|
|
2362
3033
|
console.log();
|
|
2363
3034
|
console.log(` ${c.bold}Want to upload this report to agentaudit.dev?${c.reset}`);
|
|
2364
3035
|
console.log(` ${c.dim}Create an API key at ${c.cyan}${REGISTRY_URL}/profile${c.dim} (sign in with GitHub)${c.reset}`);
|
|
@@ -2372,27 +3043,8 @@ async function auditRepo(url) {
|
|
|
2372
3043
|
saveCredentials({ api_key: pastedKey.trim(), agent_name: agentName });
|
|
2373
3044
|
creds = { api_key: pastedKey.trim(), agent_name: agentName };
|
|
2374
3045
|
console.log(` ${c.green}valid!${c.reset}`);
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
const res = await fetch(`${REGISTRY_URL}/api/reports`, {
|
|
2378
|
-
method: 'POST',
|
|
2379
|
-
headers: {
|
|
2380
|
-
'Authorization': `Bearer ${creds.api_key}`,
|
|
2381
|
-
'Content-Type': 'application/json',
|
|
2382
|
-
},
|
|
2383
|
-
body: JSON.stringify(report),
|
|
2384
|
-
signal: AbortSignal.timeout(15_000),
|
|
2385
|
-
});
|
|
2386
|
-
if (res.ok) {
|
|
2387
|
-
console.log(` ${c.green}done${c.reset}`);
|
|
2388
|
-
console.log(` ${c.dim}Report: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
|
|
2389
|
-
} else {
|
|
2390
|
-
console.log(` ${c.yellow}failed (HTTP ${res.status})${c.reset}`);
|
|
2391
|
-
}
|
|
2392
|
-
} catch (err) {
|
|
2393
|
-
console.log(` ${c.red}failed${c.reset}`);
|
|
2394
|
-
console.log(` ${c.dim}${err.message}${c.reset}`);
|
|
2395
|
-
}
|
|
3046
|
+
await uploadReport(report, creds);
|
|
3047
|
+
console.log(` ${c.dim}Report: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
|
|
2396
3048
|
} else {
|
|
2397
3049
|
console.log(` ${c.red}invalid key${c.reset}`);
|
|
2398
3050
|
console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to configure.${c.reset}`);
|
|
@@ -2401,7 +3053,7 @@ async function auditRepo(url) {
|
|
|
2401
3053
|
} else {
|
|
2402
3054
|
console.log(` ${c.dim}Run ${c.cyan}agentaudit setup${c.dim} to configure your API key and upload reports${c.reset}`);
|
|
2403
3055
|
}
|
|
2404
|
-
|
|
3056
|
+
|
|
2405
3057
|
console.log();
|
|
2406
3058
|
return report;
|
|
2407
3059
|
}
|
|
@@ -2594,20 +3246,22 @@ function renderBenchmarkTab(data, width) {
|
|
|
2594
3246
|
lines.push(` ${c.bold}${fmtNum(benchmark.models.length)}${c.reset} models ${c.dim}│${c.reset} ${c.bold}${fmtNum(overview.total_reports || 0)}${c.reset} audits ${c.dim}│${c.reset} ${c.bold}${fmtNum(overview.total_findings || 0)}${c.reset} findings`);
|
|
2595
3247
|
lines.push('');
|
|
2596
3248
|
|
|
2597
|
-
// Header
|
|
2598
|
-
const nameW =
|
|
2599
|
-
const
|
|
2600
|
-
|
|
3249
|
+
// Header — fixed column widths for alignment
|
|
3250
|
+
const nameW = 30;
|
|
3251
|
+
const auditsW = 6;
|
|
3252
|
+
const riskW = 5;
|
|
3253
|
+
const hdr = ` ${padRight('Model', nameW)} ${padLeft('Audits', auditsW)} ${padLeft('Risk', riskW)} ${'Detection'.padEnd(14)} Severity`;
|
|
3254
|
+
lines.push(` ${c.bold}${stripAnsi(hdr).trim()}${c.reset}`);
|
|
2601
3255
|
lines.push(` ${c.dim}${'─'.repeat(Math.min(width - 4, 86))}${c.reset}`);
|
|
2602
3256
|
|
|
2603
3257
|
for (const m of benchmark.models) {
|
|
2604
3258
|
const name = (m.audit_model || 'unknown').slice(0, nameW - 2);
|
|
2605
|
-
const audits = padLeft(fmtNum(m.total_audits),
|
|
3259
|
+
const audits = padLeft(fmtNum(m.total_audits), auditsW);
|
|
2606
3260
|
const riskVal = parseFloat(m.avg_risk_score) || 0;
|
|
2607
3261
|
const riskColor = riskVal <= 20 ? c.green : riskVal <= 40 ? c.yellow : c.red;
|
|
2608
|
-
const risk = `${riskColor}${padLeft(String(Math.round(riskVal)),
|
|
3262
|
+
const risk = `${riskColor}${padLeft(String(Math.round(riskVal)), riskW)}${c.reset}`;
|
|
2609
3263
|
const detection = renderGauge(m.detection_rate || 0, 100, 10);
|
|
2610
|
-
// Severity as compact text
|
|
3264
|
+
// Severity as compact text
|
|
2611
3265
|
const sev = m.severity_breakdown || {};
|
|
2612
3266
|
const sevParts = [];
|
|
2613
3267
|
if (sev.critical) sevParts.push(`${c.red}${sev.critical}C${c.reset}`);
|
|
@@ -2615,7 +3269,7 @@ function renderBenchmarkTab(data, width) {
|
|
|
2615
3269
|
if (sev.medium) sevParts.push(`${c.yellow}${sev.medium}M${c.reset}`);
|
|
2616
3270
|
if (sev.low) sevParts.push(`${c.blue}${sev.low}L${c.reset}`);
|
|
2617
3271
|
const sevStr = sevParts.length > 0 ? sevParts.join(' ') : `${c.dim}—${c.reset}`;
|
|
2618
|
-
lines.push(` ${padRight(name, nameW)}
|
|
3272
|
+
lines.push(` ${padRight(name, nameW)} ${audits} ${risk} ${detection} ${sevStr}`);
|
|
2619
3273
|
}
|
|
2620
3274
|
|
|
2621
3275
|
// Vulnerability landscape
|
|
@@ -3283,9 +3937,11 @@ async function main() {
|
|
|
3283
3937
|
// Strip global flags from args (including --model <value>)
|
|
3284
3938
|
const globalFlags = new Set(['--json', '--quiet', '-q', '--no-color', '--no-upload']);
|
|
3285
3939
|
let args = rawArgs.filter(a => !globalFlags.has(a));
|
|
3286
|
-
// Remove --model <value>
|
|
3940
|
+
// Remove --model <value> and --models <value> pairs
|
|
3287
3941
|
const modelIdx = args.indexOf('--model');
|
|
3288
3942
|
if (modelIdx !== -1) args.splice(modelIdx, 2);
|
|
3943
|
+
const modelsIdx = args.indexOf('--models');
|
|
3944
|
+
if (modelsIdx !== -1) args.splice(modelsIdx, 2);
|
|
3289
3945
|
|
|
3290
3946
|
// Detect per-command --help BEFORE stripping (e.g. `agentaudit model --help`)
|
|
3291
3947
|
const wantsHelp = args.includes('--help') || args.includes('-h');
|
|
@@ -3335,15 +3991,16 @@ async function main() {
|
|
|
3335
3991
|
`Deep LLM-powered 3-pass security audit (~30s). Requires an LLM API key.`,
|
|
3336
3992
|
``,
|
|
3337
3993
|
`${c.bold}Options:${c.reset}`,
|
|
3338
|
-
` --model <name>
|
|
3339
|
-
` --
|
|
3340
|
-
` --
|
|
3341
|
-
` --
|
|
3994
|
+
` --model <name> Override LLM model for this run`,
|
|
3995
|
+
` --models <a,b,c> Multi-model audit (parallel calls, consensus comparison)`,
|
|
3996
|
+
` --no-upload Skip uploading report to registry`,
|
|
3997
|
+
` --export Export audit payload as markdown (for manual LLM review)`,
|
|
3998
|
+
` --debug Show raw LLM response on parse errors`,
|
|
3342
3999
|
``,
|
|
3343
4000
|
`${c.bold}Examples:${c.reset}`,
|
|
3344
4001
|
` agentaudit audit https://github.com/owner/repo`,
|
|
3345
|
-
` agentaudit audit https://github.com/owner/repo --no-upload`,
|
|
3346
4002
|
` agentaudit audit https://github.com/owner/repo --model gpt-4o`,
|
|
4003
|
+
` agentaudit audit https://github.com/owner/repo --models gemini-2.5-flash,claude-sonnet-4-20250514`,
|
|
3347
4004
|
` agentaudit audit https://github.com/owner/repo --export`,
|
|
3348
4005
|
],
|
|
3349
4006
|
lookup: [
|
|
@@ -3457,10 +4114,32 @@ async function main() {
|
|
|
3457
4114
|
` agentaudit benchmark --json`,
|
|
3458
4115
|
],
|
|
3459
4116
|
bench: null, // alias → benchmark
|
|
4117
|
+
consensus: [
|
|
4118
|
+
`${c.bold}agentaudit consensus${c.reset} <package-name>`,
|
|
4119
|
+
``,
|
|
4120
|
+
`View multi-model consensus status from the AgentAudit registry.`,
|
|
4121
|
+
`Shows agreement across different LLM models and peer reviewers.`,
|
|
4122
|
+
``,
|
|
4123
|
+
`${c.bold}Options:${c.reset}`,
|
|
4124
|
+
` --json Machine-readable JSON output`,
|
|
4125
|
+
``,
|
|
4126
|
+
`${c.bold}Examples:${c.reset}`,
|
|
4127
|
+
` agentaudit consensus nanobanana-mcp-server`,
|
|
4128
|
+
` agentaudit consensus fastmcp --json`,
|
|
4129
|
+
],
|
|
4130
|
+
history: [
|
|
4131
|
+
`${c.bold}agentaudit history${c.reset} [options]`,
|
|
4132
|
+
``,
|
|
4133
|
+
`Show your local audit history. Results are stored in ~/.config/agentaudit/history/`,
|
|
4134
|
+
`after every audit run. No internet connection required.`,
|
|
4135
|
+
``,
|
|
4136
|
+
`${c.bold}Options:${c.reset}`,
|
|
4137
|
+
` --json Machine-readable JSON output`,
|
|
4138
|
+
],
|
|
3460
4139
|
activity: [
|
|
3461
4140
|
`${c.bold}agentaudit activity${c.reset} [options]`,
|
|
3462
4141
|
``,
|
|
3463
|
-
`Show your recent audits and findings from the AgentAudit registry.`,
|
|
4142
|
+
`Show your recent audits and findings from the AgentAudit registry (online).`,
|
|
3464
4143
|
`Requires being logged in (run ${c.cyan}agentaudit setup${c.reset} first).`,
|
|
3465
4144
|
``,
|
|
3466
4145
|
`${c.bold}Options:${c.reset}`,
|
|
@@ -3553,16 +4232,19 @@ async function main() {
|
|
|
3553
4232
|
console.log(` agentaudit <command> [options]`);
|
|
3554
4233
|
console.log();
|
|
3555
4234
|
console.log(` ${c.bold}SCAN & AUDIT${c.reset}`);
|
|
3556
|
-
console.log(` ${c.cyan}discover${c.reset} Find MCP servers in your AI
|
|
4235
|
+
console.log(` ${c.cyan}discover${c.reset} Find MCP servers & skills in your AI tools`);
|
|
3557
4236
|
console.log(` ${c.cyan}scan${c.reset} <url> [url...] Quick static scan (regex, ~2s)`);
|
|
3558
4237
|
console.log(` ${c.cyan}audit${c.reset} <url> [url...] Deep LLM-powered security audit (~30s)`);
|
|
4238
|
+
console.log(` ${c.cyan}validate${c.reset} [path] Validate SKILL.md format & security`);
|
|
3559
4239
|
console.log(` ${c.cyan}lookup${c.reset} <name> Look up package in registry`);
|
|
4240
|
+
console.log(` ${c.cyan}consensus${c.reset} <name> View multi-model consensus for a package`);
|
|
3560
4241
|
console.log();
|
|
3561
4242
|
console.log(` ${c.bold}COMMUNITY${c.reset}`);
|
|
3562
4243
|
console.log(` ${c.cyan}dashboard${c.reset} Interactive dashboard (full-screen)`);
|
|
3563
4244
|
console.log(` ${c.cyan}leaderboard${c.reset} Top contributors ranking`);
|
|
3564
4245
|
console.log(` ${c.cyan}benchmark${c.reset} LLM model performance comparison`);
|
|
3565
|
-
console.log(` ${c.cyan}
|
|
4246
|
+
console.log(` ${c.cyan}history${c.reset} Your local audit history`);
|
|
4247
|
+
console.log(` ${c.cyan}activity${c.reset} Your recent audits & findings (online)`);
|
|
3566
4248
|
console.log(` ${c.cyan}search${c.reset} <query> Search packages in registry`);
|
|
3567
4249
|
console.log();
|
|
3568
4250
|
console.log(` ${c.bold}CONFIGURATION${c.reset}`);
|
|
@@ -3576,6 +4258,7 @@ async function main() {
|
|
|
3576
4258
|
console.log(` ${c.dim}--quiet Suppress banner${c.reset}`);
|
|
3577
4259
|
console.log(` ${c.dim}--no-color Disable ANSI colors (also: NO_COLOR env)${c.reset}`);
|
|
3578
4260
|
console.log(` ${c.dim}--model <name> Override LLM model for this run${c.reset}`);
|
|
4261
|
+
console.log(` ${c.dim}--models <a,b,c> Multi-model audit (parallel, with consensus)${c.reset}`);
|
|
3579
4262
|
console.log(` ${c.dim}--no-upload Skip uploading report to registry${c.reset}`);
|
|
3580
4263
|
console.log(` ${c.dim}--export Export audit payload as markdown${c.reset}`);
|
|
3581
4264
|
console.log(` ${c.dim}--debug Show raw LLM response on parse errors${c.reset}`);
|
|
@@ -3584,6 +4267,7 @@ async function main() {
|
|
|
3584
4267
|
console.log(` agentaudit discover --quick`);
|
|
3585
4268
|
console.log(` agentaudit scan https://github.com/owner/repo`);
|
|
3586
4269
|
console.log(` agentaudit audit https://github.com/owner/repo`);
|
|
4270
|
+
console.log(` agentaudit audit <url> --models gemini-2.5-flash,claude-sonnet-4-20250514`);
|
|
3587
4271
|
console.log(` agentaudit lookup fastmcp --json`);
|
|
3588
4272
|
console.log();
|
|
3589
4273
|
console.log(` ${c.bold}LEARN MORE${c.reset}`);
|
|
@@ -3610,6 +4294,37 @@ async function main() {
|
|
|
3610
4294
|
await benchmarkCommand(targets);
|
|
3611
4295
|
return;
|
|
3612
4296
|
}
|
|
4297
|
+
if (command === 'history') {
|
|
4298
|
+
banner();
|
|
4299
|
+
const entries = loadHistory(30);
|
|
4300
|
+
if (entries.length === 0) {
|
|
4301
|
+
console.log(` ${c.dim}No local audit history yet. Run ${c.cyan}agentaudit audit <url>${c.dim} to start.${c.reset}`);
|
|
4302
|
+
console.log();
|
|
4303
|
+
return;
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
if (jsonMode) {
|
|
4307
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
4308
|
+
return;
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
console.log(sectionHeader(`Local History (${entries.length})`));
|
|
4312
|
+
console.log();
|
|
4313
|
+
|
|
4314
|
+
for (const entry of entries) {
|
|
4315
|
+
const slug = entry.skill_slug || 'unknown';
|
|
4316
|
+
const risk = entry.risk_score ?? '?';
|
|
4317
|
+
const sev = entry.max_severity || 'none';
|
|
4318
|
+
const sc = severityColor(sev);
|
|
4319
|
+
const model = entry.audit_model || '?';
|
|
4320
|
+
const fc = entry.findings?.length || 0;
|
|
4321
|
+
const ts = entry._file?.slice(0, 10) || '';
|
|
4322
|
+
console.log(` ${sc}┃${c.reset} ${c.bold}${slug.padEnd(30)}${c.reset} ${riskBadge(risk)} ${c.dim}${model}${c.reset}`);
|
|
4323
|
+
console.log(` ${sc}┃${c.reset} ${c.dim}${ts} ${fc} findings ${sev.toUpperCase()}${c.reset}`);
|
|
4324
|
+
console.log();
|
|
4325
|
+
}
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
3613
4328
|
if (command === 'activity' || command === 'my') {
|
|
3614
4329
|
await activityCommand(targets);
|
|
3615
4330
|
return;
|
|
@@ -3618,6 +4333,73 @@ async function main() {
|
|
|
3618
4333
|
await searchCommand(targets);
|
|
3619
4334
|
return;
|
|
3620
4335
|
}
|
|
4336
|
+
if (command === 'consensus') {
|
|
4337
|
+
banner();
|
|
4338
|
+
const pkg = targets[0];
|
|
4339
|
+
if (!pkg) {
|
|
4340
|
+
console.log(` ${c.red}Error: package name required${c.reset}`);
|
|
4341
|
+
console.log(` ${c.dim}Usage: ${c.cyan}agentaudit consensus <package-name>${c.reset}`);
|
|
4342
|
+
process.exitCode = 2;
|
|
4343
|
+
return;
|
|
4344
|
+
}
|
|
4345
|
+
const slug = pkg.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
4346
|
+
if (!jsonMode) console.log(` Fetching consensus for ${c.bold}${slug}${c.reset}...`);
|
|
4347
|
+
try {
|
|
4348
|
+
const res = await fetch(`${REGISTRY_URL}/api/packages/${slug}/consensus`, { signal: AbortSignal.timeout(10_000) });
|
|
4349
|
+
if (!res.ok) {
|
|
4350
|
+
if (res.status === 404) {
|
|
4351
|
+
console.log(` ${c.yellow}Not found${c.reset} — "${slug}" hasn't been audited yet.`);
|
|
4352
|
+
console.log(` ${c.dim}Run: ${c.cyan}agentaudit audit <repo-url>${c.dim} to create the first audit${c.reset}`);
|
|
4353
|
+
} else {
|
|
4354
|
+
console.log(` ${c.red}API error (HTTP ${res.status})${c.reset}`);
|
|
4355
|
+
}
|
|
4356
|
+
return;
|
|
4357
|
+
}
|
|
4358
|
+
const data = await res.json();
|
|
4359
|
+
if (jsonMode) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
4360
|
+
|
|
4361
|
+
console.log();
|
|
4362
|
+
console.log(sectionHeader(`Consensus: ${slug}`));
|
|
4363
|
+
console.log();
|
|
4364
|
+
|
|
4365
|
+
// Status
|
|
4366
|
+
const status = data.consensus_status || data.status || 'pending';
|
|
4367
|
+
const statusColor = status === 'reached' ? c.green : status === 'disputed' ? c.yellow : c.dim;
|
|
4368
|
+
console.log(` Status: ${statusColor}${status.toUpperCase()}${c.reset}`);
|
|
4369
|
+
|
|
4370
|
+
// Risk + Severity
|
|
4371
|
+
if (data.consensus_risk_score != null) console.log(` Risk: ${riskBadge(data.consensus_risk_score)}`);
|
|
4372
|
+
if (data.consensus_severity) {
|
|
4373
|
+
const sc = severityColor(data.consensus_severity);
|
|
4374
|
+
console.log(` Severity: ${sc}${data.consensus_severity.toUpperCase()}${c.reset}`);
|
|
4375
|
+
}
|
|
4376
|
+
|
|
4377
|
+
// Models
|
|
4378
|
+
if (data.models && data.models.length > 0) {
|
|
4379
|
+
console.log();
|
|
4380
|
+
console.log(` ${c.bold}Models (${data.models.length}):${c.reset}`);
|
|
4381
|
+
for (const m of data.models) {
|
|
4382
|
+
const sc = severityColor(m.severity || m.max_severity);
|
|
4383
|
+
const risk = m.risk_score ?? '?';
|
|
4384
|
+
console.log(` ${sc}┃${c.reset} ${(m.model || m.audit_model || '?').padEnd(30)} ${c.dim}risk ${risk}${c.reset} ${sc}${(m.severity || m.max_severity || '').toUpperCase()}${c.reset}`);
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
// Reviewers
|
|
4389
|
+
if (data.reviews != null || data.reviewer_count != null) {
|
|
4390
|
+
const count = data.reviewer_count || data.reviews?.length || 0;
|
|
4391
|
+
console.log();
|
|
4392
|
+
console.log(` ${c.dim}Reviews: ${count} | Threshold: 5 reviewers, >60% agreement${c.reset}`);
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
console.log();
|
|
4396
|
+
console.log(` ${c.dim}Full details: ${REGISTRY_URL}/packages/${slug}${c.reset}`);
|
|
4397
|
+
console.log();
|
|
4398
|
+
} catch (err) {
|
|
4399
|
+
console.log(` ${c.red}Failed: ${err.message}${c.reset}`);
|
|
4400
|
+
}
|
|
4401
|
+
return;
|
|
4402
|
+
}
|
|
3621
4403
|
|
|
3622
4404
|
banner();
|
|
3623
4405
|
|
|
@@ -3988,6 +4770,124 @@ async function main() {
|
|
|
3988
4770
|
return;
|
|
3989
4771
|
}
|
|
3990
4772
|
|
|
4773
|
+
if (command === 'validate') {
|
|
4774
|
+
const paths = targets.filter(t => !t.startsWith('--'));
|
|
4775
|
+
|
|
4776
|
+
// If no path given, find all skills and validate them
|
|
4777
|
+
if (paths.length === 0) {
|
|
4778
|
+
const skills = findSkills();
|
|
4779
|
+
if (skills.length === 0) {
|
|
4780
|
+
console.log(` ${c.yellow}No SKILL.md files found${c.reset}`);
|
|
4781
|
+
console.log(` ${c.dim}Searched: ~/.claude/skills/, ~/.cursor/skills/, .claude/skills/, .cursor/skills/${c.reset}`);
|
|
4782
|
+
console.log();
|
|
4783
|
+
console.log(` ${c.dim}Usage: ${c.cyan}agentaudit validate [path/to/SKILL.md]${c.reset}`);
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4787
|
+
console.log(` ${c.bold}Validating ${skills.length} skill${skills.length !== 1 ? 's' : ''}${c.reset}`);
|
|
4788
|
+
console.log();
|
|
4789
|
+
|
|
4790
|
+
let totalErrors = 0;
|
|
4791
|
+
let totalWarnings = 0;
|
|
4792
|
+
|
|
4793
|
+
for (const skill of skills) {
|
|
4794
|
+
const { errors, warnings, info } = skill.validation;
|
|
4795
|
+
totalErrors += errors.length;
|
|
4796
|
+
totalWarnings += warnings.length;
|
|
4797
|
+
|
|
4798
|
+
const name = info.name || skill.dirName;
|
|
4799
|
+
const hasErrors = errors.length > 0;
|
|
4800
|
+
const hasWarnings = warnings.length > 0;
|
|
4801
|
+
|
|
4802
|
+
if (hasErrors) {
|
|
4803
|
+
console.log(` ${c.red}✖${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
|
|
4804
|
+
for (const err of errors) console.log(` ${c.red}✖ ${err}${c.reset}`);
|
|
4805
|
+
for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
|
|
4806
|
+
} else if (hasWarnings) {
|
|
4807
|
+
console.log(` ${c.yellow}⚠${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
|
|
4808
|
+
for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
|
|
4809
|
+
} else {
|
|
4810
|
+
console.log(` ${c.green}✔${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
// Show MCP references
|
|
4814
|
+
if (info.mcpServers && info.mcpServers.length > 0) {
|
|
4815
|
+
console.log(` ${c.dim}MCP servers: ${info.mcpServers.join(', ')}${c.reset}`);
|
|
4816
|
+
}
|
|
4817
|
+
if (info.allowedTools === null) {
|
|
4818
|
+
console.log(` ${c.yellow}⚠ no allowed-tools — unrestricted tool access${c.reset}`);
|
|
4819
|
+
}
|
|
4820
|
+
console.log();
|
|
4821
|
+
}
|
|
4822
|
+
|
|
4823
|
+
// Summary
|
|
4824
|
+
console.log(sectionHeader('Validation Summary'));
|
|
4825
|
+
console.log();
|
|
4826
|
+
if (totalErrors === 0 && totalWarnings === 0) {
|
|
4827
|
+
console.log(` ${c.green}✔ All ${skills.length} skills valid${c.reset}`);
|
|
4828
|
+
} else {
|
|
4829
|
+
if (totalErrors > 0) console.log(` ${c.red}✖ ${totalErrors} error${totalErrors !== 1 ? 's' : ''}${c.reset}`);
|
|
4830
|
+
if (totalWarnings > 0) console.log(` ${c.yellow}⚠ ${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}${c.reset}`);
|
|
4831
|
+
}
|
|
4832
|
+
console.log();
|
|
4833
|
+
|
|
4834
|
+
if (jsonMode) {
|
|
4835
|
+
console.log(JSON.stringify(skills.map(s => ({
|
|
4836
|
+
name: s.validation.info.name || s.dirName,
|
|
4837
|
+
path: s.path,
|
|
4838
|
+
source: s.source,
|
|
4839
|
+
errors: s.validation.errors,
|
|
4840
|
+
warnings: s.validation.warnings,
|
|
4841
|
+
mcpServers: s.validation.info.mcpServers,
|
|
4842
|
+
allowedTools: s.validation.info.allowedTools,
|
|
4843
|
+
})), null, 2));
|
|
4844
|
+
}
|
|
4845
|
+
|
|
4846
|
+
process.exitCode = totalErrors > 0 ? 1 : 0;
|
|
4847
|
+
return;
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
// Validate specific file(s)
|
|
4851
|
+
for (const p of paths) {
|
|
4852
|
+
const resolved = path.resolve(p);
|
|
4853
|
+
if (!fs.existsSync(resolved)) {
|
|
4854
|
+
console.log(` ${c.red}✖ File not found: ${p}${c.reset}`);
|
|
4855
|
+
process.exitCode = 1;
|
|
4856
|
+
continue;
|
|
4857
|
+
}
|
|
4858
|
+
|
|
4859
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
4860
|
+
const parsed = parseSkillFrontmatter(content);
|
|
4861
|
+
const { errors, warnings, info } = validateSkill(parsed);
|
|
4862
|
+
const name = info.name || path.basename(path.dirname(resolved));
|
|
4863
|
+
|
|
4864
|
+
console.log(` ${c.bold}${name}${c.reset} ${c.dim}${resolved}${c.reset}`);
|
|
4865
|
+
console.log();
|
|
4866
|
+
|
|
4867
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
4868
|
+
console.log(` ${c.green}✔ Valid skill format${c.reset}`);
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4871
|
+
for (const err of errors) console.log(` ${c.red}✖ ${err}${c.reset}`);
|
|
4872
|
+
for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
|
|
4873
|
+
|
|
4874
|
+
if (info.name) console.log(` ${c.dim}name:${c.reset} ${info.name}`);
|
|
4875
|
+
if (info.description) console.log(` ${c.dim}description:${c.reset} ${info.description.slice(0, 80)}${info.description.length > 80 ? '...' : ''}`);
|
|
4876
|
+
if (info.allowedTools) console.log(` ${c.dim}allowed-tools:${c.reset} ${info.allowedTools.join(', ')}`);
|
|
4877
|
+
else if (info.allowedTools === null) console.log(` ${c.yellow}allowed-tools: none (unrestricted)${c.reset}`);
|
|
4878
|
+
if (info.mcpServers?.length > 0) console.log(` ${c.dim}MCP servers:${c.reset} ${info.mcpServers.join(', ')}`);
|
|
4879
|
+
if (info.bodyLines) console.log(` ${c.dim}body:${c.reset} ${info.bodyLines} lines`);
|
|
4880
|
+
console.log();
|
|
4881
|
+
|
|
4882
|
+
if (jsonMode) {
|
|
4883
|
+
console.log(JSON.stringify({ name: info.name, path: resolved, errors, warnings, info }, null, 2));
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4886
|
+
process.exitCode = errors.length > 0 ? 1 : 0;
|
|
4887
|
+
}
|
|
4888
|
+
return;
|
|
4889
|
+
}
|
|
4890
|
+
|
|
3991
4891
|
if (command === 'discover') {
|
|
3992
4892
|
const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
|
|
3993
4893
|
const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
|
|
@@ -4081,8 +4981,13 @@ async function main() {
|
|
|
4081
4981
|
|
|
4082
4982
|
let hasFindings = false;
|
|
4083
4983
|
for (const url of urls) {
|
|
4084
|
-
const
|
|
4085
|
-
|
|
4984
|
+
const result = await auditRepo(url);
|
|
4985
|
+
// Multi-model returns array, single-model returns object
|
|
4986
|
+
if (Array.isArray(result)) {
|
|
4987
|
+
if (result.some(r => r?.findings?.length > 0)) hasFindings = true;
|
|
4988
|
+
} else if (result?.findings?.length > 0) {
|
|
4989
|
+
hasFindings = true;
|
|
4990
|
+
}
|
|
4086
4991
|
}
|
|
4087
4992
|
process.exitCode = hasFindings ? 1 : 0;
|
|
4088
4993
|
return;
|