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.
- package/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/clients/configs.d.ts +124 -0
- package/dist/clients/configs.js +403 -0
- package/dist/commands/approve.d.ts +15 -0
- package/dist/commands/approve.js +128 -0
- package/dist/commands/diff.d.ts +6 -0
- package/dist/commands/diff.js +114 -0
- package/dist/commands/pins.d.ts +18 -0
- package/dist/commands/pins.js +88 -0
- package/dist/commands/proxy.d.ts +24 -0
- package/dist/commands/proxy.js +144 -0
- package/dist/commands/rules.d.ts +15 -0
- package/dist/commands/rules.js +173 -0
- package/dist/commands/scan.d.ts +57 -0
- package/dist/commands/scan.js +323 -0
- package/dist/commands/unwrap.d.ts +5 -0
- package/dist/commands/unwrap.js +69 -0
- package/dist/commands/wrap.d.ts +131 -0
- package/dist/commands/wrap.js +518 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +13 -0
- package/dist/patterns/indicators.d.ts +41 -0
- package/dist/patterns/indicators.js +85 -0
- package/dist/patterns/secrets.d.ts +20 -0
- package/dist/patterns/secrets.js +70 -0
- package/dist/proxy/audit.d.ts +53 -0
- package/dist/proxy/audit.js +79 -0
- package/dist/proxy/canonical.d.ts +13 -0
- package/dist/proxy/canonical.js +34 -0
- package/dist/proxy/diff.d.ts +22 -0
- package/dist/proxy/diff.js +124 -0
- package/dist/proxy/fingerprint.d.ts +34 -0
- package/dist/proxy/fingerprint.js +140 -0
- package/dist/proxy/firewall.d.ts +132 -0
- package/dist/proxy/firewall.js +665 -0
- package/dist/proxy/framing.d.ts +45 -0
- package/dist/proxy/framing.js +86 -0
- package/dist/proxy/lockfile.d.ts +58 -0
- package/dist/proxy/lockfile.js +258 -0
- package/dist/proxy/names.d.ts +27 -0
- package/dist/proxy/names.js +175 -0
- package/dist/proxy/policy.d.ts +72 -0
- package/dist/proxy/policy.js +86 -0
- package/dist/proxy/proxy.d.ts +18 -0
- package/dist/proxy/proxy.js +237 -0
- package/dist/proxy/spawn.d.ts +51 -0
- package/dist/proxy/spawn.js +110 -0
- package/dist/report/index.d.ts +35 -0
- package/dist/report/index.js +25 -0
- package/dist/report/json.d.ts +22 -0
- package/dist/report/json.js +40 -0
- package/dist/report/markdown.d.ts +12 -0
- package/dist/report/markdown.js +87 -0
- package/dist/report/sarif.d.ts +89 -0
- package/dist/report/sarif.js +142 -0
- package/dist/report/text.d.ts +16 -0
- package/dist/report/text.js +206 -0
- package/dist/rules/code.d.ts +15 -0
- package/dist/rules/code.js +729 -0
- package/dist/rules/config.d.ts +3 -0
- package/dist/rules/config.js +2164 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.js +8 -0
- package/dist/rules/secrets.d.ts +2 -0
- package/dist/rules/secrets.js +108 -0
- package/dist/rules/text.d.ts +2 -0
- package/dist/rules/text.js +1698 -0
- package/dist/scan/discover.d.ts +37 -0
- package/dist/scan/discover.js +525 -0
- package/dist/scan/engine.d.ts +24 -0
- package/dist/scan/engine.js +111 -0
- package/dist/scan/ignore.d.ts +28 -0
- package/dist/scan/ignore.js +46 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +25 -0
- package/dist/util/colors.d.ts +12 -0
- package/dist/util/colors.js +24 -0
- package/dist/util/frontmatter.d.ts +20 -0
- package/dist/util/frontmatter.js +136 -0
- package/dist/util/glob.d.ts +7 -0
- package/dist/util/glob.js +44 -0
- package/dist/util/hash.d.ts +3 -0
- package/dist/util/hash.js +24 -0
- package/dist/util/jsonc.d.ts +14 -0
- package/dist/util/jsonc.js +113 -0
- package/dist/util/paths.d.ts +9 -0
- package/dist/util/paths.js +37 -0
- package/dist/util/text.d.ts +29 -0
- package/dist/util/text.js +96 -0
- package/dist/util/toml-lite.d.ts +40 -0
- package/dist/util/toml-lite.js +507 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +57 -4
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isRecord, parseJsonc, stripComments } from '../util/jsonc.js';
|
|
5
|
+
import { appDataDir, homeDir } from '../util/paths.js';
|
|
6
|
+
import { joinKeyPath, parseToml } from '../util/toml-lite.js';
|
|
7
|
+
const MIB = 1024 * 1024;
|
|
8
|
+
export const CLIENT_CONFIGS = [
|
|
9
|
+
{
|
|
10
|
+
id: 'claude-desktop',
|
|
11
|
+
client: 'claude-desktop',
|
|
12
|
+
label: 'Claude Desktop',
|
|
13
|
+
scope: 'user',
|
|
14
|
+
format: 'json',
|
|
15
|
+
serversKeys: ['mcpServers'],
|
|
16
|
+
resolve: () => path.join(appDataDir(), 'Claude', 'claude_desktop_config.json'),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'claude-code-user',
|
|
20
|
+
client: 'claude-code',
|
|
21
|
+
label: 'Claude Code (user)',
|
|
22
|
+
scope: 'user',
|
|
23
|
+
format: 'json',
|
|
24
|
+
serversKeys: ['mcpServers', 'projects.*.mcpServers'],
|
|
25
|
+
maxBytes: 20 * MIB,
|
|
26
|
+
resolve: () => path.join(homeDir(), '.claude.json'),
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'claude-code-project',
|
|
30
|
+
client: 'claude-code',
|
|
31
|
+
label: 'Claude Code (project .mcp.json)',
|
|
32
|
+
scope: 'project',
|
|
33
|
+
format: 'json',
|
|
34
|
+
serversKeys: ['mcpServers'],
|
|
35
|
+
resolve: (cwd) => path.join(cwd, '.mcp.json'),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'cursor-user',
|
|
39
|
+
client: 'cursor',
|
|
40
|
+
label: 'Cursor',
|
|
41
|
+
scope: 'user',
|
|
42
|
+
format: 'json',
|
|
43
|
+
serversKeys: ['mcpServers'],
|
|
44
|
+
resolve: () => path.join(homeDir(), '.cursor', 'mcp.json'),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'cursor-project',
|
|
48
|
+
client: 'cursor',
|
|
49
|
+
label: 'Cursor (project)',
|
|
50
|
+
scope: 'project',
|
|
51
|
+
format: 'json',
|
|
52
|
+
serversKeys: ['mcpServers'],
|
|
53
|
+
resolve: (cwd) => path.join(cwd, '.cursor', 'mcp.json'),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'vscode-user',
|
|
57
|
+
client: 'vscode',
|
|
58
|
+
label: 'VS Code',
|
|
59
|
+
scope: 'user',
|
|
60
|
+
format: 'json',
|
|
61
|
+
serversKeys: ['servers'],
|
|
62
|
+
resolve: () => path.join(appDataDir(), 'Code', 'User', 'mcp.json'),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'vscode-project',
|
|
66
|
+
client: 'vscode',
|
|
67
|
+
label: 'VS Code (project)',
|
|
68
|
+
scope: 'project',
|
|
69
|
+
format: 'json',
|
|
70
|
+
serversKeys: ['servers'],
|
|
71
|
+
resolve: (cwd) => path.join(cwd, '.vscode', 'mcp.json'),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'windsurf',
|
|
75
|
+
client: 'windsurf',
|
|
76
|
+
label: 'Windsurf',
|
|
77
|
+
scope: 'user',
|
|
78
|
+
format: 'json',
|
|
79
|
+
serversKeys: ['mcpServers'],
|
|
80
|
+
resolve: () => path.join(homeDir(), '.codeium', 'windsurf', 'mcp_config.json'),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'codex',
|
|
84
|
+
client: 'codex',
|
|
85
|
+
label: 'Codex',
|
|
86
|
+
scope: 'user',
|
|
87
|
+
format: 'toml',
|
|
88
|
+
serversKeys: ['mcp_servers'],
|
|
89
|
+
resolve: () => path.join(homeDir(), '.codex', 'config.toml'),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'gemini-user',
|
|
93
|
+
client: 'gemini',
|
|
94
|
+
label: 'Gemini CLI',
|
|
95
|
+
scope: 'user',
|
|
96
|
+
format: 'json',
|
|
97
|
+
serversKeys: ['mcpServers'],
|
|
98
|
+
resolve: () => path.join(homeDir(), '.gemini', 'settings.json'),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'gemini-project',
|
|
102
|
+
client: 'gemini',
|
|
103
|
+
label: 'Gemini CLI (project)',
|
|
104
|
+
scope: 'project',
|
|
105
|
+
format: 'json',
|
|
106
|
+
serversKeys: ['mcpServers'],
|
|
107
|
+
resolve: (cwd) => path.join(cwd, '.gemini', 'settings.json'),
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
/** Normalised form of a path for equality checks and dedupe: resolved, and lower-cased on Windows. */
|
|
111
|
+
export function pathKey(p) {
|
|
112
|
+
const resolved = path.resolve(p);
|
|
113
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
114
|
+
}
|
|
115
|
+
/** The known client config at exactly this path, if any. */
|
|
116
|
+
export function findClientConfig(absPath, cwd) {
|
|
117
|
+
const key = pathKey(absPath);
|
|
118
|
+
return CLIENT_CONFIGS.find((location) => pathKey(location.resolve(cwd)) === key);
|
|
119
|
+
}
|
|
120
|
+
function compact(text) {
|
|
121
|
+
return text.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Configs selected by one --client value. An exact id or label picks that one
|
|
125
|
+
* config; a client family such as "cursor" picks the family's user-level configs.
|
|
126
|
+
*/
|
|
127
|
+
export function matchClientConfigs(query) {
|
|
128
|
+
const q = compact(query);
|
|
129
|
+
if (!q)
|
|
130
|
+
return [];
|
|
131
|
+
const exact = CLIENT_CONFIGS.filter((l) => compact(l.id) === q || compact(l.label) === q);
|
|
132
|
+
if (exact.length > 0)
|
|
133
|
+
return exact;
|
|
134
|
+
return CLIENT_CONFIGS.filter((l) => compact(l.client) === q && l.scope === 'user');
|
|
135
|
+
}
|
|
136
|
+
/** Values --client accepts, for usage messages. */
|
|
137
|
+
export function clientChoices() {
|
|
138
|
+
return [...new Set([...CLIENT_CONFIGS.map((l) => l.client), ...CLIENT_CONFIGS.map((l) => l.id)])];
|
|
139
|
+
}
|
|
140
|
+
/** Reads and parses a JSON or JSONC config. Throws an Error with a readable message. */
|
|
141
|
+
export function readJsonConfig(file) {
|
|
142
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
143
|
+
const source = text.startsWith('') ? text.slice(1) : text;
|
|
144
|
+
const data = source.trim() === '' ? {} : parseJsonc(source);
|
|
145
|
+
if (!isRecord(data))
|
|
146
|
+
throw new Error(`${file} is not valid JSON (or not a JSON object)`);
|
|
147
|
+
const stripped = stripComments(source);
|
|
148
|
+
return { path: file, text, data, hasComments: stripped !== source, lossyNumber: findLossyNumber(stripped) };
|
|
149
|
+
}
|
|
150
|
+
/** The first integer literal longer than 15 digits that a parse and re-serialise would round, or a number that overflows to null. */
|
|
151
|
+
function findLossyNumber(text) {
|
|
152
|
+
for (const match of text.matchAll(/"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g)) {
|
|
153
|
+
const token = match[0];
|
|
154
|
+
if (token.startsWith('"'))
|
|
155
|
+
continue;
|
|
156
|
+
if (!Number.isFinite(Number(token)))
|
|
157
|
+
return token;
|
|
158
|
+
if (/^-?\d{16,}$/.test(token) && String(Number(token)) !== token)
|
|
159
|
+
return token;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
/** Every server entry under the given keys, in file order. */
|
|
164
|
+
export function listServers(data, keys) {
|
|
165
|
+
const out = [];
|
|
166
|
+
const collect = (servers, section, project) => {
|
|
167
|
+
if (!isRecord(servers))
|
|
168
|
+
return;
|
|
169
|
+
for (const [name, server] of Object.entries(servers)) {
|
|
170
|
+
if (!isRecord(server))
|
|
171
|
+
continue;
|
|
172
|
+
out.push(project === undefined ? { name, section, server } : { name, section, project, server });
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
if (key !== 'projects.*.mcpServers') {
|
|
177
|
+
collect(data[key], key);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const projects = data.projects;
|
|
181
|
+
if (!isRecord(projects))
|
|
182
|
+
continue;
|
|
183
|
+
for (const [project, value] of Object.entries(projects)) {
|
|
184
|
+
if (isRecord(value))
|
|
185
|
+
collect(value.mcpServers, `projects[${JSON.stringify(project)}].mcpServers`, project);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
const REMOTE_TYPES = new Set(['http', 'sse', 'streamable-http', 'streamablehttp', 'ws', 'websocket']);
|
|
191
|
+
/** True for servers reached over the network (url, serverUrl, httpUrl or an http/sse type). */
|
|
192
|
+
export function isRemoteServer(server) {
|
|
193
|
+
if (typeof server.url === 'string' || typeof server.serverUrl === 'string' || typeof server.httpUrl === 'string')
|
|
194
|
+
return true;
|
|
195
|
+
return typeof server.type === 'string' && REMOTE_TYPES.has(server.type.toLowerCase()) && typeof server.command !== 'string';
|
|
196
|
+
}
|
|
197
|
+
/** The command line of a stdio server, or a reason it cannot be read as one. */
|
|
198
|
+
export function readLaunch(server) {
|
|
199
|
+
const { command, args } = server;
|
|
200
|
+
if (typeof command !== 'string' || command.trim() === '')
|
|
201
|
+
return { error: 'no command (not a stdio server)' };
|
|
202
|
+
if (args === undefined)
|
|
203
|
+
return { command, args: [] };
|
|
204
|
+
if (!Array.isArray(args) || !args.every((a) => typeof a === 'string')) {
|
|
205
|
+
return { error: 'args is not a list of strings' };
|
|
206
|
+
}
|
|
207
|
+
return { command, args: [...args] };
|
|
208
|
+
}
|
|
209
|
+
/** Options of `toolmoat proxy` that take a value, so a value such as "--" is not read as the separator. */
|
|
210
|
+
const PROXY_VALUE_FLAGS = new Set(['--name', '--lock', '--mode', '--on-new', '--audit-log', '--allow-secret']);
|
|
211
|
+
const PROXY_BOOLEAN_FLAGS = new Set(['--no-audit', '--log-args']);
|
|
212
|
+
function commandName(command) {
|
|
213
|
+
return path.basename(command.replace(/\\/g, '/')).toLowerCase().replace(/\.(cmd|exe|ps1|bat)$/, '');
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The original command line inside an toolmoat-wrapped one ([command, ...args]
|
|
217
|
+
* after the "--"), or null when the command line was not written by wrap. An
|
|
218
|
+
* empty array means the entry is wrapped but has nothing after "--".
|
|
219
|
+
* Recognised launchers: <node> <.../cli.js> proxy, npx -y toolmoat@<version> proxy, toolmoat proxy.
|
|
220
|
+
* Only options of `toolmoat proxy` may stand between "proxy" and "--", so
|
|
221
|
+
* another program's `node tool/cli.js proxy --stdio -- upstream` is not taken
|
|
222
|
+
* for a wrapped server (unwrap would otherwise rewrite it).
|
|
223
|
+
*/
|
|
224
|
+
export function splitWrapped(command, args) {
|
|
225
|
+
const at = args.indexOf('proxy');
|
|
226
|
+
if (at === -1)
|
|
227
|
+
return null;
|
|
228
|
+
const viaCli = at === 1 && /(?:^|[\\/])cli\.js$/i.test(args[0]);
|
|
229
|
+
const viaPackage = args.slice(0, at).some((arg) => /^toolmoat(?:@[^\s]{1,64})?$/.test(arg));
|
|
230
|
+
const viaBin = at === 0 && commandName(command) === 'toolmoat';
|
|
231
|
+
if (!viaCli && !viaPackage && !viaBin)
|
|
232
|
+
return null;
|
|
233
|
+
for (let i = at + 1; i < args.length; i++) {
|
|
234
|
+
const arg = args[i];
|
|
235
|
+
if (arg === '--')
|
|
236
|
+
return args.slice(i + 1);
|
|
237
|
+
if (PROXY_VALUE_FLAGS.has(arg))
|
|
238
|
+
i++;
|
|
239
|
+
else if (!PROXY_BOOLEAN_FLAGS.has(arg) && !PROXY_VALUE_FLAGS.has(arg.split('=')[0]))
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The command line a wrapped server really runs, or null when the launch is
|
|
246
|
+
* not wrapped by toolmoat (or wraps nothing). Rules that judge a server's
|
|
247
|
+
* command should look at this rather than at the toolmoat launcher.
|
|
248
|
+
*/
|
|
249
|
+
export function originalLaunch(launch) {
|
|
250
|
+
const inner = splitWrapped(launch.command, launch.args);
|
|
251
|
+
if (!inner || inner.length === 0)
|
|
252
|
+
return null;
|
|
253
|
+
return { command: inner[0], args: inner.slice(1) };
|
|
254
|
+
}
|
|
255
|
+
/** The stdio servers of a parsed JSON config with their command lines. */
|
|
256
|
+
export function stdioServers(data, keys) {
|
|
257
|
+
const out = [];
|
|
258
|
+
for (const entry of listServers(data, keys)) {
|
|
259
|
+
if (isRemoteServer(entry.server))
|
|
260
|
+
continue;
|
|
261
|
+
const launch = readLaunch(entry.server);
|
|
262
|
+
if (!('error' in launch))
|
|
263
|
+
out.push({ ...entry, launch });
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
/** Path of the one-time backup kept next to a config before toolmoat first changes it. */
|
|
268
|
+
export function backupPath(file) {
|
|
269
|
+
return `${file}.toolmoat.bak`;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Writes config.data back to config.path, keeping the original indentation,
|
|
273
|
+
* line endings, BOM and trailing newline. Creates "<file>.toolmoat.bak" the
|
|
274
|
+
* first time, writes a temp file in the same directory and renames it over the
|
|
275
|
+
* original, so a crash never leaves a half-written config. Refuses when the
|
|
276
|
+
* file changed on disk since it was read (clients rewrite their configs).
|
|
277
|
+
* Returns the backup path when this call created it.
|
|
278
|
+
*/
|
|
279
|
+
export function writeJsonConfig(config) {
|
|
280
|
+
const target = realTarget(config.path);
|
|
281
|
+
const current = fs.readFileSync(target, 'utf8');
|
|
282
|
+
if (current !== config.text) {
|
|
283
|
+
throw new Error(`${config.path} changed on disk while toolmoat was editing it; run the command again`);
|
|
284
|
+
}
|
|
285
|
+
const backup = backupPath(target);
|
|
286
|
+
let created = null;
|
|
287
|
+
if (!fs.existsSync(backup)) {
|
|
288
|
+
fs.copyFileSync(target, backup, fs.constants.COPYFILE_EXCL);
|
|
289
|
+
created = backup;
|
|
290
|
+
}
|
|
291
|
+
atomicWrite(target, serializeLike(config.data, config.text));
|
|
292
|
+
return { backup: created };
|
|
293
|
+
}
|
|
294
|
+
function realTarget(file) {
|
|
295
|
+
try {
|
|
296
|
+
return fs.realpathSync(file);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return file;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** JSON.stringify with the indentation, line endings, BOM and final newline of the original text. */
|
|
303
|
+
export function serializeLike(data, original) {
|
|
304
|
+
const bom = original.startsWith('') ? '' : '';
|
|
305
|
+
const eol = original.includes('\r\n') ? '\r\n' : '\n';
|
|
306
|
+
const trailing = /\n[ \t]*$/.test(original) || original.trim() === '' ? eol : '';
|
|
307
|
+
let json = JSON.stringify(data, null, detectIndent(original));
|
|
308
|
+
if (eol !== '\n')
|
|
309
|
+
json = json.replace(/\n/g, eol);
|
|
310
|
+
return `${bom}${json}${trailing}`;
|
|
311
|
+
}
|
|
312
|
+
function detectIndent(text) {
|
|
313
|
+
const body = text.trim();
|
|
314
|
+
if (body !== '' && !body.includes('\n'))
|
|
315
|
+
return 0;
|
|
316
|
+
const match = /\n([ \t]{1,16})\S/.exec(text);
|
|
317
|
+
if (!match)
|
|
318
|
+
return 2;
|
|
319
|
+
const indent = match[1];
|
|
320
|
+
return indent.startsWith('\t') ? '\t' : indent.length;
|
|
321
|
+
}
|
|
322
|
+
function atomicWrite(target, content) {
|
|
323
|
+
const mode = fs.statSync(target).mode & 0o777;
|
|
324
|
+
const temp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
|
|
325
|
+
try {
|
|
326
|
+
const fd = fs.openSync(temp, 'wx', mode);
|
|
327
|
+
try {
|
|
328
|
+
fs.writeFileSync(fd, content, 'utf8');
|
|
329
|
+
fs.fsyncSync(fd);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
fs.closeSync(fd);
|
|
333
|
+
}
|
|
334
|
+
if (process.platform !== 'win32')
|
|
335
|
+
fs.chmodSync(temp, mode);
|
|
336
|
+
renameWithRetry(temp, target);
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
fs.rmSync(temp, { force: true });
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Windows briefly locks files that editors, antivirus or the client itself have open. */
|
|
344
|
+
function renameWithRetry(from, to) {
|
|
345
|
+
for (let attempt = 0;; attempt++) {
|
|
346
|
+
try {
|
|
347
|
+
fs.renameSync(from, to);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
const code = error.code;
|
|
352
|
+
const transient = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
|
|
353
|
+
if (process.platform !== 'win32' || !transient || attempt >= 5)
|
|
354
|
+
throw error;
|
|
355
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * (attempt + 1));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Reads the servers of a Codex config.toml with toml-lite. wrap prints
|
|
361
|
+
* replacement lines for a server's own [mcp_servers.<name>] table, so servers
|
|
362
|
+
* defined inline or with dotted keys are marked unparsed. It never rewrites
|
|
363
|
+
* the file.
|
|
364
|
+
*/
|
|
365
|
+
export function readTomlServers(text) {
|
|
366
|
+
const doc = parseToml(text);
|
|
367
|
+
const table = doc.data.mcp_servers;
|
|
368
|
+
if (!isRecord(table))
|
|
369
|
+
return [];
|
|
370
|
+
const lines = text.split(/\r?\n/);
|
|
371
|
+
const servers = [];
|
|
372
|
+
for (const [name, value] of Object.entries(table)) {
|
|
373
|
+
if (!isRecord(value))
|
|
374
|
+
continue;
|
|
375
|
+
const line = doc.lines.get(joinKeyPath(['mcp_servers', name])) ?? 0;
|
|
376
|
+
const source = (lines[line - 1] ?? '').trim();
|
|
377
|
+
const server = { name, header: source, line };
|
|
378
|
+
const { command, args, url } = value;
|
|
379
|
+
if (typeof command === 'string')
|
|
380
|
+
server.command = command;
|
|
381
|
+
else if (command !== undefined)
|
|
382
|
+
server.unparsed = 'command is not a string';
|
|
383
|
+
if (Array.isArray(args) && args.every((a) => typeof a === 'string'))
|
|
384
|
+
server.args = [...args];
|
|
385
|
+
else if (args !== undefined)
|
|
386
|
+
server.unparsed = 'args is not a list of strings';
|
|
387
|
+
if (typeof url === 'string')
|
|
388
|
+
server.url = url;
|
|
389
|
+
if (!source.startsWith('['))
|
|
390
|
+
server.unparsed = 'it is defined inline or with dotted keys rather than in its own [mcp_servers.<name>] table';
|
|
391
|
+
else if (command === undefined && url === undefined && doc.errors.length > 0) {
|
|
392
|
+
server.unparsed = `config.toml has syntax toolmoat could not read (line ${doc.errors[0].line})`;
|
|
393
|
+
}
|
|
394
|
+
servers.push(server);
|
|
395
|
+
}
|
|
396
|
+
return servers;
|
|
397
|
+
}
|
|
398
|
+
/** Formats a string as a TOML value: a literal string when that is safe, else a basic string. */
|
|
399
|
+
export function tomlString(value) {
|
|
400
|
+
if (!/['\u0000-\u001f\u007f]/.test(value))
|
|
401
|
+
return `'${value}'`;
|
|
402
|
+
return JSON.stringify(value);
|
|
403
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PendingTool, ServerEntry } from '../proxy/lockfile.js';
|
|
2
|
+
export declare const help = "Usage: toolmoat approve <server> [--tool <tool>]... [--all] [--reset] [--lock <file>]\n\nAccepts tool changes that toolmoat proxy is holding for a server: the\npending definitions become the new pins. Without --tool every pending\nchange is approved. Review them first with toolmoat diff <server>.\nA running proxy picks up the approval within a second and asks the client to\nreload its tool list.\n\nOptions:\n --tool <tool> Approve only this tool (repeatable)\n --all Approve every pending change (the default without --tool)\n --reset Forget the server entirely; its tools are pinned again on the next run\n --lock <file> Lockfile (default: <toolmoat home>/lock.json)";
|
|
3
|
+
export interface ApproveResult {
|
|
4
|
+
approved: Array<{
|
|
5
|
+
tool: string;
|
|
6
|
+
reason: PendingTool['reason'];
|
|
7
|
+
}>;
|
|
8
|
+
missing: string[];
|
|
9
|
+
}
|
|
10
|
+
/** Moves pending definitions into the pins. Pure: returns the new entry and what happened. */
|
|
11
|
+
export declare function approvePending(entry: ServerEntry, tools: readonly string[] | 'all', now: string): {
|
|
12
|
+
entry: ServerEntry;
|
|
13
|
+
result: ApproveResult;
|
|
14
|
+
};
|
|
15
|
+
export declare function run(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { getOwn, setOwn } from '../proxy/canonical.js';
|
|
4
|
+
import { defaultLockPath, readLockfile, updateServer } from '../proxy/lockfile.js';
|
|
5
|
+
import { safeText } from '../proxy/names.js';
|
|
6
|
+
import { palette } from '../util/colors.js';
|
|
7
|
+
export const help = `Usage: toolmoat approve <server> [--tool <tool>]... [--all] [--reset] [--lock <file>]
|
|
8
|
+
|
|
9
|
+
Accepts tool changes that toolmoat proxy is holding for a server: the
|
|
10
|
+
pending definitions become the new pins. Without --tool every pending
|
|
11
|
+
change is approved. Review them first with toolmoat diff <server>.
|
|
12
|
+
A running proxy picks up the approval within a second and asks the client to
|
|
13
|
+
reload its tool list.
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--tool <tool> Approve only this tool (repeatable)
|
|
17
|
+
--all Approve every pending change (the default without --tool)
|
|
18
|
+
--reset Forget the server entirely; its tools are pinned again on the next run
|
|
19
|
+
--lock <file> Lockfile (default: <toolmoat home>/lock.json)`;
|
|
20
|
+
/** Moves pending definitions into the pins. Pure: returns the new entry and what happened. */
|
|
21
|
+
export function approvePending(entry, tools, now) {
|
|
22
|
+
const next = { ...entry, tools: { ...entry.tools }, pending: { ...entry.pending } };
|
|
23
|
+
const wanted = tools === 'all' ? Object.keys(entry.pending).sort() : [...new Set(tools)];
|
|
24
|
+
const result = { approved: [], missing: [] };
|
|
25
|
+
for (const tool of wanted) {
|
|
26
|
+
const pending = getOwn(next.pending, tool);
|
|
27
|
+
if (!pending) {
|
|
28
|
+
result.missing.push(tool);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
setOwn(next.tools, tool, { hash: pending.hash, definition: pending.definition, pinnedAt: now });
|
|
32
|
+
delete next.pending[tool];
|
|
33
|
+
result.approved.push({ tool, reason: pending.reason });
|
|
34
|
+
}
|
|
35
|
+
if (result.approved.length > 0)
|
|
36
|
+
next.updated = now;
|
|
37
|
+
return { entry: next, result };
|
|
38
|
+
}
|
|
39
|
+
export async function run(argv) {
|
|
40
|
+
let values;
|
|
41
|
+
let positionals;
|
|
42
|
+
try {
|
|
43
|
+
({ values, positionals } = parseArgs({
|
|
44
|
+
args: argv,
|
|
45
|
+
options: { tool: { type: 'string', multiple: true }, all: { type: 'boolean' }, reset: { type: 'boolean' }, lock: { type: 'string' } },
|
|
46
|
+
allowPositionals: true,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return usageError(error instanceof Error ? error.message : String(error));
|
|
51
|
+
}
|
|
52
|
+
const name = positionals[0];
|
|
53
|
+
if (name === undefined || positionals.length > 1)
|
|
54
|
+
return usageError('expected exactly one server name');
|
|
55
|
+
if (values.reset && (values.all || values.tool))
|
|
56
|
+
return usageError('--reset cannot be combined with --tool or --all');
|
|
57
|
+
if (values.all && values.tool)
|
|
58
|
+
return usageError('use either --all or --tool, not both');
|
|
59
|
+
const lockPath = path.resolve(values.lock ?? defaultLockPath());
|
|
60
|
+
try {
|
|
61
|
+
return values.reset ? reset(lockPath, name) : approve(lockPath, name, values.tool ?? 'all');
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
process.stderr.write(`toolmoat approve: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function approve(lockPath, name, tools) {
|
|
69
|
+
const c = palette(process.stdout);
|
|
70
|
+
const shownName = safeText(name, 100);
|
|
71
|
+
let result = undefined;
|
|
72
|
+
let pendingBefore = [];
|
|
73
|
+
updateServer(lockPath, name, (current) => {
|
|
74
|
+
if (!current)
|
|
75
|
+
return current;
|
|
76
|
+
pendingBefore = Object.keys(current.pending).sort();
|
|
77
|
+
const outcome = approvePending(current, tools, new Date().toISOString());
|
|
78
|
+
result = outcome.result;
|
|
79
|
+
return outcome.entry;
|
|
80
|
+
});
|
|
81
|
+
if (!result) {
|
|
82
|
+
process.stderr.write(`toolmoat approve: no server named '${shownName}' in ${lockPath}${knownServers(lockPath)}\n`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
for (const tool of result.missing) {
|
|
86
|
+
const others = pendingBefore.length > 0 ? ` (pending: ${pendingBefore.map((t) => safeText(t, 60)).join(', ')})` : '';
|
|
87
|
+
process.stderr.write(`toolmoat approve: no pending change for tool '${safeText(tool, 100)}' on ${shownName}${others}\n`);
|
|
88
|
+
}
|
|
89
|
+
if (result.approved.length === 0) {
|
|
90
|
+
if (result.missing.length === 0)
|
|
91
|
+
process.stderr.write(`toolmoat approve: nothing to approve for ${shownName}; every tool matches its pin\n`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
const lines = [c.green(`Approved ${result.approved.length} tool change${result.approved.length === 1 ? '' : 's'} for ${shownName}:`)];
|
|
95
|
+
for (const item of result.approved)
|
|
96
|
+
lines.push(` ${safeText(item.tool, 100)} (${item.reason})`);
|
|
97
|
+
lines.push(c.dim(`Pins updated in ${lockPath}. A running proxy reloads them automatically; otherwise restart the MCP server.`));
|
|
98
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
99
|
+
return result.missing.length > 0 ? 1 : 0;
|
|
100
|
+
}
|
|
101
|
+
function reset(lockPath, name) {
|
|
102
|
+
let removed = undefined;
|
|
103
|
+
updateServer(lockPath, name, (current) => {
|
|
104
|
+
removed = current;
|
|
105
|
+
return undefined;
|
|
106
|
+
});
|
|
107
|
+
if (!removed) {
|
|
108
|
+
process.stderr.write(`toolmoat approve: no server named '${safeText(name, 100)}' in ${lockPath}${knownServers(lockPath)}\n`);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
const tools = Object.keys(removed.tools).length;
|
|
112
|
+
const pending = Object.keys(removed.pending).length;
|
|
113
|
+
process.stdout.write(`Forgot ${safeText(name, 100)} (${tools} pinned tool${tools === 1 ? '' : 's'}, ${pending} pending). Its tools will be pinned again the next time it runs.\n`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
function knownServers(lockPath) {
|
|
117
|
+
try {
|
|
118
|
+
const names = Object.keys(readLockfile(lockPath).servers).sort();
|
|
119
|
+
return names.length > 0 ? ` (known: ${names.map((n) => safeText(n, 60)).join(', ')})` : '';
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function usageError(message) {
|
|
126
|
+
process.stderr.write(`toolmoat approve: ${message}\nUsage: toolmoat approve <server> [--tool <tool>]... [--all] [--reset] [--lock <file>]\n`);
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ServerEntry } from '../proxy/lockfile.js';
|
|
2
|
+
import type { Palette } from '../util/colors.js';
|
|
3
|
+
export declare const help = "Usage: toolmoat diff <server> [--tool <tool>] [--lock <file>]\n\nShows the tool changes toolmoat proxy is holding for a server: why each tool\nwas held, what the scanner found, and a line diff between the pinned\ndefinition and the new one (new or flagged tools are shown in full).\n\nOptions:\n --tool <tool> Only this tool\n --lock <file> Lockfile (default: <toolmoat home>/lock.json)";
|
|
4
|
+
export declare function run(argv: string[]): Promise<number>;
|
|
5
|
+
/** The review block for one pending tool: reason, findings and a diff against the pin. */
|
|
6
|
+
export declare function renderPending(entry: ServerEntry, tool: string, c: Palette): string[];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { getOwn } from '../proxy/canonical.js';
|
|
4
|
+
import { diffLines, hunks, prettyDefinition } from '../proxy/diff.js';
|
|
5
|
+
import { defaultLockPath, readLockfile } from '../proxy/lockfile.js';
|
|
6
|
+
import { safeText, shellQuote } from '../proxy/names.js';
|
|
7
|
+
import { palette } from '../util/colors.js';
|
|
8
|
+
export const help = `Usage: toolmoat diff <server> [--tool <tool>] [--lock <file>]
|
|
9
|
+
|
|
10
|
+
Shows the tool changes toolmoat proxy is holding for a server: why each tool
|
|
11
|
+
was held, what the scanner found, and a line diff between the pinned
|
|
12
|
+
definition and the new one (new or flagged tools are shown in full).
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--tool <tool> Only this tool
|
|
16
|
+
--lock <file> Lockfile (default: <toolmoat home>/lock.json)`;
|
|
17
|
+
const REASON_TEXT = {
|
|
18
|
+
changed: 'definition changed since it was pinned',
|
|
19
|
+
new: 'tool appeared after the server was pinned',
|
|
20
|
+
flagged: 'definition flagged by the scanner',
|
|
21
|
+
};
|
|
22
|
+
export async function run(argv) {
|
|
23
|
+
let values;
|
|
24
|
+
let positionals;
|
|
25
|
+
try {
|
|
26
|
+
({ values, positionals } = parseArgs({ args: argv, options: { tool: { type: 'string' }, lock: { type: 'string' } }, allowPositionals: true }));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return usageError(error instanceof Error ? error.message : String(error));
|
|
30
|
+
}
|
|
31
|
+
const name = positionals[0];
|
|
32
|
+
if (name === undefined || positionals.length > 1)
|
|
33
|
+
return usageError('expected exactly one server name');
|
|
34
|
+
const lockPath = path.resolve(values.lock ?? defaultLockPath());
|
|
35
|
+
let lock;
|
|
36
|
+
try {
|
|
37
|
+
lock = readLockfile(lockPath);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
process.stderr.write(`toolmoat diff: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
const entry = getOwn(lock.servers, name);
|
|
44
|
+
if (!entry) {
|
|
45
|
+
process.stderr.write(`toolmoat diff: no server named '${safeText(name, 100)}' in ${lockPath}${knownServers(lock)}\n`);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
const c = palette(process.stdout);
|
|
49
|
+
let tools = Object.keys(entry.pending).sort();
|
|
50
|
+
if (values.tool !== undefined) {
|
|
51
|
+
if (!getOwn(entry.pending, values.tool)) {
|
|
52
|
+
const pinned = getOwn(entry.tools, values.tool) ? ' (it is pinned and unchanged)' : '';
|
|
53
|
+
process.stdout.write(`No pending change for tool '${safeText(values.tool, 100)}' on ${safeText(name, 100)}${pinned}.\n`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
tools = [values.tool];
|
|
57
|
+
}
|
|
58
|
+
if (tools.length === 0) {
|
|
59
|
+
process.stdout.write(`No pending changes for ${safeText(name, 100)}: all ${Object.keys(entry.tools).length} tools match their pins.\n`);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
const out = [c.bold(`${safeText(name, 100)}: ${tools.length} pending tool change${tools.length === 1 ? '' : 's'}`), c.dim(`lockfile: ${lockPath}`)];
|
|
63
|
+
for (const tool of tools)
|
|
64
|
+
out.push('', ...renderPending(entry, tool, c));
|
|
65
|
+
out.push('', `Approve with: ${c.cyan(`toolmoat approve ${shellQuote(name)}${values.tool !== undefined ? ` --tool ${shellQuote(values.tool)}` : ''}`)}`);
|
|
66
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
/** The review block for one pending tool: reason, findings and a diff against the pin. */
|
|
70
|
+
export function renderPending(entry, tool, c) {
|
|
71
|
+
const pending = getOwn(entry.pending, tool);
|
|
72
|
+
const pinned = getOwn(entry.tools, tool);
|
|
73
|
+
const out = [`${c.bold(safeText(tool, 100))} ${c.yellow(pending.reason)} ${c.dim(`${REASON_TEXT[pending.reason]}, detected ${pending.detectedAt || 'unknown'}`)}`];
|
|
74
|
+
if (pending.findings.length > 0) {
|
|
75
|
+
out.push(' findings:');
|
|
76
|
+
for (const f of pending.findings) {
|
|
77
|
+
const severity = f.severity === 'critical' || f.severity === 'high' ? c.red(f.severity) : c.yellow(f.severity);
|
|
78
|
+
out.push(` ${severity} ${f.ruleId} ${safeText(f.message, 200)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const after = prettyDefinition(pending.definition).map((line) => safeText(line));
|
|
82
|
+
if (!pinned) {
|
|
83
|
+
out.push(` ${c.dim('definition (not pinned before):')}`);
|
|
84
|
+
for (const line of after)
|
|
85
|
+
out.push(c.green(` + ${line}`));
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
const before = prettyDefinition(pinned.definition).map((line) => safeText(line));
|
|
89
|
+
out.push(c.dim(` --- pinned ${pinned.pinnedAt}`), c.dim(` +++ pending ${pending.detectedAt}`));
|
|
90
|
+
const changes = hunks(diffLines(before, after));
|
|
91
|
+
if (changes.length === 0)
|
|
92
|
+
out.push(c.dim(' (no visible difference; the definitions differ only in key order or encoding)'));
|
|
93
|
+
changes.forEach((hunk, index) => {
|
|
94
|
+
if (index > 0)
|
|
95
|
+
out.push(c.dim(' ...'));
|
|
96
|
+
for (const op of hunk.ops) {
|
|
97
|
+
if (op.type === 'removed')
|
|
98
|
+
out.push(c.red(` - ${op.line}`));
|
|
99
|
+
else if (op.type === 'added')
|
|
100
|
+
out.push(c.green(` + ${op.line}`));
|
|
101
|
+
else
|
|
102
|
+
out.push(c.dim(` ${op.line}`));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
function knownServers(lock) {
|
|
108
|
+
const names = Object.keys(lock.servers).sort();
|
|
109
|
+
return names.length > 0 ? ` (known: ${names.map((n) => safeText(n, 60)).join(', ')})` : '';
|
|
110
|
+
}
|
|
111
|
+
function usageError(message) {
|
|
112
|
+
process.stderr.write(`toolmoat diff: ${message}\nUsage: toolmoat diff <server> [--tool <tool>] [--lock <file>]\n`);
|
|
113
|
+
return 2;
|
|
114
|
+
}
|