agentaudit 3.12.0 → 3.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +684 -36
- package/index.mjs +1 -1
- package/package.json +1 -1
package/cli.mjs
CHANGED
|
@@ -643,15 +643,17 @@ function padLeft(str, len) {
|
|
|
643
643
|
|
|
644
644
|
function drawBox(title, contentLines, width) {
|
|
645
645
|
const inner = width - 4; // 2 for "│ " + 2 for " │"
|
|
646
|
+
const totalDash = inner + 2; // total horizontal line chars between corners
|
|
646
647
|
const lines = [];
|
|
647
648
|
const titleStr = title ? ` ${title} ` : '';
|
|
648
649
|
const titleLen = visLen(titleStr);
|
|
649
|
-
|
|
650
|
-
|
|
650
|
+
// Top: ╭─ Title ────────────╮ (1 dash before title + title + remaining dashes)
|
|
651
|
+
const topDash = BOX.h.repeat(Math.max(1, totalDash - 1 - titleLen));
|
|
652
|
+
lines.push(` ${BOX.tl}${c.dim}${BOX.h}${c.reset}${c.bold}${titleStr}${c.reset}${c.dim}${topDash}${c.reset}${BOX.tr}`);
|
|
651
653
|
for (const line of contentLines) {
|
|
652
654
|
lines.push(` ${BOX.v} ${padRight(line, inner + 1)}${BOX.v}`);
|
|
653
655
|
}
|
|
654
|
-
lines.push(` ${BOX.bl}${c.dim}${BOX.h.repeat(
|
|
656
|
+
lines.push(` ${BOX.bl}${c.dim}${BOX.h.repeat(totalDash)}${c.reset}${BOX.br}`);
|
|
655
657
|
return lines;
|
|
656
658
|
}
|
|
657
659
|
|
|
@@ -929,24 +931,38 @@ function detectPackageInfo(repoPath, files) {
|
|
|
929
931
|
info.type = 'library';
|
|
930
932
|
}
|
|
931
933
|
|
|
932
|
-
// Extract MCP tools
|
|
934
|
+
// Extract MCP tools — only from files that reference MCP SDK
|
|
935
|
+
const mcpKeywords = ['modelcontextprotocol', 'FastMCP', 'mcp.server', 'mcp_server', '@mcp.tool', '@server.tool', '.tool(', 'ListTools', 'CallTool'];
|
|
936
|
+
const mcpFiles = files.filter(f => mcpKeywords.some(kw => f.content.includes(kw)));
|
|
937
|
+
// Fallback: if no MCP-specific files found, try entrypoint files
|
|
938
|
+
if (mcpFiles.length === 0) {
|
|
939
|
+
const entryNames = ['index.js', 'index.ts', 'index.mjs', 'main.py', 'server.py', 'app.py', 'src/index.ts', 'src/main.ts', 'src/index.js'];
|
|
940
|
+
for (const f of files) {
|
|
941
|
+
if (entryNames.includes(f.path)) mcpFiles.push(f);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
933
945
|
const toolPatterns = [
|
|
934
|
-
// JS/TS
|
|
935
|
-
|
|
936
|
-
// Python: @mcp.tool()
|
|
937
|
-
|
|
938
|
-
//
|
|
939
|
-
/
|
|
946
|
+
// JS/TS MCP SDK: server.tool('name', ...) or .setTool('name', ...)
|
|
947
|
+
/\.tool\s*\(\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
|
|
948
|
+
// Python: @mcp.tool() / @server.tool() followed by def name
|
|
949
|
+
/@(?:mcp|server)\.tool\s*\(.*?\)[\s\S]*?def\s+([a-z_][a-z0-9_]*)/gi,
|
|
950
|
+
// Python: Tool(name="xxx")
|
|
951
|
+
/Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
|
|
952
|
+
// ListTools handler: { name: "tool_name", description: ... }
|
|
953
|
+
/{\s*(?:['"]?)name(?:['"]?)\s*:\s*['"]([a-z_][a-z0-9_]*)['"]\s*,\s*(?:['"]?)description(?:['"]?)\s*:/gi,
|
|
940
954
|
];
|
|
941
|
-
|
|
955
|
+
|
|
956
|
+
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']);
|
|
957
|
+
|
|
942
958
|
const toolSet = new Set();
|
|
943
|
-
for (const file of
|
|
959
|
+
for (const file of mcpFiles) {
|
|
944
960
|
for (const pattern of toolPatterns) {
|
|
945
961
|
pattern.lastIndex = 0;
|
|
946
962
|
let m;
|
|
947
963
|
while ((m = pattern.exec(file.content)) !== null) {
|
|
948
964
|
const name = m[1] || m[2];
|
|
949
|
-
if (name && name.length > 2 && name.length < 50 && !
|
|
965
|
+
if (name && name.length > 2 && name.length < 50 && !toolBlacklist.has(name)) {
|
|
950
966
|
toolSet.add(name);
|
|
951
967
|
}
|
|
952
968
|
}
|
|
@@ -1566,6 +1582,235 @@ function findMcpConfigs() {
|
|
|
1566
1582
|
return found;
|
|
1567
1583
|
}
|
|
1568
1584
|
|
|
1585
|
+
// ── Skill Discovery & Validation ─────────────────────────
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Parse YAML frontmatter from a SKILL.md file.
|
|
1589
|
+
* Returns { meta: {...}, body: string, errors: string[] }
|
|
1590
|
+
*/
|
|
1591
|
+
function parseSkillFrontmatter(content) {
|
|
1592
|
+
const errors = [];
|
|
1593
|
+
const lines = content.split('\n');
|
|
1594
|
+
|
|
1595
|
+
// Must start with ---
|
|
1596
|
+
if (lines[0].trim() !== '---') {
|
|
1597
|
+
return { meta: null, body: content, errors: ['Missing YAML frontmatter (file must start with ---)'] };
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// Find closing ---
|
|
1601
|
+
let endIdx = -1;
|
|
1602
|
+
for (let i = 1; i < lines.length; i++) {
|
|
1603
|
+
if (lines[i].trim() === '---') { endIdx = i; break; }
|
|
1604
|
+
}
|
|
1605
|
+
if (endIdx === -1) {
|
|
1606
|
+
return { meta: null, body: content, errors: ['Unclosed frontmatter (missing closing ---)'] };
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Parse YAML-like key: value pairs
|
|
1610
|
+
const meta = {};
|
|
1611
|
+
const yamlLines = lines.slice(1, endIdx);
|
|
1612
|
+
for (let i = 0; i < yamlLines.length; i++) {
|
|
1613
|
+
const line = yamlLines[i];
|
|
1614
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue;
|
|
1615
|
+
|
|
1616
|
+
// Check for tabs
|
|
1617
|
+
if (line.includes('\t')) {
|
|
1618
|
+
errors.push(`Line ${i + 2}: Tab character found (use spaces)`);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const match = line.match(/^([a-z][a-z0-9_-]*):\s*(.*)/i);
|
|
1622
|
+
if (!match) {
|
|
1623
|
+
// Could be a continuation line (YAML multiline)
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
const key = match[1].toLowerCase();
|
|
1627
|
+
let value = match[2].trim();
|
|
1628
|
+
|
|
1629
|
+
// Handle YAML lists on next lines
|
|
1630
|
+
if (value === '' && i + 1 < yamlLines.length && yamlLines[i + 1].match(/^\s+-\s/)) {
|
|
1631
|
+
const items = [];
|
|
1632
|
+
let j = i + 1;
|
|
1633
|
+
while (j < yamlLines.length && yamlLines[j].match(/^\s+-\s/)) {
|
|
1634
|
+
items.push(yamlLines[j].replace(/^\s+-\s*/, '').trim());
|
|
1635
|
+
j++;
|
|
1636
|
+
}
|
|
1637
|
+
value = items;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Strip surrounding quotes
|
|
1641
|
+
if (typeof value === 'string' && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
|
|
1642
|
+
value = value.slice(1, -1);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
meta[key] = value;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const body = lines.slice(endIdx + 1).join('\n').trim();
|
|
1649
|
+
return { meta, body, errors };
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Validate a parsed skill against the Claude Code SKILL.md spec.
|
|
1654
|
+
* Returns { errors: [...], warnings: [...], info: {...} }
|
|
1655
|
+
*/
|
|
1656
|
+
function validateSkill(parsed) {
|
|
1657
|
+
const { meta, body, errors: parseErrors } = parsed;
|
|
1658
|
+
const errors = [...parseErrors];
|
|
1659
|
+
const warnings = [];
|
|
1660
|
+
const info = {};
|
|
1661
|
+
|
|
1662
|
+
if (!meta) return { errors, warnings, info };
|
|
1663
|
+
|
|
1664
|
+
// Known fields
|
|
1665
|
+
const knownFields = new Set([
|
|
1666
|
+
'name', 'description', 'allowed-tools', 'user-invocable', 'user-invokable',
|
|
1667
|
+
'disable-model-invocation', 'license', 'metadata', 'argument-hint',
|
|
1668
|
+
'compatibility', 'version', 'author',
|
|
1669
|
+
]);
|
|
1670
|
+
|
|
1671
|
+
// Check for unknown fields
|
|
1672
|
+
for (const key of Object.keys(meta)) {
|
|
1673
|
+
if (!knownFields.has(key)) {
|
|
1674
|
+
warnings.push(`Unknown frontmatter field: "${key}"`);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// Required: name
|
|
1679
|
+
if (!meta.name) {
|
|
1680
|
+
errors.push('Missing required field: name');
|
|
1681
|
+
} else {
|
|
1682
|
+
info.name = meta.name;
|
|
1683
|
+
if (meta.name.length > 64) errors.push(`name exceeds 64 chars (${meta.name.length})`);
|
|
1684
|
+
if (/<[^>]+>/.test(meta.name)) errors.push('name contains XML/HTML tags');
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Required: description
|
|
1688
|
+
if (!meta.description) {
|
|
1689
|
+
errors.push('Missing required field: description');
|
|
1690
|
+
} else {
|
|
1691
|
+
info.description = typeof meta.description === 'string' ? meta.description.slice(0, 120) : String(meta.description).slice(0, 120);
|
|
1692
|
+
if (typeof meta.description === 'string' && meta.description.length > 1024) {
|
|
1693
|
+
warnings.push(`description is ${meta.description.length} chars (recommended max: 1024)`);
|
|
1694
|
+
}
|
|
1695
|
+
if (/<[^>]+>/.test(meta.description)) warnings.push('description contains XML/HTML tags');
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// Security: allowed-tools
|
|
1699
|
+
if (!meta['allowed-tools']) {
|
|
1700
|
+
warnings.push('No allowed-tools set — skill has access to ALL tools (security risk)');
|
|
1701
|
+
info.allowedTools = null;
|
|
1702
|
+
} else {
|
|
1703
|
+
const tools = typeof meta['allowed-tools'] === 'string'
|
|
1704
|
+
? meta['allowed-tools'].split(',').map(t => t.trim()).filter(Boolean)
|
|
1705
|
+
: Array.isArray(meta['allowed-tools']) ? meta['allowed-tools'] : [];
|
|
1706
|
+
info.allowedTools = tools;
|
|
1707
|
+
// Check for wildcard/dangerous patterns
|
|
1708
|
+
if (tools.some(t => t === '*' || t === 'Bash' || t === 'Bash(*)')) {
|
|
1709
|
+
warnings.push('allowed-tools includes unrestricted Bash access');
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// Boolean fields
|
|
1714
|
+
for (const boolField of ['user-invocable', 'user-invokable', 'disable-model-invocation']) {
|
|
1715
|
+
if (meta[boolField] !== undefined) {
|
|
1716
|
+
const val = String(meta[boolField]).toLowerCase();
|
|
1717
|
+
if (!['true', 'false'].includes(val)) {
|
|
1718
|
+
errors.push(`${boolField} must be true or false (got: "${meta[boolField]}")`);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Typo detection
|
|
1724
|
+
if (meta['user-invokable'] && !meta['user-invocable']) {
|
|
1725
|
+
warnings.push('Using "user-invokable" (known typo variant) — both spellings work');
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Body checks
|
|
1729
|
+
if (body) {
|
|
1730
|
+
const bodyLines = body.split('\n').length;
|
|
1731
|
+
info.bodyLines = bodyLines;
|
|
1732
|
+
if (bodyLines > 500) warnings.push(`Body is ${bodyLines} lines (recommended max: 500)`);
|
|
1733
|
+
|
|
1734
|
+
// Check for potential prompt injection patterns in body
|
|
1735
|
+
const injectionPatterns = [
|
|
1736
|
+
{ pattern: /ignore\s+(all\s+)?previous\s+(instructions|rules)/i, label: 'Prompt injection pattern' },
|
|
1737
|
+
{ pattern: /<IMPORTANT>/i, label: 'Suspicious <IMPORTANT> tag' },
|
|
1738
|
+
{ pattern: /system\s*:\s*you\s+are/i, label: 'System prompt override attempt' },
|
|
1739
|
+
];
|
|
1740
|
+
for (const { pattern, label } of injectionPatterns) {
|
|
1741
|
+
if (pattern.test(body)) {
|
|
1742
|
+
warnings.push(`${label} detected in body`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Extract MCP tool references
|
|
1748
|
+
const mcpRefs = [];
|
|
1749
|
+
const mcpPattern = /mcp__([a-z0-9_-]+)__([a-z0-9_]+)/gi;
|
|
1750
|
+
const fullText = (meta.description || '') + ' ' + (typeof meta['allowed-tools'] === 'string' ? meta['allowed-tools'] : '') + ' ' + (body || '');
|
|
1751
|
+
let mcpMatch;
|
|
1752
|
+
while ((mcpMatch = mcpPattern.exec(fullText)) !== null) {
|
|
1753
|
+
mcpRefs.push({ server: mcpMatch[1], tool: mcpMatch[2] });
|
|
1754
|
+
}
|
|
1755
|
+
info.mcpRefs = mcpRefs;
|
|
1756
|
+
|
|
1757
|
+
// Deduplicate MCP server names
|
|
1758
|
+
info.mcpServers = [...new Set(mcpRefs.map(r => r.server))];
|
|
1759
|
+
|
|
1760
|
+
return { errors, warnings, info };
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* Find all SKILL.md files in known skill directories.
|
|
1765
|
+
*/
|
|
1766
|
+
function findSkills() {
|
|
1767
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
1768
|
+
const cwd = process.cwd();
|
|
1769
|
+
const found = [];
|
|
1770
|
+
|
|
1771
|
+
const skillDirs = [
|
|
1772
|
+
// Global skill dirs
|
|
1773
|
+
{ name: 'Claude Code (global)', base: path.join(home, '.claude', 'skills') },
|
|
1774
|
+
{ name: 'Cursor (global)', base: path.join(home, '.cursor', 'skills') },
|
|
1775
|
+
{ name: 'Antigravity (global)', base: path.join(home, '.agent', 'skills') },
|
|
1776
|
+
// Project-level skill dirs
|
|
1777
|
+
{ name: 'Claude Code (project)', base: path.join(cwd, '.claude', 'skills') },
|
|
1778
|
+
{ name: 'Cursor (project)', base: path.join(cwd, '.cursor', 'skills') },
|
|
1779
|
+
{ name: 'GitHub Skills (project)', base: path.join(cwd, '.github', 'skills') },
|
|
1780
|
+
{ name: 'Antigravity (project)', base: path.join(cwd, '.agent', 'skills') },
|
|
1781
|
+
];
|
|
1782
|
+
|
|
1783
|
+
for (const dir of skillDirs) {
|
|
1784
|
+
if (!fs.existsSync(dir.base)) continue;
|
|
1785
|
+
try {
|
|
1786
|
+
const entries = fs.readdirSync(dir.base, { withFileTypes: true });
|
|
1787
|
+
for (const entry of entries) {
|
|
1788
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
1789
|
+
const skillPath = path.join(dir.base, entry.name, 'SKILL.md');
|
|
1790
|
+
if (!fs.existsSync(skillPath)) continue;
|
|
1791
|
+
try {
|
|
1792
|
+
const content = fs.readFileSync(skillPath, 'utf8');
|
|
1793
|
+
const parsed = parseSkillFrontmatter(content);
|
|
1794
|
+
const validation = validateSkill(parsed);
|
|
1795
|
+
found.push({
|
|
1796
|
+
source: dir.name,
|
|
1797
|
+
dir: path.join(dir.base, entry.name),
|
|
1798
|
+
path: skillPath,
|
|
1799
|
+
dirName: entry.name,
|
|
1800
|
+
parsed,
|
|
1801
|
+
validation,
|
|
1802
|
+
isSymlink: entry.isSymbolicLink(),
|
|
1803
|
+
});
|
|
1804
|
+
} catch {}
|
|
1805
|
+
}
|
|
1806
|
+
} catch {}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
return found;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// ── Server Config Extraction ─────────────────────────────
|
|
1813
|
+
|
|
1569
1814
|
function extractServersFromConfig(config) {
|
|
1570
1815
|
// Handle both { mcpServers: {...} } and { servers: {...} } formats
|
|
1571
1816
|
const servers = config.mcpServers || config.servers || {};
|
|
@@ -1627,7 +1872,10 @@ function extractServersFromConfig(config) {
|
|
|
1627
1872
|
}
|
|
1628
1873
|
} catch {}
|
|
1629
1874
|
}
|
|
1630
|
-
|
|
1875
|
+
|
|
1876
|
+
// Resolve local installation directory
|
|
1877
|
+
info.localDir = resolveLocalDir(info);
|
|
1878
|
+
|
|
1631
1879
|
result.push(info);
|
|
1632
1880
|
}
|
|
1633
1881
|
return result;
|
|
@@ -1640,6 +1888,196 @@ function serverSlug(server) {
|
|
|
1640
1888
|
return server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
|
|
1641
1889
|
}
|
|
1642
1890
|
|
|
1891
|
+
/**
|
|
1892
|
+
* Resolve the local installation directory for a discovered MCP server.
|
|
1893
|
+
* Returns an absolute path or null if not found.
|
|
1894
|
+
*/
|
|
1895
|
+
function resolveLocalDir(server) {
|
|
1896
|
+
const home = os.homedir();
|
|
1897
|
+
const isWin = process.platform === 'win32';
|
|
1898
|
+
|
|
1899
|
+
// node /path/to/file → walk up to project root (package.json or .git)
|
|
1900
|
+
const allArgs = [server.command, ...server.args].filter(Boolean).join(' ');
|
|
1901
|
+
const nodePathMatch = allArgs.match(/node\s+["']?([^"'\s]+)/);
|
|
1902
|
+
if (nodePathMatch) {
|
|
1903
|
+
let dir = path.dirname(path.resolve(nodePathMatch[1]));
|
|
1904
|
+
for (let i = 0; i < 5; i++) {
|
|
1905
|
+
if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
1906
|
+
const parent = path.dirname(dir);
|
|
1907
|
+
if (parent === dir) break;
|
|
1908
|
+
dir = parent;
|
|
1909
|
+
}
|
|
1910
|
+
// Fallback: use the script's directory
|
|
1911
|
+
return path.dirname(path.resolve(nodePathMatch[1]));
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// python /path/to/file → same approach
|
|
1915
|
+
const pyPathMatch = allArgs.match(/python[3]?\s+["']?([^"'\s]+\.py)/);
|
|
1916
|
+
if (pyPathMatch) {
|
|
1917
|
+
let dir = path.dirname(path.resolve(pyPathMatch[1]));
|
|
1918
|
+
for (let i = 0; i < 5; i++) {
|
|
1919
|
+
if (fs.existsSync(path.join(dir, 'pyproject.toml')) || fs.existsSync(path.join(dir, 'setup.py')) || fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
1920
|
+
const parent = path.dirname(dir);
|
|
1921
|
+
if (parent === dir) break;
|
|
1922
|
+
dir = parent;
|
|
1923
|
+
}
|
|
1924
|
+
return path.dirname(path.resolve(pyPathMatch[1]));
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// npm/npx package → check global node_modules
|
|
1928
|
+
if (server.npmPackage) {
|
|
1929
|
+
const pkgName = server.npmPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
|
|
1930
|
+
const candidates = [];
|
|
1931
|
+
// Global npm
|
|
1932
|
+
try {
|
|
1933
|
+
const globalRoot = execFileSync('npm', ['root', '-g'], { timeout: 5000, stdio: 'pipe' }).toString().trim();
|
|
1934
|
+
candidates.push(path.join(globalRoot, pkgName));
|
|
1935
|
+
} catch {}
|
|
1936
|
+
// Local node_modules (cwd)
|
|
1937
|
+
candidates.push(path.join(process.cwd(), 'node_modules', pkgName));
|
|
1938
|
+
for (const dir of candidates) {
|
|
1939
|
+
if (fs.existsSync(dir)) return dir;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// uvx/pip package → check uv tools cache and site-packages
|
|
1944
|
+
if (server.pyPackage) {
|
|
1945
|
+
const pkgName = server.pyPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
|
|
1946
|
+
const candidates = [];
|
|
1947
|
+
if (isWin) {
|
|
1948
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
1949
|
+
candidates.push(path.join(localAppData, 'uv', 'tools', pkgName));
|
|
1950
|
+
} else {
|
|
1951
|
+
candidates.push(path.join(home, '.local', 'share', 'uv', 'tools', pkgName));
|
|
1952
|
+
}
|
|
1953
|
+
// Also try pip show
|
|
1954
|
+
try {
|
|
1955
|
+
const pipOut = execFileSync('pip', ['show', pkgName, '-f'], { timeout: 5000, stdio: 'pipe' }).toString();
|
|
1956
|
+
const locMatch = pipOut.match(/Location:\s*(.+)/);
|
|
1957
|
+
if (locMatch) {
|
|
1958
|
+
const normalized = pkgName.replace(/-/g, '_');
|
|
1959
|
+
const pkgDir = path.join(locMatch[1].trim(), normalized);
|
|
1960
|
+
if (fs.existsSync(pkgDir)) candidates.push(pkgDir);
|
|
1961
|
+
}
|
|
1962
|
+
} catch {}
|
|
1963
|
+
for (const dir of candidates) {
|
|
1964
|
+
if (fs.existsSync(dir)) return dir;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* Scan a local directory (like scanRepo but without cloning).
|
|
1973
|
+
*/
|
|
1974
|
+
async function scanLocalDir(localDir, serverName) {
|
|
1975
|
+
const start = Date.now();
|
|
1976
|
+
const slug = serverName.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
|
|
1977
|
+
|
|
1978
|
+
if (!jsonMode) process.stdout.write(`${icons.scan} Scanning ${c.bold}${slug}${c.reset} ${c.dim}(local)${c.reset} ${c.dim}...${c.reset}`);
|
|
1979
|
+
|
|
1980
|
+
// Collect files from local dir
|
|
1981
|
+
const files = collectFiles(localDir);
|
|
1982
|
+
if (files.length === 0) {
|
|
1983
|
+
if (!jsonMode) process.stdout.write(` ${c.yellow}no scannable files found${c.reset}\n`);
|
|
1984
|
+
return null;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Detect info
|
|
1988
|
+
const info = detectPackageInfo(localDir, files);
|
|
1989
|
+
|
|
1990
|
+
// Quick checks
|
|
1991
|
+
const findings = quickChecks(files);
|
|
1992
|
+
|
|
1993
|
+
// Registry lookup
|
|
1994
|
+
const registryData = await checkRegistry(slug);
|
|
1995
|
+
|
|
1996
|
+
const duration = elapsed(start);
|
|
1997
|
+
|
|
1998
|
+
if (!jsonMode) {
|
|
1999
|
+
process.stdout.write('\r\x1b[K');
|
|
2000
|
+
printScanResult(`local://${localDir}`, info, files, findings, registryData, duration);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
return { slug, url: `local://${localDir}`, info, files: files.length, findings, registryData, duration };
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/**
|
|
2007
|
+
* Download package source from PyPI or npm to a temp dir and scan it.
|
|
2008
|
+
* Used as last resort when git clone fails and no local install exists.
|
|
2009
|
+
*/
|
|
2010
|
+
async function downloadAndScan(server) {
|
|
2011
|
+
const start = Date.now();
|
|
2012
|
+
const slug = server.name.toLowerCase().replace(/[^a-z0-9-]/gi, '-');
|
|
2013
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentaudit-pkg-'));
|
|
2014
|
+
|
|
2015
|
+
try {
|
|
2016
|
+
if (server.pyPackage) {
|
|
2017
|
+
const pkgName = server.pyPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
|
|
2018
|
+
if (!jsonMode) process.stdout.write(`${icons.scan} Downloading ${c.bold}${pkgName}${c.reset} ${c.dim}from PyPI...${c.reset}`);
|
|
2019
|
+
// Download sdist/wheel without installing
|
|
2020
|
+
execFileSync('pip', ['download', '--no-deps', '-d', tmpDir, pkgName], { timeout: 30000, stdio: 'pipe' });
|
|
2021
|
+
// Extract any .tar.gz or .whl (zip) files
|
|
2022
|
+
const downloaded = fs.readdirSync(tmpDir);
|
|
2023
|
+
const extractDir = path.join(tmpDir, 'src');
|
|
2024
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
2025
|
+
for (const f of downloaded) {
|
|
2026
|
+
const fp = path.join(tmpDir, f);
|
|
2027
|
+
if (f.endsWith('.whl') || f.endsWith('.zip')) {
|
|
2028
|
+
execFileSync('python', ['-m', 'zipfile', '-e', fp, extractDir], { timeout: 10000, stdio: 'pipe' });
|
|
2029
|
+
} else if (f.endsWith('.tar.gz') || f.endsWith('.tgz')) {
|
|
2030
|
+
execFileSync('tar', ['xzf', fp, '-C', extractDir], { timeout: 10000, stdio: 'pipe' });
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
const files = collectFiles(extractDir);
|
|
2034
|
+
if (files.length === 0) return null;
|
|
2035
|
+
const info = detectPackageInfo(extractDir, files);
|
|
2036
|
+
const findings = quickChecks(files);
|
|
2037
|
+
const registryData = await checkRegistry(slug);
|
|
2038
|
+
const duration = elapsed(start);
|
|
2039
|
+
if (!jsonMode) {
|
|
2040
|
+
process.stdout.write('\r\x1b[K');
|
|
2041
|
+
printScanResult(`pypi://${pkgName}`, info, files, findings, registryData, duration);
|
|
2042
|
+
}
|
|
2043
|
+
return { slug, url: `pypi://${pkgName}`, info, files: files.length, findings, registryData, duration };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
if (server.npmPackage) {
|
|
2047
|
+
const pkgName = server.npmPackage.replace(/@latest$/, '').replace(/@[\d.]+$/, '');
|
|
2048
|
+
if (!jsonMode) process.stdout.write(`${icons.scan} Downloading ${c.bold}${pkgName}${c.reset} ${c.dim}from npm...${c.reset}`);
|
|
2049
|
+
// npm pack downloads tarball without installing
|
|
2050
|
+
execFileSync('npm', ['pack', pkgName, '--pack-destination', tmpDir], { timeout: 30000, stdio: 'pipe' });
|
|
2051
|
+
const tarballs = fs.readdirSync(tmpDir).filter(f => f.endsWith('.tgz'));
|
|
2052
|
+
if (tarballs.length === 0) return null;
|
|
2053
|
+
const extractDir = path.join(tmpDir, 'src');
|
|
2054
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
2055
|
+
execFileSync('tar', ['xzf', path.join(tmpDir, tarballs[0]), '-C', extractDir], { timeout: 10000, stdio: 'pipe' });
|
|
2056
|
+
const files = collectFiles(extractDir);
|
|
2057
|
+
if (files.length === 0) return null;
|
|
2058
|
+
const info = detectPackageInfo(extractDir, files);
|
|
2059
|
+
const findings = quickChecks(files);
|
|
2060
|
+
const registryData = await checkRegistry(slug);
|
|
2061
|
+
const duration = elapsed(start);
|
|
2062
|
+
if (!jsonMode) {
|
|
2063
|
+
process.stdout.write('\r\x1b[K');
|
|
2064
|
+
printScanResult(`npm://${pkgName}`, info, files, findings, registryData, duration);
|
|
2065
|
+
}
|
|
2066
|
+
return { slug, url: `npm://${pkgName}`, info, files: files.length, findings, registryData, duration };
|
|
2067
|
+
}
|
|
2068
|
+
} catch (err) {
|
|
2069
|
+
if (!jsonMode) {
|
|
2070
|
+
process.stdout.write('\r\x1b[K');
|
|
2071
|
+
process.stdout.write(`${icons.scan} ${c.bold}${slug}${c.reset} ${c.yellow}download failed${c.reset}\n`);
|
|
2072
|
+
const msg = err.stderr?.toString().trim().split('\n')[0] || err.message?.split('\n')[0] || '';
|
|
2073
|
+
if (msg) console.log(` ${c.dim}${msg}${c.reset}`);
|
|
2074
|
+
}
|
|
2075
|
+
} finally {
|
|
2076
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
2077
|
+
}
|
|
2078
|
+
return null;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
1643
2081
|
async function searchGitHub(query) {
|
|
1644
2082
|
try {
|
|
1645
2083
|
const res = await fetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&per_page=1`, {
|
|
@@ -1822,14 +2260,17 @@ async function discoverCommand(options = {}) {
|
|
|
1822
2260
|
const hasOfficial = regData.has_official_audit;
|
|
1823
2261
|
console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
|
|
1824
2262
|
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 });
|
|
2263
|
+
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
2264
|
} else {
|
|
1827
2265
|
unauditedServers++;
|
|
1828
2266
|
console.log(`${branch} ${c.bold}${server.name}${c.reset} ${sourceLabel}`);
|
|
1829
2267
|
if (resolvedUrl) {
|
|
1830
2268
|
console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Run: ${c.cyan}agentaudit audit ${resolvedUrl}${c.reset}`);
|
|
1831
2269
|
unauditedWithUrls.push({ name: server.name, sourceUrl: resolvedUrl });
|
|
1832
|
-
allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, hasAudit: false });
|
|
2270
|
+
allServersWithUrls.push({ name: server.name, sourceUrl: resolvedUrl, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: false });
|
|
2271
|
+
} else if (server.localDir || server.pyPackage || server.npmPackage) {
|
|
2272
|
+
console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}${server.localDir ? 'local install found' : 'package registry available'} — will scan${c.reset}`);
|
|
2273
|
+
allServersWithUrls.push({ name: server.name, sourceUrl: null, localDir: server.localDir, pyPackage: server.pyPackage, npmPackage: server.npmPackage, hasAudit: false });
|
|
1833
2274
|
} else {
|
|
1834
2275
|
console.log(`${pipe} ${c.yellow}⚠ not audited${c.reset} ${c.dim}Source URL unknown — check the package's GitHub/npm page${c.reset}`);
|
|
1835
2276
|
}
|
|
@@ -1854,29 +2295,115 @@ async function discoverCommand(options = {}) {
|
|
|
1854
2295
|
}
|
|
1855
2296
|
console.log();
|
|
1856
2297
|
|
|
1857
|
-
//
|
|
2298
|
+
// ── Skill Discovery ──────────────────────────────────
|
|
2299
|
+
const skills = findSkills();
|
|
2300
|
+
if (skills.length > 0) {
|
|
2301
|
+
console.log(sectionHeader(`Skills — ${skills.length} found`));
|
|
2302
|
+
console.log();
|
|
2303
|
+
|
|
2304
|
+
// Group by source
|
|
2305
|
+
const bySource = {};
|
|
2306
|
+
for (const skill of skills) {
|
|
2307
|
+
(bySource[skill.source] || (bySource[skill.source] = [])).push(skill);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
for (const [source, sourceSkills] of Object.entries(bySource)) {
|
|
2311
|
+
console.log(`${icons.bullet} ${c.bold}${source}${c.reset}`);
|
|
2312
|
+
console.log();
|
|
2313
|
+
|
|
2314
|
+
for (let i = 0; i < sourceSkills.length; i++) {
|
|
2315
|
+
const skill = sourceSkills[i];
|
|
2316
|
+
const isLast = i === sourceSkills.length - 1;
|
|
2317
|
+
const branch = isLast ? icons.treeLast : icons.tree;
|
|
2318
|
+
const pipe = isLast ? ' ' : `${icons.pipe} `;
|
|
2319
|
+
const { errors, warnings, info } = skill.validation;
|
|
2320
|
+
const name = info.name || skill.dirName;
|
|
2321
|
+
const hasErrors = errors.length > 0;
|
|
2322
|
+
const hasWarnings = warnings.length > 0;
|
|
2323
|
+
|
|
2324
|
+
// Status indicator
|
|
2325
|
+
let status;
|
|
2326
|
+
if (hasErrors) status = `${c.red}✖ ${errors.length} error${errors.length !== 1 ? 's' : ''}${c.reset}`;
|
|
2327
|
+
else if (hasWarnings) status = `${c.yellow}⚠ ${warnings.length} warning${warnings.length !== 1 ? 's' : ''}${c.reset}`;
|
|
2328
|
+
else status = `${c.green}✔ valid${c.reset}`;
|
|
2329
|
+
|
|
2330
|
+
console.log(`${branch} ${c.bold}${name}${c.reset} ${status}`);
|
|
2331
|
+
|
|
2332
|
+
// Description (truncated)
|
|
2333
|
+
if (info.description) {
|
|
2334
|
+
const desc = info.description.length > 70 ? info.description.slice(0, 67) + '...' : info.description;
|
|
2335
|
+
console.log(`${pipe} ${c.dim}${desc}${c.reset}`);
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
// MCP tool references
|
|
2339
|
+
if (info.mcpServers && info.mcpServers.length > 0) {
|
|
2340
|
+
const serverList = info.mcpServers.map(s => `${c.cyan}${s}${c.reset}`).join(', ');
|
|
2341
|
+
console.log(`${pipe} ${c.dim}uses MCP:${c.reset} ${serverList}`);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// Allowed tools summary
|
|
2345
|
+
if (info.allowedTools === null) {
|
|
2346
|
+
console.log(`${pipe} ${c.yellow}⚠ no allowed-tools — unrestricted access${c.reset}`);
|
|
2347
|
+
} else if (info.allowedTools && info.allowedTools.length > 0) {
|
|
2348
|
+
const toolCount = info.allowedTools.length;
|
|
2349
|
+
console.log(`${pipe} ${c.dim}${toolCount} allowed tool${toolCount !== 1 ? 's' : ''}${c.reset}`);
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Show errors/warnings inline
|
|
2353
|
+
if (hasErrors) {
|
|
2354
|
+
for (const err of errors.slice(0, 3)) {
|
|
2355
|
+
console.log(`${pipe} ${c.red} ✖ ${err}${c.reset}`);
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
if (hasWarnings && !hasErrors) {
|
|
2359
|
+
for (const warn of warnings.slice(0, 2)) {
|
|
2360
|
+
console.log(`${pipe} ${c.yellow} ⚠ ${warn}${c.reset}`);
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
console.log();
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// --scan: automatically scan all servers (git clone + local fallback)
|
|
1858
2369
|
if (autoScan) {
|
|
1859
2370
|
const isCloneable = (url) => /^https?:\/\/(github\.com|gitlab\.com|bitbucket\.org)\//i.test(url);
|
|
1860
|
-
|
|
1861
|
-
|
|
2371
|
+
// Include servers that are cloneable OR have a local dir OR a known package
|
|
2372
|
+
const scanTargets = allServersWithUrls.filter(s =>
|
|
2373
|
+
(s.sourceUrl && isCloneable(s.sourceUrl)) || s.localDir || s.pyPackage || s.npmPackage
|
|
2374
|
+
);
|
|
2375
|
+
// Deduplicate by sourceUrl or localDir
|
|
1862
2376
|
const seen = new Set();
|
|
1863
2377
|
const dedupedTargets = scanTargets.filter(s => {
|
|
1864
|
-
|
|
1865
|
-
seen.
|
|
2378
|
+
const key = (s.sourceUrl && isCloneable(s.sourceUrl)) ? s.sourceUrl : s.localDir;
|
|
2379
|
+
if (!key || seen.has(key)) return false;
|
|
2380
|
+
seen.add(key);
|
|
1866
2381
|
return true;
|
|
1867
2382
|
});
|
|
1868
|
-
const
|
|
2383
|
+
const skippedCount = allServersWithUrls.length - scanTargets.length;
|
|
1869
2384
|
if (dedupedTargets.length > 0) {
|
|
1870
2385
|
console.log(sectionHeader(`Auto-scanning ${dedupedTargets.length} server${dedupedTargets.length !== 1 ? 's' : ''}`));
|
|
1871
2386
|
console.log(` ${c.bold}${icons.scan} Starting scans...${c.reset}`);
|
|
1872
|
-
if (
|
|
1873
|
-
console.log(` ${c.dim}(${
|
|
2387
|
+
if (skippedCount > 0) {
|
|
2388
|
+
console.log(` ${c.dim}(${skippedCount} skipped — remote-only, no local source)${c.reset}`);
|
|
1874
2389
|
}
|
|
1875
2390
|
console.log();
|
|
1876
|
-
|
|
2391
|
+
|
|
1877
2392
|
const scanResults = [];
|
|
1878
2393
|
for (const target of dedupedTargets) {
|
|
1879
|
-
|
|
2394
|
+
let result = null;
|
|
2395
|
+
// Try git clone first if URL is cloneable
|
|
2396
|
+
if (target.sourceUrl && isCloneable(target.sourceUrl)) {
|
|
2397
|
+
result = await scanRepo(target.sourceUrl);
|
|
2398
|
+
}
|
|
2399
|
+
// Fallback 1: scan local installation
|
|
2400
|
+
if (!result && target.localDir) {
|
|
2401
|
+
result = await scanLocalDir(target.localDir, target.name);
|
|
2402
|
+
}
|
|
2403
|
+
// Fallback 2: download from PyPI/npm and scan
|
|
2404
|
+
if (!result && (target.pyPackage || target.npmPackage)) {
|
|
2405
|
+
result = await downloadAndScan(target);
|
|
2406
|
+
}
|
|
1880
2407
|
if (result) scanResults.push({ ...result, serverName: target.name });
|
|
1881
2408
|
}
|
|
1882
2409
|
|
|
@@ -1969,7 +2496,7 @@ async function discoverCommand(options = {}) {
|
|
|
1969
2496
|
}
|
|
1970
2497
|
|
|
1971
2498
|
if (!autoScan && !interactiveAudit && !jsonMode) {
|
|
1972
|
-
console.log(` ${c.dim}
|
|
2499
|
+
console.log(` ${c.dim}Run ${c.cyan}agentaudit discover --quick${c.dim} to auto-scan all servers${c.reset}`);
|
|
1973
2500
|
console.log();
|
|
1974
2501
|
}
|
|
1975
2502
|
}
|
|
@@ -2594,20 +3121,22 @@ function renderBenchmarkTab(data, width) {
|
|
|
2594
3121
|
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
3122
|
lines.push('');
|
|
2596
3123
|
|
|
2597
|
-
// Header
|
|
2598
|
-
const nameW =
|
|
2599
|
-
const
|
|
2600
|
-
|
|
3124
|
+
// Header — fixed column widths for alignment
|
|
3125
|
+
const nameW = 30;
|
|
3126
|
+
const auditsW = 6;
|
|
3127
|
+
const riskW = 5;
|
|
3128
|
+
const hdr = ` ${padRight('Model', nameW)} ${padLeft('Audits', auditsW)} ${padLeft('Risk', riskW)} ${'Detection'.padEnd(14)} Severity`;
|
|
3129
|
+
lines.push(` ${c.bold}${stripAnsi(hdr).trim()}${c.reset}`);
|
|
2601
3130
|
lines.push(` ${c.dim}${'─'.repeat(Math.min(width - 4, 86))}${c.reset}`);
|
|
2602
3131
|
|
|
2603
3132
|
for (const m of benchmark.models) {
|
|
2604
3133
|
const name = (m.audit_model || 'unknown').slice(0, nameW - 2);
|
|
2605
|
-
const audits = padLeft(fmtNum(m.total_audits),
|
|
3134
|
+
const audits = padLeft(fmtNum(m.total_audits), auditsW);
|
|
2606
3135
|
const riskVal = parseFloat(m.avg_risk_score) || 0;
|
|
2607
3136
|
const riskColor = riskVal <= 20 ? c.green : riskVal <= 40 ? c.yellow : c.red;
|
|
2608
|
-
const risk = `${riskColor}${padLeft(String(Math.round(riskVal)),
|
|
3137
|
+
const risk = `${riskColor}${padLeft(String(Math.round(riskVal)), riskW)}${c.reset}`;
|
|
2609
3138
|
const detection = renderGauge(m.detection_rate || 0, 100, 10);
|
|
2610
|
-
// Severity as compact text
|
|
3139
|
+
// Severity as compact text
|
|
2611
3140
|
const sev = m.severity_breakdown || {};
|
|
2612
3141
|
const sevParts = [];
|
|
2613
3142
|
if (sev.critical) sevParts.push(`${c.red}${sev.critical}C${c.reset}`);
|
|
@@ -2615,7 +3144,7 @@ function renderBenchmarkTab(data, width) {
|
|
|
2615
3144
|
if (sev.medium) sevParts.push(`${c.yellow}${sev.medium}M${c.reset}`);
|
|
2616
3145
|
if (sev.low) sevParts.push(`${c.blue}${sev.low}L${c.reset}`);
|
|
2617
3146
|
const sevStr = sevParts.length > 0 ? sevParts.join(' ') : `${c.dim}—${c.reset}`;
|
|
2618
|
-
lines.push(` ${padRight(name, nameW)}
|
|
3147
|
+
lines.push(` ${padRight(name, nameW)} ${audits} ${risk} ${detection} ${sevStr}`);
|
|
2619
3148
|
}
|
|
2620
3149
|
|
|
2621
3150
|
// Vulnerability landscape
|
|
@@ -3553,9 +4082,10 @@ async function main() {
|
|
|
3553
4082
|
console.log(` agentaudit <command> [options]`);
|
|
3554
4083
|
console.log();
|
|
3555
4084
|
console.log(` ${c.bold}SCAN & AUDIT${c.reset}`);
|
|
3556
|
-
console.log(` ${c.cyan}discover${c.reset} Find MCP servers in your AI
|
|
4085
|
+
console.log(` ${c.cyan}discover${c.reset} Find MCP servers & skills in your AI tools`);
|
|
3557
4086
|
console.log(` ${c.cyan}scan${c.reset} <url> [url...] Quick static scan (regex, ~2s)`);
|
|
3558
4087
|
console.log(` ${c.cyan}audit${c.reset} <url> [url...] Deep LLM-powered security audit (~30s)`);
|
|
4088
|
+
console.log(` ${c.cyan}validate${c.reset} [path] Validate SKILL.md format & security`);
|
|
3559
4089
|
console.log(` ${c.cyan}lookup${c.reset} <name> Look up package in registry`);
|
|
3560
4090
|
console.log();
|
|
3561
4091
|
console.log(` ${c.bold}COMMUNITY${c.reset}`);
|
|
@@ -3988,6 +4518,124 @@ async function main() {
|
|
|
3988
4518
|
return;
|
|
3989
4519
|
}
|
|
3990
4520
|
|
|
4521
|
+
if (command === 'validate') {
|
|
4522
|
+
const paths = targets.filter(t => !t.startsWith('--'));
|
|
4523
|
+
|
|
4524
|
+
// If no path given, find all skills and validate them
|
|
4525
|
+
if (paths.length === 0) {
|
|
4526
|
+
const skills = findSkills();
|
|
4527
|
+
if (skills.length === 0) {
|
|
4528
|
+
console.log(` ${c.yellow}No SKILL.md files found${c.reset}`);
|
|
4529
|
+
console.log(` ${c.dim}Searched: ~/.claude/skills/, ~/.cursor/skills/, .claude/skills/, .cursor/skills/${c.reset}`);
|
|
4530
|
+
console.log();
|
|
4531
|
+
console.log(` ${c.dim}Usage: ${c.cyan}agentaudit validate [path/to/SKILL.md]${c.reset}`);
|
|
4532
|
+
return;
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
console.log(` ${c.bold}Validating ${skills.length} skill${skills.length !== 1 ? 's' : ''}${c.reset}`);
|
|
4536
|
+
console.log();
|
|
4537
|
+
|
|
4538
|
+
let totalErrors = 0;
|
|
4539
|
+
let totalWarnings = 0;
|
|
4540
|
+
|
|
4541
|
+
for (const skill of skills) {
|
|
4542
|
+
const { errors, warnings, info } = skill.validation;
|
|
4543
|
+
totalErrors += errors.length;
|
|
4544
|
+
totalWarnings += warnings.length;
|
|
4545
|
+
|
|
4546
|
+
const name = info.name || skill.dirName;
|
|
4547
|
+
const hasErrors = errors.length > 0;
|
|
4548
|
+
const hasWarnings = warnings.length > 0;
|
|
4549
|
+
|
|
4550
|
+
if (hasErrors) {
|
|
4551
|
+
console.log(` ${c.red}✖${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
|
|
4552
|
+
for (const err of errors) console.log(` ${c.red}✖ ${err}${c.reset}`);
|
|
4553
|
+
for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
|
|
4554
|
+
} else if (hasWarnings) {
|
|
4555
|
+
console.log(` ${c.yellow}⚠${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
|
|
4556
|
+
for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
|
|
4557
|
+
} else {
|
|
4558
|
+
console.log(` ${c.green}✔${c.reset} ${c.bold}${name}${c.reset} ${c.dim}${skill.path}${c.reset}`);
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
// Show MCP references
|
|
4562
|
+
if (info.mcpServers && info.mcpServers.length > 0) {
|
|
4563
|
+
console.log(` ${c.dim}MCP servers: ${info.mcpServers.join(', ')}${c.reset}`);
|
|
4564
|
+
}
|
|
4565
|
+
if (info.allowedTools === null) {
|
|
4566
|
+
console.log(` ${c.yellow}⚠ no allowed-tools — unrestricted tool access${c.reset}`);
|
|
4567
|
+
}
|
|
4568
|
+
console.log();
|
|
4569
|
+
}
|
|
4570
|
+
|
|
4571
|
+
// Summary
|
|
4572
|
+
console.log(sectionHeader('Validation Summary'));
|
|
4573
|
+
console.log();
|
|
4574
|
+
if (totalErrors === 0 && totalWarnings === 0) {
|
|
4575
|
+
console.log(` ${c.green}✔ All ${skills.length} skills valid${c.reset}`);
|
|
4576
|
+
} else {
|
|
4577
|
+
if (totalErrors > 0) console.log(` ${c.red}✖ ${totalErrors} error${totalErrors !== 1 ? 's' : ''}${c.reset}`);
|
|
4578
|
+
if (totalWarnings > 0) console.log(` ${c.yellow}⚠ ${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}${c.reset}`);
|
|
4579
|
+
}
|
|
4580
|
+
console.log();
|
|
4581
|
+
|
|
4582
|
+
if (jsonMode) {
|
|
4583
|
+
console.log(JSON.stringify(skills.map(s => ({
|
|
4584
|
+
name: s.validation.info.name || s.dirName,
|
|
4585
|
+
path: s.path,
|
|
4586
|
+
source: s.source,
|
|
4587
|
+
errors: s.validation.errors,
|
|
4588
|
+
warnings: s.validation.warnings,
|
|
4589
|
+
mcpServers: s.validation.info.mcpServers,
|
|
4590
|
+
allowedTools: s.validation.info.allowedTools,
|
|
4591
|
+
})), null, 2));
|
|
4592
|
+
}
|
|
4593
|
+
|
|
4594
|
+
process.exitCode = totalErrors > 0 ? 1 : 0;
|
|
4595
|
+
return;
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4598
|
+
// Validate specific file(s)
|
|
4599
|
+
for (const p of paths) {
|
|
4600
|
+
const resolved = path.resolve(p);
|
|
4601
|
+
if (!fs.existsSync(resolved)) {
|
|
4602
|
+
console.log(` ${c.red}✖ File not found: ${p}${c.reset}`);
|
|
4603
|
+
process.exitCode = 1;
|
|
4604
|
+
continue;
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
4608
|
+
const parsed = parseSkillFrontmatter(content);
|
|
4609
|
+
const { errors, warnings, info } = validateSkill(parsed);
|
|
4610
|
+
const name = info.name || path.basename(path.dirname(resolved));
|
|
4611
|
+
|
|
4612
|
+
console.log(` ${c.bold}${name}${c.reset} ${c.dim}${resolved}${c.reset}`);
|
|
4613
|
+
console.log();
|
|
4614
|
+
|
|
4615
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
4616
|
+
console.log(` ${c.green}✔ Valid skill format${c.reset}`);
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
for (const err of errors) console.log(` ${c.red}✖ ${err}${c.reset}`);
|
|
4620
|
+
for (const warn of warnings) console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
|
|
4621
|
+
|
|
4622
|
+
if (info.name) console.log(` ${c.dim}name:${c.reset} ${info.name}`);
|
|
4623
|
+
if (info.description) console.log(` ${c.dim}description:${c.reset} ${info.description.slice(0, 80)}${info.description.length > 80 ? '...' : ''}`);
|
|
4624
|
+
if (info.allowedTools) console.log(` ${c.dim}allowed-tools:${c.reset} ${info.allowedTools.join(', ')}`);
|
|
4625
|
+
else if (info.allowedTools === null) console.log(` ${c.yellow}allowed-tools: none (unrestricted)${c.reset}`);
|
|
4626
|
+
if (info.mcpServers?.length > 0) console.log(` ${c.dim}MCP servers:${c.reset} ${info.mcpServers.join(', ')}`);
|
|
4627
|
+
if (info.bodyLines) console.log(` ${c.dim}body:${c.reset} ${info.bodyLines} lines`);
|
|
4628
|
+
console.log();
|
|
4629
|
+
|
|
4630
|
+
if (jsonMode) {
|
|
4631
|
+
console.log(JSON.stringify({ name: info.name, path: resolved, errors, warnings, info }, null, 2));
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
process.exitCode = errors.length > 0 ? 1 : 0;
|
|
4635
|
+
}
|
|
4636
|
+
return;
|
|
4637
|
+
}
|
|
4638
|
+
|
|
3991
4639
|
if (command === 'discover') {
|
|
3992
4640
|
const scanFlag = targets.includes('--quick') || targets.includes('--scan') || targets.includes('-s');
|
|
3993
4641
|
const auditFlag = targets.includes('--deep') || targets.includes('--audit') || targets.includes('-a');
|
package/index.mjs
CHANGED
|
@@ -343,7 +343,7 @@ async function checkRegistry(slug) {
|
|
|
343
343
|
// ── MCP Server ───────────────────────────────────────────
|
|
344
344
|
|
|
345
345
|
const server = new Server(
|
|
346
|
-
{ name: 'agentaudit', version: '3.12.
|
|
346
|
+
{ name: 'agentaudit', version: '3.12.1' },
|
|
347
347
|
{ capabilities: { tools: {} } }
|
|
348
348
|
);
|
|
349
349
|
|