toolmoat 0.0.0-stage → 0.1.0

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +302 -2
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +80 -0
  5. package/dist/clients/configs.d.ts +124 -0
  6. package/dist/clients/configs.js +403 -0
  7. package/dist/commands/approve.d.ts +15 -0
  8. package/dist/commands/approve.js +128 -0
  9. package/dist/commands/diff.d.ts +6 -0
  10. package/dist/commands/diff.js +114 -0
  11. package/dist/commands/pins.d.ts +18 -0
  12. package/dist/commands/pins.js +88 -0
  13. package/dist/commands/proxy.d.ts +24 -0
  14. package/dist/commands/proxy.js +144 -0
  15. package/dist/commands/rules.d.ts +15 -0
  16. package/dist/commands/rules.js +173 -0
  17. package/dist/commands/scan.d.ts +57 -0
  18. package/dist/commands/scan.js +323 -0
  19. package/dist/commands/unwrap.d.ts +5 -0
  20. package/dist/commands/unwrap.js +69 -0
  21. package/dist/commands/wrap.d.ts +131 -0
  22. package/dist/commands/wrap.js +518 -0
  23. package/dist/index.d.ts +20 -0
  24. package/dist/index.js +13 -0
  25. package/dist/patterns/indicators.d.ts +41 -0
  26. package/dist/patterns/indicators.js +85 -0
  27. package/dist/patterns/secrets.d.ts +20 -0
  28. package/dist/patterns/secrets.js +70 -0
  29. package/dist/proxy/audit.d.ts +53 -0
  30. package/dist/proxy/audit.js +79 -0
  31. package/dist/proxy/canonical.d.ts +13 -0
  32. package/dist/proxy/canonical.js +34 -0
  33. package/dist/proxy/diff.d.ts +22 -0
  34. package/dist/proxy/diff.js +124 -0
  35. package/dist/proxy/fingerprint.d.ts +34 -0
  36. package/dist/proxy/fingerprint.js +140 -0
  37. package/dist/proxy/firewall.d.ts +132 -0
  38. package/dist/proxy/firewall.js +665 -0
  39. package/dist/proxy/framing.d.ts +45 -0
  40. package/dist/proxy/framing.js +86 -0
  41. package/dist/proxy/lockfile.d.ts +58 -0
  42. package/dist/proxy/lockfile.js +258 -0
  43. package/dist/proxy/names.d.ts +27 -0
  44. package/dist/proxy/names.js +175 -0
  45. package/dist/proxy/policy.d.ts +72 -0
  46. package/dist/proxy/policy.js +86 -0
  47. package/dist/proxy/proxy.d.ts +18 -0
  48. package/dist/proxy/proxy.js +237 -0
  49. package/dist/proxy/spawn.d.ts +51 -0
  50. package/dist/proxy/spawn.js +110 -0
  51. package/dist/report/index.d.ts +35 -0
  52. package/dist/report/index.js +25 -0
  53. package/dist/report/json.d.ts +22 -0
  54. package/dist/report/json.js +40 -0
  55. package/dist/report/markdown.d.ts +12 -0
  56. package/dist/report/markdown.js +87 -0
  57. package/dist/report/sarif.d.ts +89 -0
  58. package/dist/report/sarif.js +142 -0
  59. package/dist/report/text.d.ts +16 -0
  60. package/dist/report/text.js +206 -0
  61. package/dist/rules/code.d.ts +15 -0
  62. package/dist/rules/code.js +729 -0
  63. package/dist/rules/config.d.ts +3 -0
  64. package/dist/rules/config.js +2164 -0
  65. package/dist/rules/index.d.ts +3 -0
  66. package/dist/rules/index.js +8 -0
  67. package/dist/rules/secrets.d.ts +2 -0
  68. package/dist/rules/secrets.js +108 -0
  69. package/dist/rules/text.d.ts +2 -0
  70. package/dist/rules/text.js +1698 -0
  71. package/dist/scan/discover.d.ts +37 -0
  72. package/dist/scan/discover.js +525 -0
  73. package/dist/scan/engine.d.ts +24 -0
  74. package/dist/scan/engine.js +111 -0
  75. package/dist/scan/ignore.d.ts +28 -0
  76. package/dist/scan/ignore.js +46 -0
  77. package/dist/types.d.ts +98 -0
  78. package/dist/types.js +25 -0
  79. package/dist/util/colors.d.ts +12 -0
  80. package/dist/util/colors.js +24 -0
  81. package/dist/util/frontmatter.d.ts +20 -0
  82. package/dist/util/frontmatter.js +136 -0
  83. package/dist/util/glob.d.ts +7 -0
  84. package/dist/util/glob.js +44 -0
  85. package/dist/util/hash.d.ts +3 -0
  86. package/dist/util/hash.js +24 -0
  87. package/dist/util/jsonc.d.ts +14 -0
  88. package/dist/util/jsonc.js +113 -0
  89. package/dist/util/paths.d.ts +9 -0
  90. package/dist/util/paths.js +37 -0
  91. package/dist/util/text.d.ts +29 -0
  92. package/dist/util/text.js +96 -0
  93. package/dist/util/toml-lite.d.ts +40 -0
  94. package/dist/util/toml-lite.js +507 -0
  95. package/dist/version.d.ts +1 -0
  96. package/dist/version.js +1 -0
  97. package/package.json +57 -4
