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,72 @@
1
+ import type { Finding } from '../types.js';
2
+ import type { ToolDefinition } from './fingerprint.js';
3
+ import type { FindingSummary, PendingReason, PinnedTool, ServerEntry } from './lockfile.js';
4
+ /** enforce hides and blocks; warn only reports and records. */
5
+ export type Mode = 'enforce' | 'warn';
6
+ /** What happens to a tool that shows up on a server that is already pinned. */
7
+ export type OnNew = 'block' | 'allow';
8
+ export interface ToolInput {
9
+ name: string;
10
+ hash: string;
11
+ definition: ToolDefinition;
12
+ /** The pinned definition for this tool name, if there is one. */
13
+ pinned?: PinnedTool;
14
+ /** True while the server is being seen for the first time (trust on first use). */
15
+ firstUse: boolean;
16
+ onNew: OnNew;
17
+ /** Scans the definition. Called only when the tool does not match its pin. */
18
+ scan: () => Finding[];
19
+ }
20
+ interface DecisionBase {
21
+ name: string;
22
+ hash: string;
23
+ definition: ToolDefinition;
24
+ }
25
+ export type ToolDecision =
26
+ /** The definition matches its pin. */
27
+ (DecisionBase & {
28
+ action: 'allow';
29
+ })
30
+ /** Not pinned yet and accepted: trust on first use, or --on-new allow. */
31
+ | (DecisionBase & {
32
+ action: 'pin';
33
+ why: 'first-use' | 'on-new-allow';
34
+ warnings: FindingSummary[];
35
+ })
36
+ /** Held for human review. */
37
+ | (DecisionBase & {
38
+ action: 'block';
39
+ reason: PendingReason;
40
+ findings: FindingSummary[];
41
+ });
42
+ /**
43
+ * Decides one tool. A tool that matches its pin is allowed without
44
+ * rescanning (a human or first use already accepted exactly this
45
+ * definition). A changed definition is always held. An unpinned tool with a
46
+ * high or critical finding is held as flagged even on first use or with
47
+ * --on-new allow; otherwise first use pins it and later arrivals follow onNew.
48
+ */
49
+ export declare function decideTool(input: ToolInput): ToolDecision;
50
+ /** High and critical findings keep an unpinned tool away from the model. */
51
+ export declare function isBlocking(finding: {
52
+ severity: Finding['severity'];
53
+ }): boolean;
54
+ /** Medium findings let the tool through with a warning. */
55
+ export declare function isWarning(finding: {
56
+ severity: Finding['severity'];
57
+ }): boolean;
58
+ /** Findings worth keeping in the lockfile: everything above info. */
59
+ export declare function summarize(findings: readonly Finding[]): FindingSummary[];
60
+ export interface ApplyContext {
61
+ command: string;
62
+ now: string;
63
+ }
64
+ /**
65
+ * Folds a list of decisions into a server's lock entry: pins accepted tools
66
+ * and records held ones as pending. A pending entry that already holds the
67
+ * same definition keeps its original detection time. Pure: returns a new entry.
68
+ */
69
+ export declare function applyDecisions(current: ServerEntry | undefined, decisions: readonly ToolDecision[], ctx: ApplyContext): ServerEntry;
70
+ /** Pinned tools that a complete listing no longer contains. */
71
+ export declare function removedTools(entry: ServerEntry | undefined, listed: ReadonlySet<string>): string[];
72
+ export {};
@@ -0,0 +1,86 @@
1
+ import { atLeast } from '../types.js';
2
+ import { getOwn, setOwn } from './canonical.js';
3
+ /**
4
+ * Decides one tool. A tool that matches its pin is allowed without
5
+ * rescanning (a human or first use already accepted exactly this
6
+ * definition). A changed definition is always held. An unpinned tool with a
7
+ * high or critical finding is held as flagged even on first use or with
8
+ * --on-new allow; otherwise first use pins it and later arrivals follow onNew.
9
+ */
10
+ export function decideTool(input) {
11
+ const base = { name: input.name, hash: input.hash, definition: input.definition };
12
+ if (input.pinned && input.pinned.hash === input.hash)
13
+ return { ...base, action: 'allow' };
14
+ const findings = summarize(input.scan());
15
+ if (input.pinned)
16
+ return { ...base, action: 'block', reason: 'changed', findings };
17
+ if (findings.some((f) => isBlocking(f)))
18
+ return { ...base, action: 'block', reason: 'flagged', findings };
19
+ if (input.firstUse)
20
+ return { ...base, action: 'pin', why: 'first-use', warnings: findings.filter(isWarning) };
21
+ if (input.onNew === 'allow')
22
+ return { ...base, action: 'pin', why: 'on-new-allow', warnings: findings.filter(isWarning) };
23
+ return { ...base, action: 'block', reason: 'new', findings };
24
+ }
25
+ /** High and critical findings keep an unpinned tool away from the model. */
26
+ export function isBlocking(finding) {
27
+ return atLeast(finding.severity, 'high');
28
+ }
29
+ /** Medium findings let the tool through with a warning. */
30
+ export function isWarning(finding) {
31
+ return atLeast(finding.severity, 'medium') && !isBlocking(finding);
32
+ }
33
+ /** Findings worth keeping in the lockfile: everything above info. */
34
+ export function summarize(findings) {
35
+ return findings
36
+ .filter((f) => atLeast(f.severity, 'low'))
37
+ .map((f) => ({ ruleId: f.ruleId, severity: f.severity, message: f.message }));
38
+ }
39
+ /**
40
+ * Folds a list of decisions into a server's lock entry: pins accepted tools
41
+ * and records held ones as pending. A pending entry that already holds the
42
+ * same definition keeps its original detection time. Pure: returns a new entry.
43
+ */
44
+ export function applyDecisions(current, decisions, ctx) {
45
+ const entry = current
46
+ ? { ...current, tools: { ...current.tools }, pending: { ...current.pending } }
47
+ : { command: ctx.command, firstSeen: ctx.now, updated: ctx.now, tools: {}, pending: {} };
48
+ let dirty = current === undefined || current.command !== ctx.command;
49
+ entry.command = ctx.command;
50
+ for (const decision of decisions) {
51
+ if (decision.action === 'pin') {
52
+ setOwn(entry.tools, decision.name, { hash: decision.hash, definition: decision.definition, pinnedAt: ctx.now });
53
+ delete entry.pending[decision.name];
54
+ dirty = true;
55
+ }
56
+ else if (decision.action === 'allow') {
57
+ // The server serves the pinned definition again, so a change recorded earlier is obsolete.
58
+ if (getOwn(entry.pending, decision.name) !== undefined) {
59
+ delete entry.pending[decision.name];
60
+ dirty = true;
61
+ }
62
+ }
63
+ else {
64
+ const existing = getOwn(entry.pending, decision.name);
65
+ if (existing && existing.hash === decision.hash && existing.reason === decision.reason)
66
+ continue;
67
+ setOwn(entry.pending, decision.name, {
68
+ hash: decision.hash,
69
+ definition: decision.definition,
70
+ detectedAt: existing?.hash === decision.hash ? existing.detectedAt : ctx.now,
71
+ reason: decision.reason,
72
+ findings: decision.findings,
73
+ });
74
+ dirty = true;
75
+ }
76
+ }
77
+ if (dirty)
78
+ entry.updated = ctx.now;
79
+ return dirty ? entry : current;
80
+ }
81
+ /** Pinned tools that a complete listing no longer contains. */
82
+ export function removedTools(entry, listed) {
83
+ if (!entry)
84
+ return [];
85
+ return Object.keys(entry.tools).filter((name) => !listed.has(name)).sort();
86
+ }
@@ -0,0 +1,18 @@
1
+ import type { ProxyOptions } from './firewall.js';
2
+ export type { ProxyOptions } from './firewall.js';
3
+ export interface RunProxyOptions extends ProxyOptions {
4
+ /** The server executable as written in the client config, e.g. "npx". */
5
+ command: string;
6
+ args: readonly string[];
7
+ /** JSONL audit log; undefined disables auditing. */
8
+ auditPath?: string;
9
+ }
10
+ /** Writes one prefixed diagnostic line to stderr, the only place toolmoat may talk while proxying. */
11
+ export declare function stderrDiag(message: string): void;
12
+ /**
13
+ * Runs an MCP stdio server behind the firewall: spawns it, pumps
14
+ * newline-delimited JSON-RPC between this process's stdio and the server's,
15
+ * and resolves with the exit code to use (the server's own, or 1 when it
16
+ * could not start). stdout carries protocol messages only.
17
+ */
18
+ export declare function runProxy(options: RunProxyOptions): Promise<number>;
@@ -0,0 +1,237 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import { AuditLog } from './audit.js';
5
+ import { Firewall } from './firewall.js';
6
+ import { LineDecoder } from './framing.js';
7
+ import { redactCommandLine } from './names.js';
8
+ import { killServer, planSpawn } from './spawn.js';
9
+ const NEWLINE = Buffer.from('\n');
10
+ /** How long the server may keep running after the client closed stdin. */
11
+ const STDIN_EOF_GRACE_MS = 5000;
12
+ /** How long to wait for the server's last output after it exited. */
13
+ const DRAIN_AFTER_EXIT_MS = 1500;
14
+ const LOCK_POLL_MS = 1000;
15
+ /** Writes one prefixed diagnostic line to stderr, the only place toolmoat may talk while proxying. */
16
+ export function stderrDiag(message) {
17
+ try {
18
+ process.stderr.write(`[toolmoat] ${message}\n`);
19
+ }
20
+ catch {
21
+ // stderr is gone; nothing else to try.
22
+ }
23
+ }
24
+ /**
25
+ * Runs an MCP stdio server behind the firewall: spawns it, pumps
26
+ * newline-delimited JSON-RPC between this process's stdio and the server's,
27
+ * and resolves with the exit code to use (the server's own, or 1 when it
28
+ * could not start). stdout carries protocol messages only.
29
+ */
30
+ export async function runProxy(options) {
31
+ const diag = options.diag ?? stderrDiag;
32
+ const audit = options.auditPath ? new AuditLog(options.auditPath, options.server, diag) : undefined;
33
+ let firewall;
34
+ try {
35
+ firewall = new Firewall({ ...options, diag, audit });
36
+ }
37
+ catch (error) {
38
+ diag(`${errorMessage(error)}. Fix or delete the lockfile; refusing to start '${options.server}' unverified.`);
39
+ await audit?.close();
40
+ return 1;
41
+ }
42
+ const plan = planSpawn(options.command, options.args, { platform: process.platform, env: process.env, cwd: process.cwd() });
43
+ if ('error' in plan) {
44
+ diag(`could not start MCP server '${options.server}': ${plan.error}`);
45
+ await audit?.close();
46
+ return 1;
47
+ }
48
+ audit?.write({ event: 'start', decision: options.mode ?? 'enforce', detail: redactCommandLine(options.commandLine) });
49
+ let child;
50
+ try {
51
+ child = spawn(plan.file, plan.args, {
52
+ stdio: ['pipe', 'pipe', 'inherit'],
53
+ env: process.env,
54
+ windowsHide: true,
55
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
56
+ });
57
+ }
58
+ catch (error) {
59
+ diag(`could not start MCP server '${options.server}': ${errorMessage(error)}`);
60
+ await audit?.close();
61
+ return 1;
62
+ }
63
+ return supervise(child, firewall, audit, diag, options);
64
+ }
65
+ function supervise(child, firewall, audit, diag, options) {
66
+ const clientIn = process.stdin;
67
+ const clientOut = process.stdout;
68
+ const serverIn = child.stdin;
69
+ const serverOut = child.stdout;
70
+ const fromClient = new LineDecoder();
71
+ const fromServer = new LineDecoder();
72
+ return new Promise((resolve) => {
73
+ let finished = false;
74
+ let signals = 0;
75
+ let graceTimer;
76
+ let clientPaused = false;
77
+ let serverPaused = false;
78
+ const toServer = (chunks) => {
79
+ if (chunks.length === 0 || serverIn.destroyed || serverIn.writableEnded)
80
+ return;
81
+ if (!serverIn.write(Buffer.concat(chunks)) && !clientPaused) {
82
+ clientPaused = true;
83
+ clientIn.pause();
84
+ serverIn.once('drain', () => {
85
+ clientPaused = false;
86
+ clientIn.resume();
87
+ });
88
+ }
89
+ };
90
+ const toClient = (chunks) => {
91
+ if (chunks.length === 0 || clientOut.destroyed)
92
+ return;
93
+ if (!clientOut.write(Buffer.concat(chunks)) && !serverPaused) {
94
+ serverPaused = true;
95
+ serverOut.pause();
96
+ clientOut.once('drain', () => {
97
+ serverPaused = false;
98
+ serverOut.resume();
99
+ });
100
+ }
101
+ };
102
+ const route = (lines, from) => {
103
+ const onward = [];
104
+ const back = [];
105
+ for (const line of lines) {
106
+ const routed = safeRoute(firewall, line.text, from, options.mode ?? 'enforce', diag);
107
+ if (routed.passOriginal)
108
+ onward.push(line.raw, NEWLINE);
109
+ for (const text of routed.onward)
110
+ onward.push(Buffer.from(`${text}\n`, 'utf8'));
111
+ for (const text of routed.back)
112
+ back.push(Buffer.from(`${text}\n`, 'utf8'));
113
+ }
114
+ if (from === 'client') {
115
+ toServer(onward);
116
+ toClient(back);
117
+ }
118
+ else {
119
+ toClient(onward);
120
+ toServer(back);
121
+ }
122
+ };
123
+ const onClientData = (chunk) => route(fromClient.push(chunk), 'client');
124
+ const onClientEnd = () => {
125
+ const last = fromClient.flush();
126
+ if (last)
127
+ route([last], 'client');
128
+ if (!serverIn.destroyed)
129
+ serverIn.end();
130
+ graceTimer ??= setTimeout(() => killServer(child), STDIN_EOF_GRACE_MS);
131
+ };
132
+ clientIn.on('data', onClientData);
133
+ clientIn.once('end', onClientEnd);
134
+ clientIn.once('error', onClientEnd);
135
+ serverOut.on('data', (chunk) => route(fromServer.push(chunk), 'server'));
136
+ serverOut.once('end', () => {
137
+ const last = fromServer.flush();
138
+ if (last)
139
+ route([last], 'server');
140
+ });
141
+ serverIn.on('error', () => {
142
+ // EPIPE once the server has exited; its exit is handled below.
143
+ });
144
+ clientOut.on('error', () => {
145
+ diag('the MCP client closed stdout; stopping the server');
146
+ killServer(child);
147
+ });
148
+ const onLockChange = (current, previous) => {
149
+ if (current.mtimeMs === previous.mtimeMs && current.size === previous.size)
150
+ return;
151
+ const lines = firewall.lockChanged();
152
+ toClient(lines.map((text) => Buffer.from(`${text}\n`, 'utf8')));
153
+ };
154
+ fs.watchFile(options.lockPath, { interval: LOCK_POLL_MS, persistent: false }, onLockChange);
155
+ const onSignal = (signal) => {
156
+ signals++;
157
+ if (process.platform === 'win32' || signals > 1)
158
+ killServer(child);
159
+ else
160
+ child.kill(signal);
161
+ };
162
+ const signalNames = process.platform === 'win32' ? ['SIGINT', 'SIGTERM'] : ['SIGINT', 'SIGTERM', 'SIGHUP'];
163
+ for (const name of signalNames)
164
+ process.on(name, onSignal);
165
+ const onProcessExit = () => killServer(child);
166
+ process.on('exit', onProcessExit);
167
+ const finish = async (code) => {
168
+ if (finished)
169
+ return;
170
+ finished = true;
171
+ if (graceTimer)
172
+ clearTimeout(graceTimer);
173
+ fs.unwatchFile(options.lockPath, onLockChange);
174
+ for (const name of signalNames)
175
+ process.off(name, onSignal);
176
+ clientIn.off('data', onClientData);
177
+ clientIn.pause();
178
+ clientIn.destroy();
179
+ audit?.write({ event: 'exit', code });
180
+ await withTimeout(firewall.idle(), 1000);
181
+ await audit?.close();
182
+ await withTimeout(new Promise((done) => clientOut.write('', () => done())), 1000);
183
+ process.off('exit', onProcessExit);
184
+ resolve(code);
185
+ };
186
+ child.once('error', (error) => {
187
+ if (child.pid !== undefined)
188
+ return;
189
+ diag(`could not start MCP server '${options.server}': ${errorMessage(error)}`);
190
+ void finish(1);
191
+ });
192
+ child.once('exit', (code, signal) => {
193
+ const exitCode = code ?? signalExitCode(signal);
194
+ if (serverOut.readableEnded || serverOut.destroyed) {
195
+ void finish(exitCode);
196
+ return;
197
+ }
198
+ const timer = setTimeout(() => void finish(exitCode), DRAIN_AFTER_EXIT_MS);
199
+ serverOut.once('close', () => {
200
+ clearTimeout(timer);
201
+ void finish(exitCode);
202
+ });
203
+ });
204
+ });
205
+ }
206
+ /** Routes one line, failing closed in enforce mode if the firewall itself throws. */
207
+ function safeRoute(firewall, text, from, mode, diag) {
208
+ try {
209
+ return from === 'client' ? firewall.fromClient(text) : firewall.fromServer(text);
210
+ }
211
+ catch (error) {
212
+ diag(`internal error while checking a message from the ${from}: ${errorMessage(error)}; ${mode === 'enforce' ? 'dropping it' : 'forwarding it'}`);
213
+ return { passOriginal: mode !== 'enforce', onward: [], back: [] };
214
+ }
215
+ }
216
+ function signalExitCode(signal) {
217
+ if (!signal)
218
+ return 1;
219
+ const number = os.constants.signals[signal];
220
+ return typeof number === 'number' ? 128 + number : 1;
221
+ }
222
+ function withTimeout(promise, ms) {
223
+ return new Promise((resolve) => {
224
+ const timer = setTimeout(resolve, ms);
225
+ timer.unref();
226
+ promise.then(() => {
227
+ clearTimeout(timer);
228
+ resolve();
229
+ }, () => {
230
+ clearTimeout(timer);
231
+ resolve();
232
+ });
233
+ });
234
+ }
235
+ function errorMessage(error) {
236
+ return error instanceof Error ? error.message : String(error);
237
+ }
@@ -0,0 +1,51 @@
1
+ import type { ChildProcess } from 'node:child_process';
2
+ /** How to start the MCP server: the file to execute and its exact arguments. */
3
+ export interface SpawnPlan {
4
+ file: string;
5
+ args: string[];
6
+ /** Set when args are a pre-escaped cmd.exe command line that Node must not quote again. */
7
+ windowsVerbatimArguments: boolean;
8
+ /** The server runs under cmd.exe, so stopping it means stopping a process tree. */
9
+ viaShell: boolean;
10
+ }
11
+ export interface PlanContext {
12
+ platform: NodeJS.Platform;
13
+ env: NodeJS.ProcessEnv;
14
+ cwd: string;
15
+ /** Whether a path is a regular file. Defaults to the real file system; tests inject one to plan for Windows anywhere. */
16
+ isFile?: (candidate: string) => boolean;
17
+ }
18
+ /**
19
+ * Works out how to launch a server command. Elsewhere Node resolves PATH
20
+ * itself. On Windows the command is resolved through PATH and PATHEXT here,
21
+ * because most MCP servers start through .cmd shims (npx.cmd, pnpm.cmd...)
22
+ * that Node refuses to spawn without a shell since the CVE-2024-27980 fix;
23
+ * those run through cmd.exe with every argument escaped for it.
24
+ */
25
+ export declare function planSpawn(command: string, args: readonly string[], ctx: PlanContext): SpawnPlan | {
26
+ error: string;
27
+ };
28
+ /** Case-insensitive environment lookup, as Windows itself does. */
29
+ export declare function envValue(env: NodeJS.ProcessEnv, name: string): string | undefined;
30
+ /**
31
+ * Finds the file Windows would run for a command, trying each PATHEXT
32
+ * extension in each PATH directory. Paths containing a separator resolve
33
+ * against cwd. Unlike cmd.exe, a bare name is never looked up in the current
34
+ * directory, so a planted npx.cmd in a project cannot hijack the server.
35
+ */
36
+ export declare function resolveWindowsCommand(command: string, env: NodeJS.ProcessEnv, cwd: string, exists?: (candidate: string) => boolean): string | undefined;
37
+ /** Escapes the program path of a cmd.exe command line by putting a caret before every metacharacter (as cross-spawn does). */
38
+ export declare function escapeCmdCommand(command: string): string;
39
+ /**
40
+ * Escapes one argument for `cmd.exe /d /s /c "<line>"` when the target is a
41
+ * batch file: quote it the way CommandLineToArgvW expects
42
+ * (https://qntm.org/cmd), then caret-escape metacharacters twice, because
43
+ * cmd.exe parses the line once to start the batch file and again when the
44
+ * batch file expands %* (npx.cmd, npm and pnpm shims all do). Escaping only
45
+ * once lets an argument such as `say "hi" & calc` run calc.
46
+ */
47
+ export declare function escapeCmdArgument(arg: string): string;
48
+ /** Quotes an argument so CommandLineToArgvW reads it back unchanged: backslashes before a quote or the end are doubled. */
49
+ export declare function quoteForArgv(arg: string): string;
50
+ /** Stops the server. On Windows the whole tree goes, since killing cmd.exe alone would orphan the real server. */
51
+ export declare function killServer(child: ChildProcess, platform?: NodeJS.Platform): void;
@@ -0,0 +1,110 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ /**
5
+ * Works out how to launch a server command. Elsewhere Node resolves PATH
6
+ * itself. On Windows the command is resolved through PATH and PATHEXT here,
7
+ * because most MCP servers start through .cmd shims (npx.cmd, pnpm.cmd...)
8
+ * that Node refuses to spawn without a shell since the CVE-2024-27980 fix;
9
+ * those run through cmd.exe with every argument escaped for it.
10
+ */
11
+ export function planSpawn(command, args, ctx) {
12
+ if (ctx.platform !== 'win32')
13
+ return { file: command, args: [...args], windowsVerbatimArguments: false, viaShell: false };
14
+ const resolved = resolveWindowsCommand(command, ctx.env, ctx.cwd, ctx.isFile);
15
+ if (!resolved)
16
+ return { error: `command not found: ${command} (searched PATH with PATHEXT)` };
17
+ if (!/\.(?:cmd|bat)$/i.test(resolved))
18
+ return { file: resolved, args: [...args], windowsVerbatimArguments: false, viaShell: false };
19
+ const unsafe = [resolved, ...args].find((arg) => /[\r\n\0]/.test(arg));
20
+ if (unsafe !== undefined)
21
+ return { error: 'cannot pass an argument containing a line break or NUL to a .cmd/.bat server through cmd.exe' };
22
+ const line = [escapeCmdCommand(resolved), ...args.map(escapeCmdArgument)].join(' ');
23
+ return {
24
+ file: envValue(ctx.env, 'ComSpec') ?? 'cmd.exe',
25
+ args: ['/d', '/s', '/c', `"${line}"`],
26
+ windowsVerbatimArguments: true,
27
+ viaShell: true,
28
+ };
29
+ }
30
+ /** Case-insensitive environment lookup, as Windows itself does. */
31
+ export function envValue(env, name) {
32
+ const upper = name.toUpperCase();
33
+ for (const [key, value] of Object.entries(env))
34
+ if (key.toUpperCase() === upper && value !== undefined)
35
+ return value;
36
+ return undefined;
37
+ }
38
+ /**
39
+ * Finds the file Windows would run for a command, trying each PATHEXT
40
+ * extension in each PATH directory. Paths containing a separator resolve
41
+ * against cwd. Unlike cmd.exe, a bare name is never looked up in the current
42
+ * directory, so a planted npx.cmd in a project cannot hijack the server.
43
+ */
44
+ export function resolveWindowsCommand(command, env, cwd, exists = isFile) {
45
+ const win = path.win32;
46
+ const exts = (envValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.trim()).filter(Boolean);
47
+ const ext = win.extname(command);
48
+ const hasKnownExt = ext !== '' && exts.some((e) => e.toLowerCase() === ext.toLowerCase());
49
+ const names = hasKnownExt ? [command] : [...exts.map((e) => command + e.toLowerCase()), ...(ext ? [command] : [])];
50
+ const hasSeparator = /[\\/]/.test(command) || win.isAbsolute(command);
51
+ const dirs = hasSeparator
52
+ ? [cwd]
53
+ : (envValue(env, 'PATH') ?? '').split(';').map((d) => d.trim().replace(/^"(.*)"$/, '$1')).filter(Boolean);
54
+ for (const dir of dirs) {
55
+ for (const name of names) {
56
+ const candidate = win.resolve(dir, name);
57
+ if (exists(candidate))
58
+ return candidate;
59
+ }
60
+ }
61
+ return undefined;
62
+ }
63
+ function isFile(p) {
64
+ try {
65
+ return fs.statSync(p).isFile();
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
72
+ /** Escapes the program path of a cmd.exe command line by putting a caret before every metacharacter (as cross-spawn does). */
73
+ export function escapeCmdCommand(command) {
74
+ return path.win32.normalize(command).replace(CMD_META, '^$1');
75
+ }
76
+ /**
77
+ * Escapes one argument for `cmd.exe /d /s /c "<line>"` when the target is a
78
+ * batch file: quote it the way CommandLineToArgvW expects
79
+ * (https://qntm.org/cmd), then caret-escape metacharacters twice, because
80
+ * cmd.exe parses the line once to start the batch file and again when the
81
+ * batch file expands %* (npx.cmd, npm and pnpm shims all do). Escaping only
82
+ * once lets an argument such as `say "hi" & calc` run calc.
83
+ */
84
+ export function escapeCmdArgument(arg) {
85
+ return quoteForArgv(arg).replace(CMD_META, '^$1').replace(CMD_META, '^$1');
86
+ }
87
+ /** Quotes an argument so CommandLineToArgvW reads it back unchanged: backslashes before a quote or the end are doubled. */
88
+ export function quoteForArgv(arg) {
89
+ let out = '"';
90
+ let slashes = 0;
91
+ for (const ch of arg) {
92
+ if (ch === '\\') {
93
+ slashes++;
94
+ continue;
95
+ }
96
+ out += ch === '"' ? `${'\\'.repeat(slashes * 2 + 1)}"` : `${'\\'.repeat(slashes)}${ch}`;
97
+ slashes = 0;
98
+ }
99
+ return `${out}${'\\'.repeat(slashes * 2)}"`;
100
+ }
101
+ /** Stops the server. On Windows the whole tree goes, since killing cmd.exe alone would orphan the real server. */
102
+ export function killServer(child, platform = process.platform) {
103
+ if (child.exitCode !== null || child.signalCode !== null)
104
+ return;
105
+ if (platform === 'win32' && child.pid !== undefined) {
106
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
107
+ return;
108
+ }
109
+ child.kill('SIGTERM');
110
+ }
@@ -0,0 +1,35 @@
1
+ import type { ScanResult, Severity } from '../types.js';
2
+ export type ReportFormat = 'text' | 'json' | 'sarif' | 'markdown';
3
+ export declare const REPORT_FORMATS: readonly ReportFormat[];
4
+ export declare function isReportFormat(value: string): value is ReportFormat;
5
+ /** Context shared by every reporter. Reporters are pure: everything they print comes from the result and these options. */
6
+ export interface ReportOptions {
7
+ /** Version printed in the report. Defaults to the running toolmoat version. */
8
+ version?: string;
9
+ /** Scan roots shown in the text header: display paths, or absolute paths that get shortened. */
10
+ roots?: readonly string[];
11
+ /** Directory paths are shown relative to, and the SARIF %SRCROOT%. Defaults to process.cwd(). */
12
+ cwd?: string;
13
+ /** Text only: emit ANSI colors. */
14
+ color?: boolean;
15
+ /** Text only: list skipped files and every scan root. */
16
+ verbose?: boolean;
17
+ /** Text only: print findings and nothing else, and nothing at all when clean. */
18
+ quiet?: boolean;
19
+ /** The --min-severity the result was filtered with. */
20
+ minSeverity?: Severity;
21
+ /** How many findings were dropped for being below minSeverity. */
22
+ hidden?: number;
23
+ /** How many findings the user's ignore file suppressed. */
24
+ suppressed?: number;
25
+ /** Display path of the ignore file that was applied, if any. */
26
+ ignoreFile?: string;
27
+ /** Maps a finding's display path to its absolute path, so SARIF can emit exact URIs. */
28
+ filePaths?: ReadonlyMap<string, string>;
29
+ }
30
+ /** Renders a scan result in the requested format. The returned string ends with a newline unless it is empty. */
31
+ export declare function renderReport(format: ReportFormat, result: ScanResult, options?: ReportOptions): string;
32
+ export { renderJson, toJsonFinding } from './json.js';
33
+ export { escapeCell, renderMarkdown } from './markdown.js';
34
+ export { buildSarif, renderSarif, sarifArtifactLocation } from './sarif.js';
35
+ export { renderText, shortPath } from './text.js';
@@ -0,0 +1,25 @@
1
+ import { renderJson } from './json.js';
2
+ import { renderMarkdown } from './markdown.js';
3
+ import { renderSarif } from './sarif.js';
4
+ import { renderText } from './text.js';
5
+ export const REPORT_FORMATS = ['text', 'json', 'sarif', 'markdown'];
6
+ export function isReportFormat(value) {
7
+ return REPORT_FORMATS.includes(value);
8
+ }
9
+ /** Renders a scan result in the requested format. The returned string ends with a newline unless it is empty. */
10
+ export function renderReport(format, result, options = {}) {
11
+ switch (format) {
12
+ case 'text':
13
+ return renderText(result, options);
14
+ case 'json':
15
+ return renderJson(result, options);
16
+ case 'sarif':
17
+ return renderSarif(result, options);
18
+ case 'markdown':
19
+ return renderMarkdown(result, options);
20
+ }
21
+ }
22
+ export { renderJson, toJsonFinding } from './json.js';
23
+ export { escapeCell, renderMarkdown } from './markdown.js';
24
+ export { buildSarif, renderSarif, sarifArtifactLocation } from './sarif.js';
25
+ export { renderText, shortPath } from './text.js';
@@ -0,0 +1,22 @@
1
+ import type { Finding, ScanResult, Severity } from '../types.js';
2
+ import type { ReportOptions } from './index.js';
3
+ export interface JsonReport {
4
+ tool: 'toolmoat';
5
+ version: string;
6
+ summary: {
7
+ filesScanned: number;
8
+ /** Names of the distinct targets (skills, plugins, servers) that were scanned. */
9
+ targets: string[];
10
+ durationMs: number;
11
+ counts: Record<Severity, number>;
12
+ };
13
+ findings: Finding[];
14
+ skipped: {
15
+ path: string;
16
+ reason: string;
17
+ }[];
18
+ }
19
+ /** Machine-readable report with a stable key order and 2-space indentation. */
20
+ export declare function renderJson(result: ScanResult, options?: ReportOptions): string;
21
+ /** Copies a finding with its keys in a fixed order, leaving out absent optional fields. */
22
+ export declare function toJsonFinding(finding: Finding): Finding;