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,518 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { parseArgs } from 'node:util';
5
+ import { CLIENT_CONFIGS, clientChoices, findClientConfig, isRemoteServer, listServers, matchClientConfigs, pathKey, readJsonConfig, readLaunch, readTomlServers, splitWrapped, tomlString, writeJsonConfig, } from '../clients/configs.js';
6
+ import { redactSecrets } from '../patterns/secrets.js';
7
+ import { deriveServerName } from '../proxy/names.js';
8
+ import { palette } from '../util/colors.js';
9
+ import { expandHome, homeDir } from '../util/paths.js';
10
+ import { printable, truncate } from '../util/text.js';
11
+ import { VERSION } from '../version.js';
12
+ export const help = `Usage: toolmoat wrap [options]
13
+
14
+ Routes the stdio MCP servers in your client configs through \`toolmoat proxy\`,
15
+ which pins tool definitions, guards outgoing secrets and keeps an audit log.
16
+
17
+ Options:
18
+ --client <id> Only this client (repeatable): claude-desktop, claude-code, cursor,
19
+ vscode, windsurf, codex, gemini, or one config id such as
20
+ claude-code-project, cursor-project, vscode-project, gemini-project
21
+ --config <file> Wrap the servers in this config file (repeatable)
22
+ --lock <file> Lockfile the proxy should use instead of its default
23
+ --mode <mode> Proxy mode: enforce or warn
24
+ --dry-run Show what would change and write nothing
25
+ --force Rewrite JSON files that contain comments (the comments are lost)
26
+
27
+ Without --client or --config, every user-level config that exists is wrapped:
28
+ Claude Desktop, Claude Code (~/.claude.json, including its per-project servers),
29
+ Cursor, VS Code, Windsurf, Gemini CLI and Codex. Project configs (.mcp.json,
30
+ .cursor/mcp.json, .vscode/mcp.json, .gemini/settings.json) are only wrapped when
31
+ named, because they are usually committed and the wrapped command holds paths
32
+ from this machine.
33
+
34
+ Each file is copied once to <file>.toolmoat.bak before its first change and is
35
+ replaced atomically. Remote (url) servers are left alone. Servers that are
36
+ already wrapped are left alone too, unless the node or cli.js path they start
37
+ no longer exists (say after switching Node versions): those are pointed at this
38
+ install. Codex's config.toml is never edited: wrap prints the lines to paste.
39
+ Restart the client afterwards. Undo with \`toolmoat unwrap\`.`;
40
+ /** Raised for bad command-line input; run() turns it into exit code 2. */
41
+ export class UsageError extends Error {
42
+ }
43
+ /** Absolute path of the cli.js that is running this command. */
44
+ export function currentCliPath() {
45
+ return fileURLToPath(new URL('../cli.js', import.meta.url));
46
+ }
47
+ /**
48
+ * How a wrapped server starts toolmoat: the running node with the running
49
+ * cli.js, or `npx -y toolmoat@<version>` when cli.js sits in the npx cache,
50
+ * which npm may clean at any time.
51
+ */
52
+ export function launcherFor(cliPath, nodePath = process.execPath) {
53
+ if (/[\\/]_npx[\\/]/.test(cliPath))
54
+ return { command: 'npx', prefix: ['-y', `toolmoat@${VERSION}`] };
55
+ return { command: nodePath, prefix: [cliPath] };
56
+ }
57
+ /** The wrapped command line for a server. */
58
+ export function wrapLaunch(name, original, launcher, options = {}) {
59
+ return {
60
+ command: launcher.command,
61
+ args: [
62
+ ...launcher.prefix,
63
+ 'proxy',
64
+ '--name',
65
+ name,
66
+ ...(options.lock ? ['--lock', options.lock] : []),
67
+ ...(options.mode ? ['--mode', options.mode] : []),
68
+ '--',
69
+ original.command,
70
+ ...original.args,
71
+ ],
72
+ };
73
+ }
74
+ function fromLocation(location, cwd, explicit) {
75
+ return {
76
+ path: location.resolve(cwd),
77
+ label: location.label,
78
+ scope: location.id,
79
+ format: location.format,
80
+ serversKeys: location.serversKeys,
81
+ project: location.scope === 'project',
82
+ explicit,
83
+ };
84
+ }
85
+ function customTarget(file, cwd) {
86
+ const known = findClientConfig(file, cwd);
87
+ if (known)
88
+ return { ...fromLocation(known, cwd, true), path: file };
89
+ const base = path.basename(file).toLowerCase();
90
+ if (base.endsWith('.toml')) {
91
+ return { path: file, label: 'Codex', scope: 'codex', format: 'toml', serversKeys: ['mcp_servers'], project: false, explicit: true };
92
+ }
93
+ const keys = base === '.claude.json' ? ['mcpServers', 'projects.*.mcpServers'] : ['mcpServers', 'servers'];
94
+ return { path: file, label: path.basename(file), scope: path.basename(file, path.extname(file)), format: 'json', serversKeys: keys, project: false, explicit: true };
95
+ }
96
+ /** Resolves --client and --config into the config files to process. Throws UsageError for an unknown client. */
97
+ export function selectTargets(clients, configs, cwd) {
98
+ const targets = [];
99
+ const seen = new Set();
100
+ const add = (target) => {
101
+ const key = pathKey(target.path);
102
+ if (seen.has(key))
103
+ return;
104
+ seen.add(key);
105
+ targets.push(target);
106
+ };
107
+ for (const query of clients) {
108
+ const matches = matchClientConfigs(query);
109
+ if (matches.length === 0) {
110
+ throw new UsageError(`unknown client '${query}'. Known clients: ${clientChoices().join(', ')}`);
111
+ }
112
+ for (const location of matches)
113
+ add(fromLocation(location, cwd, true));
114
+ }
115
+ for (const file of configs)
116
+ add(customTarget(path.resolve(cwd, expandHome(file)), cwd));
117
+ if (clients.length > 0 || configs.length > 0)
118
+ return { targets, projectHints: [] };
119
+ const projectHints = [];
120
+ for (const location of CLIENT_CONFIGS) {
121
+ const target = fromLocation(location, cwd, false);
122
+ if (!fs.existsSync(target.path))
123
+ continue;
124
+ if (location.scope === 'project')
125
+ projectHints.push(target);
126
+ else
127
+ add(target);
128
+ }
129
+ return { targets, projectHints };
130
+ }
131
+ /** Reads a JSON config, applies the transform to every server and writes the file when something changed. */
132
+ export function processJsonTarget(target, transform, options) {
133
+ let config;
134
+ try {
135
+ config = readJsonConfig(target.path);
136
+ }
137
+ catch (error) {
138
+ return { target, servers: [], written: false, error: readError(error) };
139
+ }
140
+ const servers = listServers(config.data, target.serversKeys).map(transform);
141
+ const report = { target, servers, written: false };
142
+ if (!servers.some((s) => s.status === 'changed'))
143
+ return report;
144
+ if (config.hasComments && !options.force) {
145
+ report.error = 'the file contains comments, which rewriting it would drop. Re-run with --force to rewrite it anyway, or edit it by hand';
146
+ return report;
147
+ }
148
+ if (config.lossyNumber !== null) {
149
+ report.error = `the file contains the number ${truncate(config.lossyNumber, 40)}, which rewriting it would change because JSON cannot hold it exactly. Edit the file by hand`;
150
+ return report;
151
+ }
152
+ if (options.dryRun)
153
+ return report;
154
+ try {
155
+ const { backup } = writeJsonConfig(config);
156
+ report.written = true;
157
+ if (backup)
158
+ report.backup = backup;
159
+ }
160
+ catch (error) {
161
+ report.error = `could not write the file: ${error instanceof Error ? error.message : String(error)}`;
162
+ }
163
+ return report;
164
+ }
165
+ function readError(error) {
166
+ const code = error.code;
167
+ if (code === 'ENOENT')
168
+ return 'file not found';
169
+ if (code === 'EACCES' || code === 'EPERM')
170
+ return 'permission denied';
171
+ return error instanceof Error ? error.message : String(error);
172
+ }
173
+ /**
174
+ * The node or cli.js path of a wrapped command line that no longer exists on
175
+ * this machine (after a Node version switch, a reinstall or a config synced
176
+ * from another machine), or null when the launcher is fine or not path-based.
177
+ */
178
+ export function missingLauncherPath(launch) {
179
+ if (launch.args.indexOf('proxy') !== 1 || !/(?:^|[\\/])cli\.js$/i.test(launch.args[0]))
180
+ return null;
181
+ return [launch.command, launch.args[0]].find((p) => path.isAbsolute(p) && !fs.existsSync(p)) ?? null;
182
+ }
183
+ /** Whether `toolmoat proxy --name` accepts the name: it refuses an empty one, control characters and more than 200 characters. */
184
+ export function isProxyName(name) {
185
+ return name !== '' && name.length <= 200 && !/[\u0000-\u001f\u007f]/.test(name);
186
+ }
187
+ /** What wrap does with one stdio command line: wrap it, point a stale wrapped one at this install, or leave it. */
188
+ export function planWrap(name, launch, launcher, options) {
189
+ if (!splitWrapped(launch.command, launch.args)) {
190
+ if (!isProxyName(name)) {
191
+ return { status: 'skipped', detail: 'its name is empty, longer than 200 characters or has control characters, which the proxy rejects; rename the server first' };
192
+ }
193
+ return { status: 'changed', detail: formatLaunch(launch), after: wrapLaunch(name, launch, launcher, options), original: launch };
194
+ }
195
+ const missing = missingLauncherPath(launch);
196
+ if (missing === null)
197
+ return { status: 'unchanged', detail: 'already wrapped' };
198
+ const after = { command: launcher.command, args: [...launcher.prefix, ...launch.args.slice(1)] };
199
+ return { status: 'changed', detail: `already wrapped, but ${formatLaunch({ command: missing, args: [] })} no longer exists; launcher updated`, after };
200
+ }
201
+ /** Lowercase letters, digits and dashes only. */
202
+ function slug(text) {
203
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
204
+ }
205
+ /**
206
+ * The --name a wrapped server gets: the client, the project for per-project
207
+ * servers, then the server's own name ("cursor.github", "claude-code-user.my-app.db").
208
+ * The lockfile keys pins by name, so two clients that both call a server
209
+ * "github" must not share one entry, or each would hide the other's tools.
210
+ */
211
+ export function scopedName(scope, name, project) {
212
+ const parts = [slug(scope) || 'mcp'];
213
+ if (project !== undefined)
214
+ parts.push(slug(path.basename(project)) || 'project');
215
+ parts.push(name);
216
+ return parts.join('.');
217
+ }
218
+ /** The wrap transform: replaces command and args of each stdio server with the proxy launch. */
219
+ export function wrapTransform(launcher, options, scope) {
220
+ return (entry) => {
221
+ const base = entry.project === undefined ? { name: entry.name } : { name: entry.name, project: entry.project };
222
+ if (isRemoteServer(entry.server)) {
223
+ return { ...base, status: 'skipped', detail: 'remote (url) server: the stdio proxy cannot wrap it' };
224
+ }
225
+ const launch = readLaunch(entry.server);
226
+ if ('error' in launch)
227
+ return { ...base, status: 'skipped', detail: launch.error };
228
+ const proxyName = scope === undefined ? entry.name : scopedName(scope, entry.name, entry.project);
229
+ const plan = planWrap(proxyName, launch, launcher, options);
230
+ if (plan.after)
231
+ applyWrapped(entry.server, plan.after);
232
+ return { ...base, ...plan };
233
+ };
234
+ }
235
+ /**
236
+ * Sets the wrapped command and args in place, so every other key keeps its
237
+ * position. When the entry had no args key, the new one is put before command:
238
+ * unwrap reads that order to drop args again and restore the entry exactly.
239
+ */
240
+ function applyWrapped(server, after) {
241
+ if (Object.hasOwn(server, 'args')) {
242
+ server.command = after.command;
243
+ server.args = after.args;
244
+ return;
245
+ }
246
+ const rest = Object.entries(server).map(([key, value]) => [key, key === 'command' ? after.command : value]);
247
+ for (const key of Object.keys(server))
248
+ delete server[key];
249
+ server.args = after.args;
250
+ for (const [key, value] of rest)
251
+ server[key] = value;
252
+ }
253
+ /** Wrap's view of a Codex config.toml: never edited, so changed servers come with lines to paste. */
254
+ export function processTomlTarget(target, transform) {
255
+ let text;
256
+ try {
257
+ text = fs.readFileSync(target.path, 'utf8');
258
+ }
259
+ catch (error) {
260
+ return { target, servers: [], written: false, error: readError(error) };
261
+ }
262
+ const servers = [];
263
+ const pasteLines = [];
264
+ for (const server of readTomlServers(text)) {
265
+ if (server.url !== undefined && server.command === undefined) {
266
+ servers.push({ name: server.name, status: 'skipped', detail: 'remote (url) server: the stdio proxy cannot wrap it' });
267
+ continue;
268
+ }
269
+ if (server.unparsed) {
270
+ servers.push({ name: server.name, status: 'skipped', detail: `${server.unparsed}; edit it by hand` });
271
+ continue;
272
+ }
273
+ if (server.command === undefined) {
274
+ servers.push({ name: server.name, status: 'skipped', detail: 'no command (not a stdio server)' });
275
+ continue;
276
+ }
277
+ const report = transform(server.name, { command: server.command, args: server.args ?? [] });
278
+ servers.push(report);
279
+ if (report.status === 'changed' && report.after) {
280
+ pasteLines.push(server.header, `command = ${tomlString(report.after.command)}`, `args = [${report.after.args.map(tomlString).join(', ')}]`, '');
281
+ }
282
+ }
283
+ const report = { target, servers, written: false };
284
+ if (pasteLines.length > 0)
285
+ report.pasteLines = pasteLines.slice(0, -1);
286
+ return report;
287
+ }
288
+ function quoteArg(arg) {
289
+ return /^[\w@%+=:,./\\-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, '\\"')}"`;
290
+ }
291
+ /** A command line for display: quoted where needed, secrets redacted, invisible characters escaped. */
292
+ export function formatLaunch(launch, max = 120) {
293
+ const text = [launch.command, ...launch.args].map(quoteArg).join(' ');
294
+ return truncate(printable(redactSecrets(text)), max);
295
+ }
296
+ function inside(base, file) {
297
+ const rel = path.relative(base, file);
298
+ if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
299
+ return null;
300
+ return rel.split(path.sep).join('/');
301
+ }
302
+ /** Relative to the working directory when inside it, '~/...' inside the home directory, else absolute; forward slashes. */
303
+ export function shownPath(file) {
304
+ const inCwd = inside(process.cwd(), file);
305
+ if (inCwd !== null)
306
+ return inCwd;
307
+ const inHome = inside(homeDir(), file);
308
+ return inHome !== null ? `~/${inHome}` : file.split(path.sep).join('/');
309
+ }
310
+ /** Client names to restart, from the labels of the configs that were written. */
311
+ export function clientsToRestart(reports) {
312
+ const names = reports.filter((r) => r.written).map((r) => r.target.label.replace(/\s*\(.*\)$/, ''));
313
+ return [...new Set(names)];
314
+ }
315
+ function joinNames(names) {
316
+ if (names.length <= 1)
317
+ return names.join('');
318
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
319
+ }
320
+ function plural(n, word) {
321
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
322
+ }
323
+ /** Parses the options wrap and unwrap share (plus wrap's --lock and --mode when allowed). Throws UsageError. */
324
+ export function parseTargetArgs(argv, withProxyOptions) {
325
+ const options = {
326
+ client: { type: 'string', multiple: true },
327
+ config: { type: 'string', multiple: true },
328
+ 'dry-run': { type: 'boolean' },
329
+ force: { type: 'boolean' },
330
+ lock: { type: 'string' },
331
+ mode: { type: 'string' },
332
+ };
333
+ let values;
334
+ try {
335
+ ({ values } = parseArgs({ args: argv, options, strict: true, allowPositionals: false }));
336
+ }
337
+ catch (error) {
338
+ throw new UsageError(error instanceof Error ? error.message : String(error));
339
+ }
340
+ if (!withProxyOptions) {
341
+ const extra = ['lock', 'mode'].find((key) => values[key] !== undefined);
342
+ if (extra)
343
+ throw new UsageError(`Unknown option '--${extra}'`);
344
+ }
345
+ const parsed = {
346
+ clients: values.client ?? [],
347
+ configs: values.config ?? [],
348
+ dryRun: values['dry-run'] === true,
349
+ force: values.force === true,
350
+ };
351
+ if (typeof values.lock === 'string')
352
+ parsed.lock = values.lock;
353
+ if (typeof values.mode === 'string')
354
+ parsed.mode = values.mode;
355
+ return parsed;
356
+ }
357
+ /** Prints the per-config report shared by wrap and unwrap. */
358
+ export function printReports(reports, verbs, dryRun) {
359
+ const c = palette();
360
+ const out = (line = '') => process.stdout.write(`${line}\n`);
361
+ for (const report of reports) {
362
+ const { target } = report;
363
+ out(`${c.bold(target.label)} ${c.dim(shownPath(target.path))}`);
364
+ if (report.servers.length === 0 && !report.error)
365
+ out(` ${c.dim('no servers')}`);
366
+ const width = Math.min(28, Math.max(4, ...report.servers.map((s) => printable(s.name).length)));
367
+ for (const server of report.servers) {
368
+ const name = printable(server.name).padEnd(width);
369
+ const where = server.project !== undefined ? c.dim(` (project ${printable(server.project)})`) : '';
370
+ if (server.status === 'changed') {
371
+ const saved = report.written || (dryRun && !report.error) || target.format === 'toml';
372
+ const verb = target.format === 'toml' ? 'paste' : saved ? (report.written ? verbs.done : verbs.planned) : 'not saved';
373
+ const color = saved ? c.green : c.yellow;
374
+ out(` ${color(verb.padEnd(10))} ${name} ${server.detail}${where}`);
375
+ }
376
+ else {
377
+ const label = server.status === 'unchanged' ? c.dim('unchanged'.padEnd(10)) : c.yellow('skipped'.padEnd(10));
378
+ out(` ${label} ${name} ${c.dim(server.detail)}${where}`);
379
+ }
380
+ }
381
+ if (report.error)
382
+ out(` ${c.red('error')} ${report.error}`);
383
+ if (report.backup)
384
+ out(` ${c.dim(`backup: ${shownPath(report.backup)}`)}`);
385
+ if (report.written && target.project) {
386
+ out(` ${c.yellow('note')} this project file is usually committed; the new command holds paths from this machine`);
387
+ }
388
+ if (report.pasteLines) {
389
+ out(` ${c.yellow('not edited')} toolmoat never rewrites TOML. Replace the command and args lines of these tables:`);
390
+ for (const line of report.pasteLines)
391
+ out(line ? ` ${line}` : '');
392
+ }
393
+ out();
394
+ }
395
+ }
396
+ /** Counts the JSON-config servers that were (or, in a dry run, would be) changed. TOML changes are only printed. */
397
+ export function countChanged(reports, dryRun) {
398
+ let count = 0;
399
+ for (const report of reports) {
400
+ if (report.target.format !== 'json' || !(report.written || (dryRun && !report.error)))
401
+ continue;
402
+ count += report.servers.filter((s) => s.status === 'changed').length;
403
+ }
404
+ return count;
405
+ }
406
+ /** Processes the selected targets; missing explicit files become error reports, missing default ones are dropped. */
407
+ export function processTargets(targets, jsonTransform, tomlTransform, options) {
408
+ const reports = [];
409
+ for (const target of targets) {
410
+ if (!fs.existsSync(target.path)) {
411
+ if (target.explicit)
412
+ reports.push({ target, servers: [], written: false, error: 'file not found' });
413
+ continue;
414
+ }
415
+ reports.push(target.format === 'toml' ? processTomlTarget(target, tomlTransform) : processJsonTarget(target, jsonTransform, options));
416
+ }
417
+ return reports;
418
+ }
419
+ export async function run(argv) {
420
+ let args;
421
+ let selection;
422
+ const cwd = process.cwd();
423
+ try {
424
+ args = parseTargetArgs(argv, true);
425
+ if (args.mode !== undefined && args.mode !== 'enforce' && args.mode !== 'warn') {
426
+ throw new UsageError(`--mode must be enforce or warn, not '${args.mode}'`);
427
+ }
428
+ selection = selectTargets(args.clients, args.configs, cwd);
429
+ }
430
+ catch (error) {
431
+ if (!(error instanceof UsageError))
432
+ throw error;
433
+ process.stderr.write(`toolmoat wrap: ${error.message}\n\n${help}\n`);
434
+ return 2;
435
+ }
436
+ const cliPath = currentCliPath();
437
+ if (!fs.existsSync(cliPath)) {
438
+ process.stderr.write(`toolmoat wrap: cannot find toolmoat's cli.js at ${cliPath}; run wrap from the installed package\n`);
439
+ return 1;
440
+ }
441
+ const launcher = launcherFor(cliPath);
442
+ const proxyOptions = {};
443
+ if (args.lock)
444
+ proxyOptions.lock = path.resolve(cwd, expandHome(args.lock));
445
+ if (args.mode === 'enforce' || args.mode === 'warn')
446
+ proxyOptions.mode = args.mode;
447
+ const reports = selection.targets.flatMap((target) => processTargets([target], wrapTransform(launcher, proxyOptions, target.scope), (name, launch) => ({ name, ...planWrap(scopedName(target.scope, name), launch, launcher, proxyOptions) }), { dryRun: args.dryRun, force: args.force }));
448
+ if (reports.some((r) => r.servers.some((s) => s.status === 'changed'))) {
449
+ const shape = wrapLaunch('<server>', { command: '<original command>', args: [] }, launcher, proxyOptions);
450
+ const text = [shape.command, ...shape.args].map((arg) => (arg.startsWith('<') ? arg : quoteArg(arg))).join(' ');
451
+ process.stdout.write(`${palette().dim(`Wrapped servers start as: ${text}`)}\n\n`);
452
+ }
453
+ const code = summarize(reports, selection, args.dryRun, 'wrap');
454
+ for (const clash of nameClashes(reports)) {
455
+ const programs = clash.programs.map((p) => truncate(printable(p), 60)).join(', ');
456
+ process.stdout.write(`${palette().yellow('note')} '${truncate(printable(clash.name), 60)}' names servers that run different programs (${programs}). The proxy keeps one lockfile entry per name, so they would hide each other's tools: change the value after --name in all but one of them.\n`);
457
+ }
458
+ return code;
459
+ }
460
+ /** Names shared by newly wrapped servers that run different programs, with those programs. */
461
+ export function nameClashes(reports) {
462
+ const byName = new Map();
463
+ for (const report of reports) {
464
+ if (report.error)
465
+ continue;
466
+ for (const server of report.servers) {
467
+ if (server.status !== 'changed' || !server.original || !server.after)
468
+ continue;
469
+ // Compare the --name the proxy will actually use, which carries the client and project scope.
470
+ const proxyName = server.after.args[server.after.args.indexOf('--name') + 1] ?? server.name;
471
+ const programs = byName.get(proxyName) ?? new Set();
472
+ programs.add(deriveServerName(server.original.command, server.original.args));
473
+ byName.set(proxyName, programs);
474
+ }
475
+ }
476
+ return [...byName].filter(([, programs]) => programs.size > 1).map(([name, programs]) => ({ name, programs: [...programs] }));
477
+ }
478
+ /** Prints the reports and the closing summary, and returns the exit code. */
479
+ export function summarize(reports, selection, dryRun, verb) {
480
+ const c = palette();
481
+ const out = (line = '') => process.stdout.write(`${line}\n`);
482
+ const words = verb === 'wrap' ? { done: 'wrapped', planned: 'would wrap' } : { done: 'unwrapped', planned: 'would unwrap' };
483
+ printReports(reports, words, dryRun);
484
+ if (selection.projectHints.length > 0) {
485
+ out(c.dim(`Project configs left alone (name them with --config or --client to ${verb} them):`));
486
+ for (const hint of selection.projectHints)
487
+ out(c.dim(` ${hint.label} ${shownPath(hint.path)}`));
488
+ out();
489
+ }
490
+ if (reports.length === 0) {
491
+ out(selection.projectHints.length > 0 ? `No user-level MCP client configs found.` : 'No MCP client configs found.');
492
+ return selection.projectHints.length > 0 ? 0 : 1;
493
+ }
494
+ const changed = countChanged(reports, dryRun);
495
+ const errors = reports.filter((r) => r.error).length;
496
+ const written = reports.filter((r) => r.written).length;
497
+ const configsTouched = dryRun ? reports.filter((r) => !r.error && r.target.format === 'json' && r.servers.some((s) => s.status === 'changed')).length : written;
498
+ const pasted = reports.filter((r) => r.pasteLines).length;
499
+ const pastedServers = reports.reduce((n, r) => n + (r.pasteLines ? r.servers.filter((s) => s.status === 'changed').length : 0), 0);
500
+ if (dryRun) {
501
+ out(`Dry run: would ${verb} ${plural(changed, 'server')} in ${plural(configsTouched, 'config')}. Nothing was written.`);
502
+ }
503
+ else if (written > 0) {
504
+ out(`${verb === 'wrap' ? 'Wrapped' : 'Unwrapped'} ${plural(changed, 'server')} in ${plural(written, 'config')}.`);
505
+ }
506
+ else {
507
+ out(pasted === 0 ? `Nothing to ${verb}.` : `Nothing to ${verb} in the JSON configs.`);
508
+ }
509
+ if (pasted > 0) {
510
+ out(`Codex: ${plural(pastedServers, 'server')} to ${verb} by hand; paste the lines shown above into ${pasted === 1 ? 'its config.toml' : 'those config.toml files'}.`);
511
+ }
512
+ const restart = clientsToRestart(reports);
513
+ if (restart.length > 0)
514
+ out(`Restart ${joinNames(restart)} so ${restart.length === 1 ? 'it starts' : 'they start'} the servers ${verb === 'wrap' ? 'through toolmoat' : 'directly again'}.`);
515
+ if (errors > 0)
516
+ out(c.red(`${plural(errors, 'config')} could not be processed; see the errors above.`));
517
+ return errors > 0 && changed === 0 && pastedServers === 0 ? 1 : 0;
518
+ }
@@ -0,0 +1,20 @@
1
+ export * from './types.js';
2
+ export { allRules, getRule } from './rules/index.js';
3
+ export { scanFile, scanFiles, sortFindings, countBySeverity, isTestPath } from './scan/engine.js';
4
+ export type { EngineOptions } from './scan/engine.js';
5
+ export { parseIgnoreFile, emptyIgnore } from './scan/ignore.js';
6
+ export type { IgnoreConfig } from './scan/ignore.js';
7
+ export { discover, classifyFile } from './scan/discover.js';
8
+ export type { DiscoverOptions, DiscoverResult } from './scan/discover.js';
9
+ export { runScan } from './commands/scan.js';
10
+ export type { ScanOptions, ScanOutcome } from './commands/scan.js';
11
+ export { renderReport, renderText, renderJson, renderSarif, buildSarif, renderMarkdown, REPORT_FORMATS } from './report/index.js';
12
+ export type { ReportFormat, ReportOptions } from './report/index.js';
13
+ export { runProxy } from './proxy/proxy.js';
14
+ export type { ProxyOptions, RunProxyOptions } from './proxy/proxy.js';
15
+ export { Firewall } from './proxy/firewall.js';
16
+ export { readLockfile, writeLockfile, defaultLockPath } from './proxy/lockfile.js';
17
+ export type { Lockfile, ServerEntry, PinnedTool, PendingTool } from './proxy/lockfile.js';
18
+ export { findSecrets, redactSecrets, SECRET_PATTERNS } from './patterns/secrets.js';
19
+ export { parseToml } from './util/toml-lite.js';
20
+ export { VERSION } from './version.js';
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ export * from './types.js';
2
+ export { allRules, getRule } from './rules/index.js';
3
+ export { scanFile, scanFiles, sortFindings, countBySeverity, isTestPath } from './scan/engine.js';
4
+ export { parseIgnoreFile, emptyIgnore } from './scan/ignore.js';
5
+ export { discover, classifyFile } from './scan/discover.js';
6
+ export { runScan } from './commands/scan.js';
7
+ export { renderReport, renderText, renderJson, renderSarif, buildSarif, renderMarkdown, REPORT_FORMATS } from './report/index.js';
8
+ export { runProxy } from './proxy/proxy.js';
9
+ export { Firewall } from './proxy/firewall.js';
10
+ export { readLockfile, writeLockfile, defaultLockPath } from './proxy/lockfile.js';
11
+ export { findSecrets, redactSecrets, SECRET_PATTERNS } from './patterns/secrets.js';
12
+ export { parseToml } from './util/toml-lite.js';
13
+ export { VERSION } from './version.js';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared indicator lists for the dangerous-code rules. Other modules (the
3
+ * config scanner, the proxy) may import these so detection stays consistent.
4
+ *
5
+ * Every regex must be linear-time: no nested quantifiers and bounded
6
+ * repetition only. Path patterns accept either "/" or "\\" as the separator
7
+ * so they fire on POSIX and Windows spellings alike, and they match the
8
+ * distinctive tail of a path so a leading "~", "$HOME" or "C:\\Users\\x" is
9
+ * not required.
10
+ */
11
+ import type { Severity } from '../types.js';
12
+ export interface Indicator {
13
+ id: string;
14
+ /** Human-readable name used in finding messages. */
15
+ name: string;
16
+ /** Linear-time matcher. */
17
+ regex: RegExp;
18
+ /** Optional default severity contribution for callers that want one. */
19
+ severity?: Severity;
20
+ }
21
+ /**
22
+ * Credential and secret files whose contents are valuable to an attacker:
23
+ * SSH keys, cloud credentials, package registry tokens and so on.
24
+ */
25
+ export declare const SENSITIVE_CRED_PATHS: readonly Indicator[];
26
+ /**
27
+ * AI-agent and developer-tool config files that store API keys, OAuth tokens
28
+ * or that, once rewritten, make an injection persistent. The config scanner
29
+ * reuses this list for its own path matching.
30
+ */
31
+ export declare const AGENT_CONFIG_PATHS: readonly Indicator[];
32
+ /**
33
+ * Hosts and URL shapes used to exfiltrate stolen data: webhook sinks, request
34
+ * bins, tunnels, out-of-band interaction services and anonymous file drops.
35
+ */
36
+ export declare const EXFIL_HOSTS: readonly Indicator[];
37
+ /**
38
+ * Living-off-the-land binaries abused to download and run payloads on Windows.
39
+ * Exported as plain names for other modules that want to match them.
40
+ */
41
+ export declare const LOLBINS: readonly string[];