@@ -0,0 +1,18 @@
1
+ import type { Lockfile } from '../proxy/lockfile.js';
2
+ export declare const help = "Usage: toolmoat pins [--lock <file>] [--json]\n\nLists the MCP servers pinned by toolmoat proxy, with their tool counts and\nany tool changes waiting for review.\n\nOptions:\n --lock <file> Lockfile (default: <toolmoat home>/lock.json)\n --json Machine-readable output";
3
+ export interface ServerSummary {
4
+ name: string;
5
+ command: string;
6
+ tools: number;
7
+ pending: number;
8
+ pendingTools: Array<{
9
+ tool: string;
10
+ reason: string;
11
+ detectedAt: string;
12
+ }>;
13
+ firstSeen: string;
14
+ updated: string;
15
+ }
16
+ /** One summary per server, sorted by name. */
17
+ export declare function summarizeServers(lock: Lockfile): ServerSummary[];
18
+ export declare function run(argv: string[]): Promise<number>;
@@ -0,0 +1,88 @@
1
+ import path from 'node:path';
2
+ import { parseArgs } from 'node:util';
3
+ import { defaultLockPath, readLockfile } from '../proxy/lockfile.js';
4
+ import { safeText } from '../proxy/names.js';
5
+ import { palette } from '../util/colors.js';
6
+ export const help = `Usage: toolmoat pins [--lock <file>] [--json]
7
+
8
+ Lists the MCP servers pinned by toolmoat proxy, with their tool counts and
9
+ any tool changes waiting for review.
10
+
11
+ Options:
12
+ --lock <file> Lockfile (default: <toolmoat home>/lock.json)
13
+ --json Machine-readable output`;
14
+ /** One summary per server, sorted by name. */
15
+ export function summarizeServers(lock) {
16
+ return Object.keys(lock.servers)
17
+ .sort()
18
+ .map((name) => {
19
+ const entry = lock.servers[name];
20
+ const pendingTools = Object.keys(entry.pending)
21
+ .sort()
22
+ .map((tool) => ({ tool, reason: entry.pending[tool].reason, detectedAt: entry.pending[tool].detectedAt }));
23
+ return {
24
+ name,
25
+ command: entry.command,
26
+ tools: Object.keys(entry.tools).length,
27
+ pending: pendingTools.length,
28
+ pendingTools,
29
+ firstSeen: entry.firstSeen,
30
+ updated: entry.updated,
31
+ };
32
+ });
33
+ }
34
+ export async function run(argv) {
35
+ let values;
36
+ try {
37
+ ({ values } = parseArgs({ args: argv, options: { lock: { type: 'string' }, json: { type: 'boolean' } }, allowPositionals: false }));
38
+ }
39
+ catch (error) {
40
+ process.stderr.write(`toolmoat pins: ${error instanceof Error ? error.message : String(error)}\n`);
41
+ return 2;
42
+ }
43
+ const lockPath = path.resolve(values.lock ?? defaultLockPath());
44
+ let lock;
45
+ try {
46
+ lock = readLockfile(lockPath);
47
+ }
48
+ catch (error) {
49
+ process.stderr.write(`toolmoat pins: ${error instanceof Error ? error.message : String(error)}\n`);
50
+ return 1;
51
+ }
52
+ const servers = summarizeServers(lock);
53
+ if (values.json) {
54
+ process.stdout.write(`${JSON.stringify({ lockfile: lockPath, servers }, null, 2)}\n`);
55
+ return 0;
56
+ }
57
+ process.stdout.write(`${renderTable(servers, lockPath)}\n`);
58
+ return 0;
59
+ }
60
+ function renderTable(servers, lockPath) {
61
+ const c = palette(process.stdout);
62
+ if (servers.length === 0) {
63
+ return `No pinned MCP servers in ${lockPath}.\nServers are pinned the first time they run behind toolmoat proxy.`;
64
+ }
65
+ const rows = servers.map((s) => [safeText(s.name, 60), String(s.tools), String(s.pending), shortTime(s.updated)]);
66
+ const header = ['SERVER', 'TOOLS', 'PENDING', 'UPDATED (UTC)'];
67
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
68
+ const line = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd();
69
+ const out = [`Lockfile: ${lockPath}`, '', c.bold(line(header))];
70
+ rows.forEach((row, index) => {
71
+ const text = line(row);
72
+ out.push(servers[index].pending > 0 ? c.yellow(text) : text);
73
+ });
74
+ const waiting = servers.filter((s) => s.pending > 0);
75
+ if (waiting.length > 0) {
76
+ out.push('');
77
+ for (const s of waiting) {
78
+ const tools = s.pendingTools.map((p) => `${safeText(p.tool, 60)} (${p.reason})`).join(', ');
79
+ out.push(`${c.yellow('!')} ${safeText(s.name, 60)}: ${tools}`);
80
+ }
81
+ out.push('', `Review with ${c.cyan('toolmoat diff <server>')}, then ${c.cyan('toolmoat approve <server>')}.`);
82
+ }
83
+ return out.join('\n');
84
+ }
85
+ function shortTime(iso) {
86
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
87
+ return match ? `${match[1]} ${match[2]}` : iso || '-';
88
+ }
@@ -0,0 +1,24 @@
1
+ import type { Mode, OnNew } from '../proxy/policy.js';
2
+ export declare const help: string;
3
+ export declare class UsageError extends Error {
4
+ name: string;
5
+ }
6
+ export interface ProxyArgs {
7
+ help: boolean;
8
+ name?: string;
9
+ lock?: string;
10
+ mode: Mode;
11
+ onNew: OnNew;
12
+ auditLog?: string;
13
+ noAudit: boolean;
14
+ logArgs: boolean;
15
+ allowSecrets: string[];
16
+ command?: string;
17
+ args: string[];
18
+ }
19
+ /**
20
+ * Parses proxy options up to `--` (or up to the first non-option word);
21
+ * everything after belongs to the server command and is never interpreted.
22
+ */
23
+ export declare function parseProxyArgs(argv: readonly string[]): ProxyArgs;
24
+ export declare function run(argv: string[]): Promise<number>;
@@ -0,0 +1,144 @@
1
+ import path from 'node:path';
2
+ import { SECRET_PATTERNS } from '../patterns/secrets.js';
3
+ import { defaultLockPath } from '../proxy/lockfile.js';
4
+ import { deriveServerName, fileSafeName, formatCommand } from '../proxy/names.js';
5
+ import { runProxy } from '../proxy/proxy.js';
6
+ import { toolmoatHome } from '../util/paths.js';
7
+ export const help = `Usage: toolmoat proxy [options] -- <command> [args...]
8
+
9
+ Runs an MCP stdio server behind toolmoat's runtime firewall. Use it in your
10
+ MCP client config in place of the server command (toolmoat wrap does that).
11
+
12
+ - Pins every tool definition (sha256) on first run and hides tools whose
13
+ definition later changes (rug pulls) or that appear later, until you
14
+ approve them.
15
+ - Hides tools whose descriptions contain high or critical findings (tool
16
+ poisoning, hidden instructions) from the model.
17
+ - Blocks tool calls whose arguments contain credentials (egress guard).
18
+ - Warns when a tool's output looks like prompt injection.
19
+ - Keeps a JSONL audit log; argument values are not logged by default.
20
+
21
+ Options:
22
+ --name <id> Server id in the lockfile (default: derived from the command)
23
+ --lock <file> Lockfile (default: <toolmoat home>/lock.json, ~/.toolmoat or TOOLMOAT_HOME)
24
+ --mode enforce|warn enforce hides and blocks; warn only reports (default: enforce)
25
+ --on-new block|allow Tools that appear on an already pinned server (default: block)
26
+ --audit-log <file> Audit log (default: <toolmoat home>/audit/<name>.jsonl)
27
+ --no-audit Do not write an audit log
28
+ --log-args Also log tool-call arguments, with secrets redacted
29
+ --allow-secret <id> Let one credential type through the egress guard (repeatable).
30
+ Ids: ${SECRET_PATTERNS.map((p) => p.id).join(', ')}
31
+ -h, --help Show this help (only before --)
32
+
33
+ Examples:
34
+ toolmoat proxy -- npx -y @modelcontextprotocol/server-filesystem ~/projects
35
+ toolmoat proxy --name demo -- node examples/rugpull-server.mjs
36
+
37
+ Held tools: toolmoat pins, toolmoat diff <name>, toolmoat approve <name>`;
38
+ export class UsageError extends Error {
39
+ name = 'UsageError';
40
+ }
41
+ const VALUE_FLAGS = new Set(['--name', '--lock', '--mode', '--on-new', '--audit-log', '--allow-secret']);
42
+ const BOOLEAN_FLAGS = new Set(['--no-audit', '--log-args', '--help', '-h']);
43
+ /**
44
+ * Parses proxy options up to `--` (or up to the first non-option word);
45
+ * everything after belongs to the server command and is never interpreted.
46
+ */
47
+ export function parseProxyArgs(argv) {
48
+ const out = { help: false, mode: 'enforce', onNew: 'block', noAudit: false, logArgs: false, allowSecrets: [], args: [] };
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const token = argv[i];
51
+ if (token === '--' || !token.startsWith('-') || token === '-') {
52
+ const start = token === '--' ? i + 1 : i;
53
+ out.command = argv[start];
54
+ out.args = argv.slice(start + 1);
55
+ break;
56
+ }
57
+ const eq = token.startsWith('--') ? token.indexOf('=') : -1;
58
+ const flag = eq === -1 ? token : token.slice(0, eq);
59
+ if (BOOLEAN_FLAGS.has(flag)) {
60
+ if (eq !== -1)
61
+ throw new UsageError(`${flag} does not take a value`);
62
+ if (flag === '--help' || flag === '-h')
63
+ out.help = true;
64
+ else if (flag === '--no-audit')
65
+ out.noAudit = true;
66
+ else
67
+ out.logArgs = true;
68
+ continue;
69
+ }
70
+ if (!VALUE_FLAGS.has(flag))
71
+ throw new UsageError(`unknown option ${flag}`);
72
+ const value = eq === -1 ? argv[++i] : token.slice(eq + 1);
73
+ if (value === undefined || value === '')
74
+ throw new UsageError(`${flag} needs a value`);
75
+ applyValue(out, flag, value);
76
+ }
77
+ return out;
78
+ }
79
+ function applyValue(out, flag, value) {
80
+ switch (flag) {
81
+ case '--name':
82
+ if (/[\u0000-\u001f\u007f]/.test(value) || value.length > 200)
83
+ throw new UsageError('--name must be at most 200 printable characters');
84
+ out.name = value;
85
+ break;
86
+ case '--lock':
87
+ out.lock = value;
88
+ break;
89
+ case '--mode':
90
+ if (value !== 'enforce' && value !== 'warn')
91
+ throw new UsageError(`--mode must be enforce or warn, not ${value}`);
92
+ out.mode = value;
93
+ break;
94
+ case '--on-new':
95
+ if (value !== 'block' && value !== 'allow')
96
+ throw new UsageError(`--on-new must be block or allow, not ${value}`);
97
+ out.onNew = value;
98
+ break;
99
+ case '--audit-log':
100
+ out.auditLog = value;
101
+ break;
102
+ case '--allow-secret':
103
+ if (!SECRET_PATTERNS.some((p) => p.id === value)) {
104
+ throw new UsageError(`unknown secret pattern '${value}' for --allow-secret; known ids: ${SECRET_PATTERNS.map((p) => p.id).join(', ')}`);
105
+ }
106
+ out.allowSecrets.push(value);
107
+ break;
108
+ }
109
+ }
110
+ export async function run(argv) {
111
+ let parsed;
112
+ try {
113
+ parsed = parseProxyArgs(argv);
114
+ }
115
+ catch (error) {
116
+ process.stderr.write(`toolmoat proxy: ${error instanceof Error ? error.message : String(error)}\nRun toolmoat proxy --help for usage.\n`);
117
+ return 2;
118
+ }
119
+ if (parsed.help) {
120
+ process.stdout.write(`${help}\n`);
121
+ return 0;
122
+ }
123
+ if (parsed.command === undefined || parsed.command === '') {
124
+ process.stderr.write('toolmoat proxy: missing the server command, e.g. toolmoat proxy -- npx -y some-mcp-server\n');
125
+ return 2;
126
+ }
127
+ const name = parsed.name ?? deriveServerName(parsed.command, parsed.args);
128
+ const auditPath = parsed.noAudit ? undefined : path.resolve(parsed.auditLog ?? path.join(toolmoatHome(), 'audit', `${fileSafeName(name)}.jsonl`));
129
+ const code = await runProxy({
130
+ server: name,
131
+ commandLine: formatCommand(parsed.command, parsed.args),
132
+ lockPath: path.resolve(parsed.lock ?? defaultLockPath()),
133
+ mode: parsed.mode,
134
+ onNew: parsed.onNew,
135
+ allowSecrets: parsed.allowSecrets,
136
+ logArgs: parsed.logArgs,
137
+ command: parsed.command,
138
+ args: parsed.args,
139
+ ...(auditPath ? { auditPath } : {}),
140
+ });
141
+ // A grandchild that inherited a handle must not keep the proxy alive after the server is gone.
142
+ setTimeout(() => process.exit(code), 500).unref();
143
+ return code;
144
+ }
@@ -0,0 +1,15 @@
1
+ import type { Rule } from '../types.js';
2
+ export declare const help = "Usage: toolmoat rules [options]\n\nList every detection rule with its id, severity, category, the file kinds it\napplies to and what it detects.\n\nOptions:\n -f, --format <format> text (default), json or markdown (a README table)\n --severity <sev> Only list rules at or above this severity\n (critical, high, medium, low or info)\n --color, --no-color Force or disable colors (NO_COLOR is honoured)";
3
+ export type RulesFormat = 'text' | 'json' | 'markdown';
4
+ export interface RulesIo {
5
+ stdout(text: string): void;
6
+ stderr(text: string): void;
7
+ /** Whether stdout takes colors when neither --color nor --no-color is given. */
8
+ color: boolean;
9
+ rules: readonly Rule[];
10
+ }
11
+ export declare function run(argv: string[], io?: Partial<RulesIo>): Promise<number>;
12
+ /** Renders a rule list sorted by id. The result ends with a newline. */
13
+ export declare function renderRules(rules: readonly Rule[], format: RulesFormat, options?: {
14
+ color?: boolean;
15
+ }): string;
@@ -0,0 +1,173 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { escapeCell } from '../report/markdown.js';
3
+ import { allRules } from '../rules/index.js';
4
+ import { atLeast, isSeverity, SEVERITIES } from '../types.js';
5
+ import { palette } from '../util/colors.js';
6
+ import { VERSION } from '../version.js';
7
+ export const help = `Usage: toolmoat rules [options]
8
+
9
+ List every detection rule with its id, severity, category, the file kinds it
10
+ applies to and what it detects.
11
+
12
+ Options:
13
+ -f, --format <format> text (default), json or markdown (a README table)
14
+ --severity <sev> Only list rules at or above this severity
15
+ (critical, high, medium, low or info)
16
+ --color, --no-color Force or disable colors (NO_COLOR is honoured)`;
17
+ const RULES_FORMATS = ['text', 'json', 'markdown'];
18
+ const WRAP_WIDTH = 100;
19
+ export async function run(argv, io = {}) {
20
+ const env = {
21
+ stdout: (text) => process.stdout.write(text),
22
+ stderr: (text) => process.stderr.write(text),
23
+ color: palette(process.stdout).bold('x') !== 'x',
24
+ rules: allRules,
25
+ ...io,
26
+ };
27
+ let values;
28
+ try {
29
+ values = parseRulesArgs(argv);
30
+ }
31
+ catch (error) {
32
+ return usage(env, parseError(error));
33
+ }
34
+ if (values.help) {
35
+ env.stdout(`${help}\n`);
36
+ return 0;
37
+ }
38
+ const format = values.format ?? 'text';
39
+ if (!isRulesFormat(format))
40
+ return usage(env, `unknown format '${format}' (expected ${RULES_FORMATS.join(', ')})`);
41
+ const severity = values.severity;
42
+ if (severity !== undefined && !isSeverity(severity))
43
+ return usage(env, `invalid --severity '${severity}' (expected ${SEVERITIES.join(', ')})`);
44
+ const color = format === 'text' && (values['no-color'] ? false : values.color ? true : env.color);
45
+ const selected = env.rules.filter((rule) => severity === undefined || atLeast(rule.severity, severity));
46
+ env.stdout(renderRules(selected, format, { color }));
47
+ return 0;
48
+ }
49
+ function parseRulesArgs(argv) {
50
+ return parseArgs({
51
+ args: argv,
52
+ strict: true,
53
+ allowPositionals: false,
54
+ options: {
55
+ format: { type: 'string', short: 'f' },
56
+ severity: { type: 'string' },
57
+ color: { type: 'boolean' },
58
+ 'no-color': { type: 'boolean' },
59
+ help: { type: 'boolean', short: 'h' },
60
+ },
61
+ }).values;
62
+ }
63
+ /** Renders a rule list sorted by id. The result ends with a newline. */
64
+ export function renderRules(rules, format, options = {}) {
65
+ const sorted = [...rules].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
66
+ switch (format) {
67
+ case 'json':
68
+ return rulesJson(sorted);
69
+ case 'markdown':
70
+ return rulesMarkdown(sorted);
71
+ case 'text':
72
+ return rulesText(sorted, palette(process.stdout, options.color ?? false));
73
+ }
74
+ }
75
+ function rulesJson(rules) {
76
+ const out = {
77
+ tool: 'toolmoat',
78
+ version: VERSION,
79
+ rules: rules.map((rule) => {
80
+ const entry = {
81
+ id: rule.id,
82
+ title: rule.title,
83
+ severity: rule.severity,
84
+ category: rule.category,
85
+ kinds: [...rule.kinds],
86
+ description: rule.description,
87
+ };
88
+ if (rule.remediation)
89
+ entry.remediation = rule.remediation;
90
+ return entry;
91
+ }),
92
+ };
93
+ return `${JSON.stringify(out, null, 2)}\n`;
94
+ }
95
+ function rulesMarkdown(rules) {
96
+ const lines = ['| ID | Title | Severity | Category | Applies to | Description |', '| --- | --- | --- | --- | --- | --- |'];
97
+ for (const rule of rules) {
98
+ const cells = [`\`${rule.id}\``, escapeCell(rule.title), rule.severity, rule.category, escapeCell(rule.kinds.join(', ')), escapeCell(rule.description, 1000)];
99
+ lines.push(`| ${cells.join(' | ')} |`);
100
+ }
101
+ return `${lines.join('\n')}\n`;
102
+ }
103
+ function rulesText(rules, c) {
104
+ if (rules.length === 0)
105
+ return 'No rules match.\n';
106
+ const idWidth = Math.max(...rules.map((rule) => rule.id.length));
107
+ const lines = [];
108
+ for (const rule of rules) {
109
+ lines.push(`${c.cyan(rule.id.padEnd(idWidth))} ${severityBadge(rule.severity, c)} ${rule.category.padEnd(21)} ${c.bold(rule.title)}`);
110
+ lines.push(...wrap(`Applies to: ${rule.kinds.join(', ')}`, ' ').map(c.gray));
111
+ lines.push(...wrap(rule.description, ' '));
112
+ lines.push('');
113
+ }
114
+ const counts = SEVERITIES.map((s) => [s, rules.filter((rule) => rule.severity === s).length])
115
+ .filter(([, n]) => n > 0)
116
+ .map(([s, n]) => `${n} ${s}`);
117
+ lines.push(`${rules.length} rule${rules.length === 1 ? '' : 's'}: ${counts.join(', ')}`);
118
+ return `${lines.join('\n')}\n`;
119
+ }
120
+ function severityBadge(severity, c) {
121
+ const text = severity.toUpperCase().padEnd('CRITICAL'.length);
122
+ switch (severity) {
123
+ case 'critical':
124
+ return c.bold(c.magenta(text));
125
+ case 'high':
126
+ return c.bold(c.red(text));
127
+ case 'medium':
128
+ return c.yellow(text);
129
+ case 'low':
130
+ return c.blue(text);
131
+ case 'info':
132
+ return c.gray(text);
133
+ }
134
+ }
135
+ function wrap(text, indent) {
136
+ const out = [];
137
+ let line = '';
138
+ for (const word of text.split(/\s+/)) {
139
+ if (!word)
140
+ continue;
141
+ if (line && indent.length + line.length + 1 + word.length > WRAP_WIDTH) {
142
+ out.push(indent + line);
143
+ line = word;
144
+ }
145
+ else {
146
+ line = line ? `${line} ${word}` : word;
147
+ }
148
+ }
149
+ if (line)
150
+ out.push(indent + line);
151
+ return out;
152
+ }
153
+ function isRulesFormat(value) {
154
+ return RULES_FORMATS.includes(value);
155
+ }
156
+ function usage(io, message) {
157
+ io.stderr(`toolmoat rules: ${message}\nRun 'toolmoat rules --help' for usage.\n`);
158
+ return 2;
159
+ }
160
+ function parseError(error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ if (error?.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
163
+ const option = /'([^']{1,200})'/.exec(message)?.[1];
164
+ if (option)
165
+ return `unknown option '${option}'`;
166
+ }
167
+ if (error?.code === 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL') {
168
+ const arg = /'([^']{1,200})'/.exec(message)?.[1];
169
+ if (arg)
170
+ return `unexpected argument '${arg}'`;
171
+ }
172
+ return message;
173
+ }
@@ -0,0 +1,57 @@
1
+ import type { ReportFormat } from '../report/index.js';
2
+ import type { DiscoverOptions, DiscoverResult } from '../scan/discover.js';
3
+ import type { IgnoreConfig } from '../scan/ignore.js';
4
+ import type { Rule, ScanResult, Severity } from '../types.js';
5
+ export declare const help = "Usage: toolmoat scan [paths...] [options]\n\nScan agent skills, plugins, hooks and MCP server configs for malicious or\nrisky content. Runs fully offline. With no paths, scans the default agent\nlocations (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Codex...).\n\nOptions:\n -f, --format <format> text (default), json, sarif or markdown\n -o, --output <file> Write the report to a file and print a one-line summary\n --fail-on <severity> Exit 1 when a finding is at or above this severity:\n critical, high (default), medium, low, info or none\n --min-severity <sev> Hide findings below this severity (default: low,\n or info with --verbose)\n --ignore-file <file> Suppressions to apply (default: ./.toolmoatignore\n if present; never read from scanned targets)\n --no-ignore Apply no ignore file at all, e.g. when the current\n directory is itself an untrusted download\n --defaults Also scan the default agent locations when paths are given\n --deep Look further: deeper directory levels and bundled files\n --max-file-size <MiB> Skip files larger than this many MiB\n -q, --quiet Only print findings (nothing when clean); with\n --output, print nothing to stdout\n -v, --verbose Show info findings, skipped files and rule errors\n --color, --no-color Force or disable colors (NO_COLOR is honoured)\n\n--fail-on also counts findings hidden by --min-severity, but never findings\nsuppressed by the ignore file.\n\nExit codes:\n 0 no findings at or above --fail-on\n 1 findings at or above --fail-on\n 2 usage or runtime error, including a path that does not exist\n\nExamples:\n toolmoat scan\n toolmoat scan ./skills --format sarif --output toolmoat.sarif\n toolmoat scan . --fail-on medium --format markdown >> \"$GITHUB_STEP_SUMMARY\"";
6
+ export type FailOn = Severity | 'none';
7
+ /** Input to the scan core. Paths are resolved by discovery against cwd. */
8
+ export interface ScanOptions {
9
+ paths: string[];
10
+ useDefaults: boolean;
11
+ cwd: string;
12
+ deep?: boolean;
13
+ maxFileBytes?: number;
14
+ /** The user's suppressions. Never loaded from inside a scanned target. */
15
+ ignore?: IgnoreConfig;
16
+ /** Findings below this severity are left out of the result. Default low. */
17
+ minSeverity?: Severity;
18
+ /** Default high. */
19
+ failOn?: FailOn;
20
+ }
21
+ export interface ScanDeps {
22
+ discover(options: DiscoverOptions): Promise<DiscoverResult>;
23
+ /** Defaults to every built-in rule. */
24
+ rules?: readonly Rule[];
25
+ }
26
+ export interface RuleError {
27
+ ruleId: string;
28
+ file: string;
29
+ message: string;
30
+ }
31
+ export interface ScanOutcome {
32
+ /** Findings at or above minSeverity, not suppressed by the ignore config. */
33
+ result: ScanResult;
34
+ exitCode: 0 | 1;
35
+ roots: string[];
36
+ /** Findings below minSeverity that were left out of the result. */
37
+ hidden: number;
38
+ /** Findings dropped by the ignore config. */
39
+ suppressed: number;
40
+ ruleErrors: RuleError[];
41
+ /** Display path to absolute path of every scanned file. */
42
+ filePaths: Map<string, string>;
43
+ }
44
+ /** The scan pipeline without any I/O of its own: discover, run the rules, filter, decide the exit code. */
45
+ export declare function runScan(options: ScanOptions, deps?: ScanDeps): Promise<ScanOutcome>;
46
+ /** Process boundary for the command, injectable for tests. */
47
+ export interface ScanIo {
48
+ cwd: string;
49
+ stdout(text: string): void;
50
+ stderr(text: string): void;
51
+ /** Whether stdout takes colors when neither --color nor --no-color is given. */
52
+ color: boolean;
53
+ deps: ScanDeps;
54
+ }
55
+ export declare function run(argv: string[], io?: Partial<ScanIo>): Promise<number>;
56
+ /** One line for stdout when the report itself went to a file. */
57
+ export declare function summaryLine(outcome: ScanOutcome, format: ReportFormat, written: string): string;