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.
Files changed (3) hide show
  1. package/cli.mjs +684 -36
  2. package/index.mjs +1 -1
  3. 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
- const topDash = BOX.h.repeat(Math.max(1, inner + 2 - titleLen));
650
- lines.push(` ${BOX.tl}${c.dim}─${c.reset}${c.bold}${titleStr}${c.reset}${c.dim}${topDash}${c.reset}${BOX.tr}`);
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(inner + 2)}${c.reset}${BOX.br}`);
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 (look for tool definitions)
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: name: 'tool_name' or "tool_name" in tool definitions
935
- /(?:name|tool_name)['":\s]+['"]([a-z_][a-z0-9_]*)['"]/gi,
936
- // Python: @mcp.tool() def func_name or Tool(name="...")
937
- /(?:@(?:mcp|server)\.tool\(\)[\s\S]*?def\s+([a-z_][a-z0-9_]*))|(?:Tool\s*\(\s*name\s*=\s*['"]([a-z_][a-z0-9_]*)['"])/gi,
938
- // Direct: tool names in ListTools handlers
939
- /['"]name['"]\s*:\s*['"]([a-z_][a-z0-9_]*)['"]/gi,
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 files) {
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 && !['type', 'name', 'string', 'object', 'number', 'boolean', 'array', 'required', 'description', 'default', 'null', 'true', 'false', 'none'].includes(name)) {
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
- // --scan: automatically scan all servers with resolved source URLs (git-cloneable only)
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
- const scanTargets = allServersWithUrls.filter(s => s.sourceUrl && isCloneable(s.sourceUrl));
1861
- // Deduplicate by sourceUrl
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
- if (seen.has(s.sourceUrl)) return false;
1865
- seen.add(s.sourceUrl);
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 skipped = allServersWithUrls.filter(s => s.sourceUrl && !isCloneable(s.sourceUrl));
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 (skipped.length > 0) {
1873
- console.log(` ${c.dim}(${skipped.length} skipped — no cloneable source URL)${c.reset}`);
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
- const result = await scanRepo(target.sourceUrl);
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}Looking for general package scanning? Try ${c.cyan}pip audit${c.dim} or ${c.cyan}npm audit${c.dim}.${c.reset}`);
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 = 28;
2599
- const hdr = ` ${padRight(`${c.bold}Model${c.reset}`, nameW + 9)} ${padRight('Audits', 7)} ${padRight('Risk', 5)} ${padRight('Detection', 16)} Severity`;
2600
- lines.push(hdr);
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), 5);
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)), 3)}${c.reset}`;
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 instead of dots
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)} ${audits} ${risk} ${detection} ${sevStr}`);
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 editors`);
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.0' },
346
+ { name: 'agentaudit', version: '3.12.1' },
347
347
  { capabilities: { tools: {} } }
348
348
  );
349
349
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentaudit",
3
- "version": "3.12.0",
3
+ "version": "3.12.1",
4
4
  "description": "Security scanner for AI packages — MCP server + CLI",
5
5
  "type": "module",
6
6
  "bin": {