mcp-stdio-guard 0.2.0 → 0.4.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/README.md +243 -8
- package/package.json +1 -1
- package/src/index.js +2265 -90
package/src/index.js
CHANGED
|
@@ -1,20 +1,78 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
5
|
|
|
5
6
|
function loadVersion() {
|
|
6
7
|
try {
|
|
7
8
|
const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
8
|
-
return typeof packageJson.version === 'string' ? packageJson.version : '0.
|
|
9
|
+
return typeof packageJson.version === 'string' ? packageJson.version : '0.4.0';
|
|
9
10
|
} catch {
|
|
10
|
-
return '0.
|
|
11
|
+
return '0.4.0';
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
const DEFAULT_PROTOCOL = '2025-11-25';
|
|
15
16
|
const DEFAULT_TIMEOUT = 5000;
|
|
17
|
+
const DEFAULT_PROFILE = 'custom';
|
|
16
18
|
const VERSION = loadVersion();
|
|
17
19
|
const JSON_SCHEMA_VERSION = 1;
|
|
20
|
+
const REDACTED = '<redacted>';
|
|
21
|
+
const GUARD_PROFILES = Object.freeze({
|
|
22
|
+
custom: {
|
|
23
|
+
description: 'preserve explicit CLI flags and legacy defaults'
|
|
24
|
+
},
|
|
25
|
+
smoke: {
|
|
26
|
+
description: 'initialize only; skip advertised capability list probes'
|
|
27
|
+
},
|
|
28
|
+
registry: {
|
|
29
|
+
description: 'initialize, advertised list probes, fingerprint, and repeat consistency'
|
|
30
|
+
},
|
|
31
|
+
ci: {
|
|
32
|
+
description: 'stable JSON output with static findings treated as failures when scanned'
|
|
33
|
+
},
|
|
34
|
+
strict: {
|
|
35
|
+
description: 'deep deterministic checks; reserved for opt-in adversarial probes'
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const GUARD_PROFILE_NAMES = Object.keys(GUARD_PROFILES);
|
|
39
|
+
const VERSION_PROBE_CACHE = new Map();
|
|
40
|
+
const NODE_OPTIONS_WITH_VALUES = new Set([
|
|
41
|
+
'--conditions',
|
|
42
|
+
'--cpu-prof-dir',
|
|
43
|
+
'--diagnostic-dir',
|
|
44
|
+
'--experimental-loader',
|
|
45
|
+
'--heapsnapshot-near-heap-limit',
|
|
46
|
+
'--import',
|
|
47
|
+
'--inspect-port',
|
|
48
|
+
'--loader',
|
|
49
|
+
'--max-old-space-size',
|
|
50
|
+
'--openssl-config',
|
|
51
|
+
'--perf-basic-prof-only-functions',
|
|
52
|
+
'--prof-process',
|
|
53
|
+
'--redirect-warnings',
|
|
54
|
+
'--require',
|
|
55
|
+
'--test-reporter',
|
|
56
|
+
'--test-reporter-destination',
|
|
57
|
+
'--title',
|
|
58
|
+
'--trace-event-categories',
|
|
59
|
+
'--trace-event-file-pattern',
|
|
60
|
+
'-C',
|
|
61
|
+
'-r'
|
|
62
|
+
]);
|
|
63
|
+
const NODE_EVAL_OPTIONS = new Set(['--eval', '--print', '-e', '-p']);
|
|
64
|
+
|
|
65
|
+
export const ISSUE_CLASSES = Object.freeze({
|
|
66
|
+
INSTALL_RUNTIME: 'installRuntime',
|
|
67
|
+
STDIO_TRANSPORT: 'stdioTransport',
|
|
68
|
+
MCP_PROTOCOL: 'mcpProtocol'
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const ISSUE_CLASS_NAMES = [
|
|
72
|
+
ISSUE_CLASSES.INSTALL_RUNTIME,
|
|
73
|
+
ISSUE_CLASSES.STDIO_TRANSPORT,
|
|
74
|
+
ISSUE_CLASSES.MCP_PROTOCOL
|
|
75
|
+
];
|
|
18
76
|
|
|
19
77
|
const STDOUT_ISSUE_CODES = new Set([
|
|
20
78
|
'stdout-empty-line',
|
|
@@ -25,12 +83,38 @@ const STDOUT_ISSUE_CODES = new Set([
|
|
|
25
83
|
'stdout-without-newline'
|
|
26
84
|
]);
|
|
27
85
|
const JSON_RPC_ISSUE_CODES = new Set([
|
|
86
|
+
'notification-response',
|
|
87
|
+
'response-id-mismatch',
|
|
28
88
|
'response-id-type-mismatch',
|
|
29
89
|
'stdout-invalid-json-rpc',
|
|
30
90
|
'stdout-unexpected-request-id'
|
|
31
91
|
]);
|
|
92
|
+
const CAPABILITY_DEFINITIONS = Object.freeze([
|
|
93
|
+
{ name: 'tools', method: 'tools/list' },
|
|
94
|
+
{ name: 'resources', method: 'resources/list' },
|
|
95
|
+
{ name: 'prompts', method: 'prompts/list' }
|
|
96
|
+
]);
|
|
97
|
+
const CAPABILITY_ISSUE_CODES = new Set([
|
|
98
|
+
'capability-list-error',
|
|
99
|
+
'capability-list-missing-response',
|
|
100
|
+
'capability-list-timeout',
|
|
101
|
+
'capability-list-unsupported'
|
|
102
|
+
]);
|
|
103
|
+
const REPEAT_DRIFT_ISSUE_CODES = new Set([
|
|
104
|
+
'repeat-capability-drift',
|
|
105
|
+
'repeat-list-shape-drift',
|
|
106
|
+
'repeat-protocol-drift',
|
|
107
|
+
'repeat-tool-drift'
|
|
108
|
+
]);
|
|
32
109
|
const INITIALIZE_ISSUE_CODES = new Set([
|
|
33
110
|
'initialize-error',
|
|
111
|
+
'initialize-invalid-capabilities',
|
|
112
|
+
'initialize-invalid-protocol-version',
|
|
113
|
+
'initialize-invalid-result',
|
|
114
|
+
'initialize-invalid-server-info',
|
|
115
|
+
'initialize-missing-capabilities',
|
|
116
|
+
'initialize-missing-protocol-version',
|
|
117
|
+
'initialize-missing-server-info',
|
|
34
118
|
'initialize-timeout',
|
|
35
119
|
'server-exited',
|
|
36
120
|
'spawn-failed'
|
|
@@ -40,11 +124,64 @@ const OPERATION_ISSUE_CODES = new Set([
|
|
|
40
124
|
'operation-missing-response',
|
|
41
125
|
'operation-timeout'
|
|
42
126
|
]);
|
|
127
|
+
const TOOL_SCHEMA_ISSUE_CODES = new Set([
|
|
128
|
+
'tool-description-missing',
|
|
129
|
+
'tool-input-schema-invalid',
|
|
130
|
+
'tool-input-schema-required-missing',
|
|
131
|
+
'tool-name-duplicate',
|
|
132
|
+
'tool-name-invalid',
|
|
133
|
+
'tools-list-invalid-result'
|
|
134
|
+
]);
|
|
135
|
+
const JSON_SCHEMA_TYPES = new Set(['array', 'boolean', 'integer', 'null', 'number', 'object', 'string']);
|
|
43
136
|
const PROCESS_ISSUE_CODES = new Set([
|
|
44
137
|
'server-crashed',
|
|
45
138
|
'server-exited',
|
|
46
139
|
'spawn-failed'
|
|
47
140
|
]);
|
|
141
|
+
const ISSUE_CLASS_BY_CODE = new Map([
|
|
142
|
+
['initialize-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
143
|
+
['operation-missing-response', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
144
|
+
['operation-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
145
|
+
['python-buffered-stdio', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
146
|
+
['server-crashed', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
147
|
+
['server-exited', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
148
|
+
['spawn-failed', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
149
|
+
|
|
150
|
+
['static-stdout-write', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
151
|
+
['stdout-content-length-framing', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
152
|
+
['stdout-empty-line', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
153
|
+
['stdout-non-json', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
154
|
+
['stdout-without-newline', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
155
|
+
|
|
156
|
+
['initialize-error', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
157
|
+
['initialize-invalid-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
158
|
+
['initialize-invalid-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
159
|
+
['initialize-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
160
|
+
['initialize-invalid-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
161
|
+
['initialize-missing-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
162
|
+
['initialize-missing-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
163
|
+
['initialize-missing-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
164
|
+
['operation-error', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
165
|
+
['capability-list-error', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
166
|
+
['capability-list-missing-response', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
167
|
+
['capability-list-timeout', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
168
|
+
['capability-list-unsupported', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
169
|
+
['repeat-capability-drift', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
170
|
+
['repeat-list-shape-drift', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
171
|
+
['repeat-protocol-drift', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
172
|
+
['repeat-tool-drift', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
173
|
+
['tool-description-missing', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
174
|
+
['tool-input-schema-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
175
|
+
['tool-input-schema-required-missing', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
176
|
+
['tool-name-duplicate', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
177
|
+
['tool-name-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
178
|
+
['tools-list-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
179
|
+
['notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
180
|
+
['response-id-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
181
|
+
['response-id-type-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
182
|
+
['stdout-invalid-json-rpc', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
183
|
+
['stdout-unexpected-request-id', ISSUE_CLASSES.MCP_PROTOCOL]
|
|
184
|
+
]);
|
|
48
185
|
|
|
49
186
|
export async function runCli(argv) {
|
|
50
187
|
const options = parseArgs(argv);
|
|
@@ -64,13 +201,18 @@ export async function runCli(argv) {
|
|
|
64
201
|
}
|
|
65
202
|
|
|
66
203
|
const guardOptions = {
|
|
204
|
+
config: options.config,
|
|
205
|
+
profile: options.profile,
|
|
67
206
|
protocol: options.protocol,
|
|
68
207
|
timeoutMs: options.timeoutMs,
|
|
69
208
|
cwd: options.cwd,
|
|
70
|
-
|
|
209
|
+
env: options.env,
|
|
210
|
+
probeCapabilities: options.probeCapabilities,
|
|
211
|
+
operations: options.operations,
|
|
212
|
+
operation: options.operations.length === 1
|
|
71
213
|
? {
|
|
72
|
-
method: options.
|
|
73
|
-
params: options.
|
|
214
|
+
method: options.operations[0].method,
|
|
215
|
+
params: options.operations[0].params
|
|
74
216
|
}
|
|
75
217
|
: null
|
|
76
218
|
};
|
|
@@ -85,6 +227,9 @@ export async function runCli(argv) {
|
|
|
85
227
|
path: options.scanPath,
|
|
86
228
|
failOnFindings: options.failOnStatic
|
|
87
229
|
};
|
|
230
|
+
if (result.fingerprint) {
|
|
231
|
+
result.fingerprint.staticScan = result.staticScan;
|
|
232
|
+
}
|
|
88
233
|
result.staticFindings = scanSource(options.scanPath);
|
|
89
234
|
if (options.failOnStatic) {
|
|
90
235
|
for (const finding of result.staticFindings) {
|
|
@@ -113,8 +258,15 @@ export async function runCli(argv) {
|
|
|
113
258
|
export function parseArgs(argv) {
|
|
114
259
|
const options = {
|
|
115
260
|
command: [],
|
|
261
|
+
configPath: '',
|
|
262
|
+
config: defaultConfigMetadata(),
|
|
263
|
+
env: {},
|
|
264
|
+
operations: [],
|
|
265
|
+
configOperations: [],
|
|
116
266
|
protocol: DEFAULT_PROTOCOL,
|
|
117
267
|
timeoutMs: DEFAULT_TIMEOUT,
|
|
268
|
+
profile: DEFAULT_PROFILE,
|
|
269
|
+
probeCapabilities: true,
|
|
118
270
|
scanPath: '',
|
|
119
271
|
failOnStatic: false,
|
|
120
272
|
requestMethod: '',
|
|
@@ -125,12 +277,14 @@ export function parseArgs(argv) {
|
|
|
125
277
|
version: false,
|
|
126
278
|
cwd: process.cwd()
|
|
127
279
|
};
|
|
280
|
+
const specifiedOptions = new Set();
|
|
128
281
|
|
|
129
282
|
for (let index = 0; index < argv.length; index += 1) {
|
|
130
283
|
const arg = argv[index];
|
|
131
284
|
|
|
132
285
|
if (arg === '--') {
|
|
133
286
|
options.command = argv.slice(index + 1);
|
|
287
|
+
specifiedOptions.add('command');
|
|
134
288
|
break;
|
|
135
289
|
}
|
|
136
290
|
|
|
@@ -140,34 +294,61 @@ export function parseArgs(argv) {
|
|
|
140
294
|
options.version = true;
|
|
141
295
|
} else if (arg === '--json') {
|
|
142
296
|
options.json = true;
|
|
297
|
+
specifiedOptions.add('json');
|
|
298
|
+
} else if (arg === '--config') {
|
|
299
|
+
options.configPath = path.resolve(readOptionValue(argv, index, arg));
|
|
300
|
+
specifiedOptions.add('configPath');
|
|
301
|
+
index += 1;
|
|
302
|
+
} else if (arg === '--profile') {
|
|
303
|
+
options.profile = readOptionValue(argv, index, arg);
|
|
304
|
+
specifiedOptions.add('profile');
|
|
305
|
+
index += 1;
|
|
143
306
|
} else if (arg === '--fail-on-static') {
|
|
144
307
|
options.failOnStatic = true;
|
|
308
|
+
specifiedOptions.add('failOnStatic');
|
|
145
309
|
} else if (arg === '--request') {
|
|
146
310
|
options.requestMethod = readOptionValue(argv, index, arg);
|
|
311
|
+
specifiedOptions.add('requestMethod');
|
|
147
312
|
index += 1;
|
|
148
313
|
} else if (arg === '--params') {
|
|
149
314
|
options.requestParams = parseJsonOption(readOptionValue(argv, index, arg), arg);
|
|
315
|
+
specifiedOptions.add('requestParams');
|
|
150
316
|
index += 1;
|
|
151
317
|
} else if (arg === '--protocol') {
|
|
152
318
|
options.protocol = readOptionValue(argv, index, arg);
|
|
319
|
+
specifiedOptions.add('protocol');
|
|
153
320
|
index += 1;
|
|
154
321
|
} else if (arg === '--timeout') {
|
|
155
322
|
options.timeoutMs = Number(readOptionValue(argv, index, arg));
|
|
323
|
+
specifiedOptions.add('timeoutMs');
|
|
156
324
|
index += 1;
|
|
157
325
|
} else if (arg === '--repeat') {
|
|
158
326
|
options.repeat = Number(readOptionValue(argv, index, arg));
|
|
327
|
+
specifiedOptions.add('repeat');
|
|
159
328
|
index += 1;
|
|
160
329
|
} else if (arg === '--scan') {
|
|
161
330
|
options.scanPath = path.resolve(readOptionValue(argv, index, arg));
|
|
331
|
+
specifiedOptions.add('scanPath');
|
|
162
332
|
index += 1;
|
|
163
333
|
} else if (arg === '--cwd') {
|
|
164
334
|
options.cwd = path.resolve(readOptionValue(argv, index, arg));
|
|
335
|
+
specifiedOptions.add('cwd');
|
|
165
336
|
index += 1;
|
|
166
337
|
} else {
|
|
167
338
|
throw new Error(`Unknown option before --: ${arg}`);
|
|
168
339
|
}
|
|
169
340
|
}
|
|
170
341
|
|
|
342
|
+
if (options.help || options.version) {
|
|
343
|
+
return options;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (options.configPath) {
|
|
347
|
+
applyConfigFile(options, loadConfigFile(options.configPath), specifiedOptions);
|
|
348
|
+
}
|
|
349
|
+
applyProfileDefaults(options, specifiedOptions);
|
|
350
|
+
options.operations = buildConfiguredOperations(options);
|
|
351
|
+
|
|
171
352
|
if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 100) {
|
|
172
353
|
throw new Error('--timeout must be an integer >= 100');
|
|
173
354
|
}
|
|
@@ -183,6 +364,275 @@ export function parseArgs(argv) {
|
|
|
183
364
|
return options;
|
|
184
365
|
}
|
|
185
366
|
|
|
367
|
+
function applyProfileDefaults(options, specifiedOptions) {
|
|
368
|
+
if (!GUARD_PROFILE_NAMES.includes(options.profile)) {
|
|
369
|
+
throw new Error(`--profile must be one of: ${GUARD_PROFILE_NAMES.join(', ')}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (options.profile === 'smoke') {
|
|
373
|
+
options.probeCapabilities = false;
|
|
374
|
+
return options;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
options.probeCapabilities = true;
|
|
378
|
+
|
|
379
|
+
if (options.profile === 'registry' && !specifiedOptions.has('repeat')) {
|
|
380
|
+
options.repeat = 2;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (options.profile === 'ci') {
|
|
384
|
+
options.json = true;
|
|
385
|
+
options.failOnStatic = true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (options.profile === 'strict') {
|
|
389
|
+
options.json = true;
|
|
390
|
+
options.failOnStatic = true;
|
|
391
|
+
if (!specifiedOptions.has('repeat')) {
|
|
392
|
+
options.repeat = 2;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return options;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function loadConfigFile(configPath) {
|
|
400
|
+
let raw;
|
|
401
|
+
try {
|
|
402
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
403
|
+
} catch (error) {
|
|
404
|
+
throw new Error(`Failed to read --config ${configPath}: ${error.message}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const parsed = JSON.parse(raw);
|
|
409
|
+
if (!isObjectRecord(parsed)) {
|
|
410
|
+
throw new Error('top-level value must be an object');
|
|
411
|
+
}
|
|
412
|
+
return parsed;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
throw new Error(`--config ${configPath} must be valid JSON: ${error.message}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function applyConfigFile(options, config, specifiedOptions) {
|
|
419
|
+
const configDir = path.dirname(options.configPath);
|
|
420
|
+
const command = normalizeConfigCommand(config);
|
|
421
|
+
const env = normalizeConfigEnv(config);
|
|
422
|
+
const requests = normalizeConfigRequests(config);
|
|
423
|
+
const safeToolCalls = normalizeSafeToolCalls(config);
|
|
424
|
+
const usesConfigCommand = command.length > 0 && !specifiedOptions.has('command');
|
|
425
|
+
const usesConfigCwd = typeof config.cwd === 'string' && !specifiedOptions.has('cwd');
|
|
426
|
+
const usesConfigOperations = !specifiedOptions.has('requestMethod') && !specifiedOptions.has('requestParams');
|
|
427
|
+
|
|
428
|
+
if (usesConfigCommand) {
|
|
429
|
+
options.command = command;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (usesConfigCwd) {
|
|
433
|
+
options.cwd = resolveConfigPath(config.cwd, configDir);
|
|
434
|
+
} else if (config.cwd !== undefined && typeof config.cwd !== 'string') {
|
|
435
|
+
throw new Error('--config cwd must be a string');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (config.protocol !== undefined && !specifiedOptions.has('protocol')) {
|
|
439
|
+
if (typeof config.protocol !== 'string' || !config.protocol) {
|
|
440
|
+
throw new Error('--config protocol must be a non-empty string');
|
|
441
|
+
}
|
|
442
|
+
options.protocol = config.protocol;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const timeoutField = Object.hasOwn(config, 'timeoutMs') ? 'timeoutMs' : 'timeout';
|
|
446
|
+
const timeoutValue = config[timeoutField];
|
|
447
|
+
if (timeoutValue !== undefined && !specifiedOptions.has('timeoutMs')) {
|
|
448
|
+
options.timeoutMs = normalizeConfigInteger(timeoutValue, timeoutField, 100);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (config.repeat !== undefined && !specifiedOptions.has('repeat')) {
|
|
452
|
+
options.repeat = normalizeConfigInteger(config.repeat, 'repeat', 1);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (config.profile !== undefined && !specifiedOptions.has('profile')) {
|
|
456
|
+
if (typeof config.profile !== 'string') {
|
|
457
|
+
throw new Error('--config profile must be a string');
|
|
458
|
+
}
|
|
459
|
+
options.profile = config.profile;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (config.json !== undefined && !specifiedOptions.has('json')) {
|
|
463
|
+
if (typeof config.json !== 'boolean') {
|
|
464
|
+
throw new Error('--config json must be a boolean');
|
|
465
|
+
}
|
|
466
|
+
options.json = config.json;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const scanPath = config.scanPath ?? config.scan;
|
|
470
|
+
if (scanPath !== undefined && !specifiedOptions.has('scanPath')) {
|
|
471
|
+
if (typeof scanPath !== 'string') {
|
|
472
|
+
throw new Error('--config scan must be a string path');
|
|
473
|
+
}
|
|
474
|
+
options.scanPath = resolveConfigPath(scanPath, configDir);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (config.failOnStatic !== undefined && !specifiedOptions.has('failOnStatic')) {
|
|
478
|
+
if (typeof config.failOnStatic !== 'boolean') {
|
|
479
|
+
throw new Error('--config failOnStatic must be a boolean');
|
|
480
|
+
}
|
|
481
|
+
options.failOnStatic = config.failOnStatic;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (Object.keys(env).length) {
|
|
485
|
+
options.env = { ...env, ...options.env };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (usesConfigOperations) {
|
|
489
|
+
options.configOperations = [...requests, ...safeToolCalls];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
options.config = {
|
|
493
|
+
enabled: true,
|
|
494
|
+
path: options.configPath,
|
|
495
|
+
resolvedPath: options.configPath,
|
|
496
|
+
checks: {
|
|
497
|
+
command: usesConfigCommand,
|
|
498
|
+
cwd: usesConfigCwd,
|
|
499
|
+
envNames: Object.keys(env).sort(),
|
|
500
|
+
requests: usesConfigOperations ? requests.map((request) => request.method) : [],
|
|
501
|
+
safeToolCalls: usesConfigOperations ? safeToolCalls.map((request) => request.safeToolCallName) : []
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function normalizeConfigCommand(config) {
|
|
507
|
+
if (config.command === undefined && config.args === undefined) return [];
|
|
508
|
+
|
|
509
|
+
if (Array.isArray(config.command)) {
|
|
510
|
+
if (!config.command.every((entry) => typeof entry === 'string' && entry)) {
|
|
511
|
+
throw new Error('--config command array entries must be non-empty strings');
|
|
512
|
+
}
|
|
513
|
+
if (config.args !== undefined) {
|
|
514
|
+
throw new Error('--config args cannot be used when command is an array');
|
|
515
|
+
}
|
|
516
|
+
return config.command;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (typeof config.command === 'string' && config.command) {
|
|
520
|
+
const args = config.args ?? [];
|
|
521
|
+
if (!Array.isArray(args) || !args.every((entry) => typeof entry === 'string')) {
|
|
522
|
+
throw new Error('--config args must be an array of strings');
|
|
523
|
+
}
|
|
524
|
+
return [config.command, ...args];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
throw new Error('--config command must be a non-empty string or array of strings');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function normalizeConfigEnv(config) {
|
|
531
|
+
if (config.env === undefined) return {};
|
|
532
|
+
if (!isObjectRecord(config.env)) {
|
|
533
|
+
throw new Error('--config env must be an object');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return Object.fromEntries(Object.entries(config.env).map(([name, value]) => {
|
|
537
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
538
|
+
throw new Error(`--config env contains invalid variable name: ${name}`);
|
|
539
|
+
}
|
|
540
|
+
if (!['string', 'number', 'boolean'].includes(typeof value)) {
|
|
541
|
+
throw new Error(`--config env.${name} must be a string, number, or boolean`);
|
|
542
|
+
}
|
|
543
|
+
return [name, String(value)];
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function normalizeConfigInteger(value, field, minimum) {
|
|
548
|
+
const normalized = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
549
|
+
if (!Number.isInteger(normalized) || normalized < minimum) {
|
|
550
|
+
throw new Error(`--config ${field} must be an integer >= ${minimum}`);
|
|
551
|
+
}
|
|
552
|
+
return normalized;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function normalizeConfigRequests(config) {
|
|
556
|
+
const values = [];
|
|
557
|
+
if (config.request !== undefined) {
|
|
558
|
+
values.push(config.request);
|
|
559
|
+
}
|
|
560
|
+
if (config.requests !== undefined) {
|
|
561
|
+
if (!Array.isArray(config.requests)) {
|
|
562
|
+
throw new Error('--config requests must be an array');
|
|
563
|
+
}
|
|
564
|
+
values.push(...config.requests);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return values.map((request, index) => {
|
|
568
|
+
if (!isObjectRecord(request)) {
|
|
569
|
+
throw new Error(`--config requests[${index}] must be an object`);
|
|
570
|
+
}
|
|
571
|
+
if (typeof request.method !== 'string' || !request.method) {
|
|
572
|
+
throw new Error(`--config requests[${index}].method must be a non-empty string`);
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
source: 'config-request',
|
|
576
|
+
method: request.method,
|
|
577
|
+
params: request.params
|
|
578
|
+
};
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function normalizeSafeToolCalls(config) {
|
|
583
|
+
if (config.safeToolCalls === undefined) return [];
|
|
584
|
+
if (!Array.isArray(config.safeToolCalls)) {
|
|
585
|
+
throw new Error('--config safeToolCalls must be an array');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return config.safeToolCalls.map((call, index) => {
|
|
589
|
+
if (!isObjectRecord(call)) {
|
|
590
|
+
throw new Error(`--config safeToolCalls[${index}] must be an object`);
|
|
591
|
+
}
|
|
592
|
+
if (typeof call.name !== 'string' || !call.name) {
|
|
593
|
+
throw new Error(`--config safeToolCalls[${index}].name must be a non-empty string`);
|
|
594
|
+
}
|
|
595
|
+
const argumentsValue = call.arguments ?? {};
|
|
596
|
+
if (!isObjectRecord(argumentsValue)) {
|
|
597
|
+
throw new Error(`--config safeToolCalls[${index}].arguments must be an object`);
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
source: 'safe-tool-call',
|
|
601
|
+
method: 'tools/call',
|
|
602
|
+
params: {
|
|
603
|
+
name: call.name,
|
|
604
|
+
arguments: argumentsValue
|
|
605
|
+
},
|
|
606
|
+
safeToolCallName: call.name
|
|
607
|
+
};
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function buildConfiguredOperations(options) {
|
|
612
|
+
if (options.requestMethod) {
|
|
613
|
+
return [{
|
|
614
|
+
source: 'cli-request',
|
|
615
|
+
method: options.requestMethod,
|
|
616
|
+
params: options.requestParams
|
|
617
|
+
}];
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return options.configOperations;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function normalizeGuardOperations(operations) {
|
|
624
|
+
return operations.map((operation) => ({
|
|
625
|
+
source: operation.source ?? 'request',
|
|
626
|
+
method: operation.method,
|
|
627
|
+
params: operation.params,
|
|
628
|
+
safeToolCallName: operation.safeToolCallName ?? ''
|
|
629
|
+
}));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function resolveConfigPath(value, configDir) {
|
|
633
|
+
return path.resolve(configDir, value);
|
|
634
|
+
}
|
|
635
|
+
|
|
186
636
|
export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
187
637
|
const startedAt = Date.now();
|
|
188
638
|
const repeat = options.repeat ?? 1;
|
|
@@ -193,8 +643,10 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
|
193
643
|
throw new Error('repeat must be an integer >= 1');
|
|
194
644
|
}
|
|
195
645
|
|
|
646
|
+
const singleRunOptions = { ...options, repeat: 1 };
|
|
647
|
+
|
|
196
648
|
for (let index = 1; index <= repeat; index += 1) {
|
|
197
|
-
const run = await guardStdioServer(commandWithArgs,
|
|
649
|
+
const run = await guardStdioServer(commandWithArgs, singleRunOptions);
|
|
198
650
|
run.run = index;
|
|
199
651
|
runs.push(run);
|
|
200
652
|
for (const issue of run.issues) {
|
|
@@ -202,19 +654,33 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
|
202
654
|
}
|
|
203
655
|
}
|
|
204
656
|
|
|
205
|
-
|
|
657
|
+
const drift = buildRepeatDrift(runs);
|
|
658
|
+
for (const issue of repeatDriftIssues(drift)) {
|
|
659
|
+
issues.push(issue);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const durationMs = Date.now() - startedAt;
|
|
663
|
+
const result = {
|
|
206
664
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
207
665
|
ok: !issues.some((issue) => issue.severity === 'error'),
|
|
666
|
+
profile: options.profile ?? DEFAULT_PROFILE,
|
|
208
667
|
command: commandWithArgs,
|
|
668
|
+
config: options.config ?? defaultConfigMetadata(),
|
|
209
669
|
protocol: options.protocol ?? DEFAULT_PROTOCOL,
|
|
210
670
|
repeat,
|
|
211
671
|
runs,
|
|
212
672
|
issues,
|
|
213
673
|
checks: {},
|
|
674
|
+
capabilityProbes: options.probeCapabilities ?? true,
|
|
675
|
+
drift,
|
|
214
676
|
staticScan: defaultStaticScan(),
|
|
215
677
|
staticFindings: [],
|
|
216
|
-
|
|
217
|
-
|
|
678
|
+
toolSchema: aggregateRunToolSchemaValidation(runs),
|
|
679
|
+
durationMs,
|
|
680
|
+
fingerprint: createFingerprint(commandWithArgs, options)
|
|
681
|
+
};
|
|
682
|
+
result.fingerprint.timings.totalMs = durationMs;
|
|
683
|
+
return finalizeResult(result);
|
|
218
684
|
}
|
|
219
685
|
|
|
220
686
|
export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
@@ -223,7 +689,8 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
223
689
|
const args = commandWithArgs.slice(1);
|
|
224
690
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
225
691
|
const protocol = options.protocol ?? DEFAULT_PROTOCOL;
|
|
226
|
-
const
|
|
692
|
+
const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
|
|
693
|
+
const probeCapabilities = options.probeCapabilities ?? true;
|
|
227
694
|
const env = { ...process.env, ...(options.env ?? {}) };
|
|
228
695
|
const issues = [];
|
|
229
696
|
const frames = [];
|
|
@@ -231,41 +698,80 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
231
698
|
let stdoutBuffer = '';
|
|
232
699
|
let initialized = false;
|
|
233
700
|
let endedByGuard = false;
|
|
701
|
+
let initializeResponseAt = 0;
|
|
702
|
+
let currentRequest = null;
|
|
703
|
+
let requestQueue = [];
|
|
704
|
+
let nextRequestId = 2;
|
|
234
705
|
let timer;
|
|
235
706
|
let child;
|
|
236
707
|
|
|
237
708
|
const result = {
|
|
238
709
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
239
710
|
ok: false,
|
|
711
|
+
profile: options.profile ?? DEFAULT_PROFILE,
|
|
240
712
|
command: commandWithArgs,
|
|
713
|
+
config: options.config ?? defaultConfigMetadata(),
|
|
241
714
|
protocol,
|
|
242
715
|
negotiatedProtocol: '',
|
|
243
716
|
initialized: false,
|
|
244
|
-
operation:
|
|
717
|
+
operation: operations.length === 1
|
|
245
718
|
? {
|
|
246
|
-
method:
|
|
719
|
+
method: operations[0].method,
|
|
720
|
+
source: operations[0].source,
|
|
721
|
+
safeToolCallName: operations[0].safeToolCallName,
|
|
247
722
|
responded: false,
|
|
248
723
|
error: null
|
|
249
724
|
}
|
|
250
725
|
: null,
|
|
726
|
+
operations: operations.map((operation, index) => ({
|
|
727
|
+
index,
|
|
728
|
+
source: operation.source,
|
|
729
|
+
method: operation.method,
|
|
730
|
+
safeToolCallName: operation.safeToolCallName,
|
|
731
|
+
responded: false,
|
|
732
|
+
error: null
|
|
733
|
+
})),
|
|
251
734
|
frames,
|
|
252
735
|
issues,
|
|
253
736
|
checks: {},
|
|
737
|
+
capabilityProbes: probeCapabilities,
|
|
738
|
+
capabilityKeys: [],
|
|
739
|
+
capabilityChecks: defaultCapabilityChecks(),
|
|
254
740
|
stderr: '',
|
|
741
|
+
process: defaultProcessInfo(timeoutMs),
|
|
255
742
|
staticScan: defaultStaticScan(),
|
|
256
743
|
staticFindings: [],
|
|
257
|
-
|
|
744
|
+
toolSchema: defaultToolSchemaValidation(),
|
|
745
|
+
durationMs: 0,
|
|
746
|
+
fingerprint: createFingerprint(commandWithArgs, {
|
|
747
|
+
protocol,
|
|
748
|
+
timeoutMs,
|
|
749
|
+
cwd: options.cwd,
|
|
750
|
+
config: options.config,
|
|
751
|
+
profile: options.profile,
|
|
752
|
+
probeCapabilities,
|
|
753
|
+
operations,
|
|
754
|
+
env: options.env
|
|
755
|
+
})
|
|
258
756
|
};
|
|
259
757
|
|
|
260
758
|
return new Promise((resolve) => {
|
|
261
|
-
function addIssue(severity, code, message) {
|
|
262
|
-
issues.push({ severity, code, message });
|
|
759
|
+
function addIssue(severity, code, message, details = {}) {
|
|
760
|
+
issues.push({ ...details, severity, code, message });
|
|
263
761
|
}
|
|
264
762
|
|
|
265
|
-
function armTimeout(code, message) {
|
|
763
|
+
function armTimeout(code, message, timeoutPhase, details = {}) {
|
|
266
764
|
clearTimeout(timer);
|
|
765
|
+
result.process.phase = timeoutPhase;
|
|
267
766
|
timer = setTimeout(() => {
|
|
268
|
-
|
|
767
|
+
result.process.timedOut = true;
|
|
768
|
+
result.process.timeoutCode = code;
|
|
769
|
+
result.process.timeoutMs = timeoutMs;
|
|
770
|
+
result.process.outcome = 'timeout';
|
|
771
|
+
addIssue('error', code, message, {
|
|
772
|
+
...timeoutIssueDetails(code, timeoutMs, timeoutPhase),
|
|
773
|
+
...details
|
|
774
|
+
});
|
|
269
775
|
finish();
|
|
270
776
|
}, timeoutMs);
|
|
271
777
|
}
|
|
@@ -281,8 +787,19 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
281
787
|
result.durationMs = Date.now() - startedAt;
|
|
282
788
|
result.stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
283
789
|
result.initialized = initialized;
|
|
790
|
+
result.fingerprint.timings.startupMs = initializeResponseAt ? initializeResponseAt - startedAt : null;
|
|
791
|
+
result.fingerprint.timings.totalMs = result.durationMs;
|
|
792
|
+
const willTerminate = child && result.process.started && !child.killed && child.exitCode === null;
|
|
793
|
+
if (willTerminate) {
|
|
794
|
+
result.process.killedByGuard = true;
|
|
795
|
+
result.process.killSignal = 'SIGTERM';
|
|
796
|
+
result.process.killReason = result.process.timedOut ? 'timeout' : 'guard-finished';
|
|
797
|
+
if (!result.process.outcome || result.process.outcome === 'running') {
|
|
798
|
+
result.process.outcome = 'guard-terminated';
|
|
799
|
+
}
|
|
800
|
+
}
|
|
284
801
|
finalizeResult(result);
|
|
285
|
-
if (
|
|
802
|
+
if (willTerminate) {
|
|
286
803
|
endedByGuard = true;
|
|
287
804
|
child.kill('SIGTERM');
|
|
288
805
|
}
|
|
@@ -290,9 +807,166 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
290
807
|
}
|
|
291
808
|
|
|
292
809
|
function send(message) {
|
|
810
|
+
if (!child?.stdin?.writable) return;
|
|
293
811
|
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
294
812
|
}
|
|
295
813
|
|
|
814
|
+
function enqueueRequest(request) {
|
|
815
|
+
requestQueue.push({
|
|
816
|
+
...request,
|
|
817
|
+
id: nextRequestId
|
|
818
|
+
});
|
|
819
|
+
nextRequestId += 1;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function startNextRequest() {
|
|
823
|
+
if (result.durationMs || currentRequest) return;
|
|
824
|
+
currentRequest = requestQueue.shift() || null;
|
|
825
|
+
if (!currentRequest) {
|
|
826
|
+
result.process.phase = 'post-initialize';
|
|
827
|
+
finishSoon();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
result.process.phase = 'operation';
|
|
832
|
+
const request = {
|
|
833
|
+
jsonrpc: '2.0',
|
|
834
|
+
id: currentRequest.id,
|
|
835
|
+
method: currentRequest.method
|
|
836
|
+
};
|
|
837
|
+
if (currentRequest.params !== undefined) {
|
|
838
|
+
request.params = currentRequest.params;
|
|
839
|
+
}
|
|
840
|
+
send(request);
|
|
841
|
+
armTimeout(
|
|
842
|
+
currentRequest.timeoutCode,
|
|
843
|
+
currentRequest.timeoutMessage,
|
|
844
|
+
'operation',
|
|
845
|
+
currentRequest.timeoutDetails
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function configureCapabilityChecks(capabilities) {
|
|
850
|
+
result.capabilityKeys = capabilityKeys(capabilities);
|
|
851
|
+
for (const definition of CAPABILITY_DEFINITIONS) {
|
|
852
|
+
const check = result.capabilityChecks[definition.name];
|
|
853
|
+
check.advertised = result.capabilityKeys.includes(definition.name);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function enqueuePostInitializeRequests() {
|
|
858
|
+
const operationCapabilities = new Set(operations.map((operation) => capabilityNameForMethod(operation.method)).filter(Boolean));
|
|
859
|
+
for (let operationIndex = 0; operationIndex < operations.length; operationIndex += 1) {
|
|
860
|
+
const operation = operations[operationIndex];
|
|
861
|
+
const operationCapability = capabilityNameForMethod(operation.method);
|
|
862
|
+
enqueueRequest({
|
|
863
|
+
kind: 'operation',
|
|
864
|
+
operationIndex,
|
|
865
|
+
capability: result.capabilityChecks[operationCapability]?.advertised ? operationCapability : '',
|
|
866
|
+
method: operation.method,
|
|
867
|
+
params: operation.params,
|
|
868
|
+
timeoutCode: 'operation-timeout',
|
|
869
|
+
timeoutMessage: `no ${operation.method} response within ${timeoutMs}ms`,
|
|
870
|
+
timeoutDetails: {}
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (!probeCapabilities) return;
|
|
875
|
+
|
|
876
|
+
for (const definition of CAPABILITY_DEFINITIONS) {
|
|
877
|
+
const check = result.capabilityChecks[definition.name];
|
|
878
|
+
if (!check.advertised || operationCapabilities.has(definition.name)) continue;
|
|
879
|
+
enqueueRequest({
|
|
880
|
+
kind: 'capability',
|
|
881
|
+
capability: definition.name,
|
|
882
|
+
method: definition.method,
|
|
883
|
+
timeoutCode: 'capability-list-timeout',
|
|
884
|
+
timeoutMessage: `${definition.method} did not receive a response for advertised ${definition.name} capability within ${timeoutMs}ms`,
|
|
885
|
+
timeoutDetails: {
|
|
886
|
+
capability: definition.name,
|
|
887
|
+
method: definition.method,
|
|
888
|
+
detailCode: 'capability-request-timeout'
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function handleCurrentRequestResponse(message) {
|
|
895
|
+
clearTimeout(timer);
|
|
896
|
+
const request = currentRequest;
|
|
897
|
+
if (!isJsonRpcResponse(message)) {
|
|
898
|
+
addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id ${request.id} is not a ${request.method} response`);
|
|
899
|
+
finish();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (request.kind === 'operation') {
|
|
904
|
+
const operationResult = result.operations[request.operationIndex];
|
|
905
|
+
operationResult.responded = true;
|
|
906
|
+
if (result.operation && request.operationIndex === 0) {
|
|
907
|
+
result.operation.responded = true;
|
|
908
|
+
}
|
|
909
|
+
if (message.error) {
|
|
910
|
+
operationResult.error = message.error;
|
|
911
|
+
if (result.operation && request.operationIndex === 0) {
|
|
912
|
+
result.operation.error = message.error;
|
|
913
|
+
}
|
|
914
|
+
addIssue('warning', 'operation-error', `${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (!message.error && request.method === 'tools/list') {
|
|
919
|
+
const validation = validateToolsListResult(message.result);
|
|
920
|
+
result.toolSchema = validation.summary;
|
|
921
|
+
for (const issue of validation.issues) {
|
|
922
|
+
addIssue(issue.severity, issue.code, issue.message, issue.details);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (request.capability) {
|
|
927
|
+
recordCapabilityListShape(request, message.result);
|
|
928
|
+
handleCapabilityResponse(request, message);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
currentRequest = null;
|
|
932
|
+
startNextRequest();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function handleCapabilityResponse(request, message) {
|
|
936
|
+
const check = result.capabilityChecks[request.capability];
|
|
937
|
+
check.responded = true;
|
|
938
|
+
if (!message.error) return;
|
|
939
|
+
|
|
940
|
+
check.error = message.error;
|
|
941
|
+
const unsupported = isUnsupportedMethodError(message.error);
|
|
942
|
+
const code = unsupported ? 'capability-list-unsupported' : 'capability-list-error';
|
|
943
|
+
const messageText = unsupported
|
|
944
|
+
? `${request.capability} capability is advertised but ${request.method} returned Method not found`
|
|
945
|
+
: `${request.capability} capability is advertised but ${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`;
|
|
946
|
+
addIssue('error', code, messageText, {
|
|
947
|
+
capability: request.capability,
|
|
948
|
+
method: request.method,
|
|
949
|
+
errorCode: message.error.code ?? null
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function recordCapabilityListShape(request, responseResult) {
|
|
954
|
+
if (!isObjectRecord(responseResult)) return;
|
|
955
|
+
const check = result.capabilityChecks[request.capability];
|
|
956
|
+
const items = responseResult[request.capability];
|
|
957
|
+
if (check && Array.isArray(items)) {
|
|
958
|
+
check.itemCount = items.length;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function addCapabilityMissingResponseIssue(request) {
|
|
963
|
+
addIssue('error', 'capability-list-missing-response', `${request.method} did not receive a response before server exit`, {
|
|
964
|
+
capability: request.capability,
|
|
965
|
+
method: request.method,
|
|
966
|
+
detailCode: 'capability-missing-response'
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
296
970
|
const pythonBufferingIssue = detectPythonBufferingIssue(commandWithArgs, env);
|
|
297
971
|
if (pythonBufferingIssue) {
|
|
298
972
|
addIssue('warning', 'python-buffered-stdio', pythonBufferingIssue);
|
|
@@ -303,15 +977,34 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
303
977
|
env,
|
|
304
978
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
305
979
|
});
|
|
980
|
+
result.process.pid = child.pid ?? null;
|
|
981
|
+
|
|
982
|
+
armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms`, 'initialize');
|
|
306
983
|
|
|
307
|
-
|
|
984
|
+
child.on('spawn', () => {
|
|
985
|
+
result.process.started = true;
|
|
986
|
+
result.process.pid = child.pid ?? null;
|
|
987
|
+
result.process.outcome = 'running';
|
|
988
|
+
});
|
|
308
989
|
|
|
309
990
|
child.on('error', (error) => {
|
|
310
991
|
clearTimeout(timer);
|
|
311
|
-
|
|
992
|
+
result.process.phase = 'startup';
|
|
993
|
+
result.process.outcome = 'spawn-failed';
|
|
994
|
+
result.process.spawnError = {
|
|
995
|
+
code: error.code || '',
|
|
996
|
+
message: error.message
|
|
997
|
+
};
|
|
998
|
+
addIssue('error', 'spawn-failed', error.message, {
|
|
999
|
+
detailCode: 'spawn-failed-before-startup',
|
|
1000
|
+
phase: 'startup',
|
|
1001
|
+
spawnErrorCode: error.code || ''
|
|
1002
|
+
});
|
|
312
1003
|
finish();
|
|
313
1004
|
});
|
|
314
1005
|
|
|
1006
|
+
child.stdin.on('error', () => {});
|
|
1007
|
+
|
|
315
1008
|
child.stdout.on('data', (chunk) => {
|
|
316
1009
|
stdoutBuffer += chunk.toString('utf8');
|
|
317
1010
|
const lines = stdoutBuffer.split(/\r?\n/);
|
|
@@ -329,17 +1022,36 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
329
1022
|
child.on('exit', (code, signal) => {
|
|
330
1023
|
if (result.durationMs) return;
|
|
331
1024
|
clearTimeout(timer);
|
|
1025
|
+
const exitPhase = initialized
|
|
1026
|
+
? currentRequest
|
|
1027
|
+
? 'operation'
|
|
1028
|
+
: 'post-initialize'
|
|
1029
|
+
: 'initialize';
|
|
1030
|
+
result.process.phase = exitPhase;
|
|
1031
|
+
result.process.outcome = 'exited';
|
|
1032
|
+
result.process.exitCode = code;
|
|
1033
|
+
result.process.signal = signal;
|
|
332
1034
|
if (stdoutBuffer.trim()) {
|
|
333
1035
|
addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
|
|
334
1036
|
}
|
|
335
|
-
if (!endedByGuard && initialized &&
|
|
336
|
-
|
|
1037
|
+
if (!endedByGuard && initialized && currentRequest?.kind === 'operation') {
|
|
1038
|
+
const operationResult = result.operations[currentRequest.operationIndex];
|
|
1039
|
+
if (operationResult && !operationResult.responded) {
|
|
1040
|
+
addIssue('error', 'operation-missing-response', `${operationResult.method} did not receive a response before server exit`, {
|
|
1041
|
+
...exitIssueDetails('during-operation', code, signal),
|
|
1042
|
+
operationIndex: currentRequest.operationIndex,
|
|
1043
|
+
method: operationResult.method
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (!endedByGuard && initialized && currentRequest?.capability) {
|
|
1048
|
+
addCapabilityMissingResponseIssue(currentRequest);
|
|
337
1049
|
}
|
|
338
|
-
if (!endedByGuard && initialized && code
|
|
339
|
-
addIssue('error', 'server-crashed', `server exited after initialize (code ${code}, signal ${signal ?? 'null'})
|
|
1050
|
+
if (!endedByGuard && initialized && isAbnormalExit(code, signal)) {
|
|
1051
|
+
addIssue('error', 'server-crashed', `server exited after initialize (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('after-initialize', code, signal));
|
|
340
1052
|
}
|
|
341
1053
|
if (!initialized && !endedByGuard && !issues.some((issue) => issue.code === 'spawn-failed')) {
|
|
342
|
-
addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})
|
|
1054
|
+
addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('before-initialize', code, signal));
|
|
343
1055
|
}
|
|
344
1056
|
finish();
|
|
345
1057
|
});
|
|
@@ -386,15 +1098,23 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
386
1098
|
|
|
387
1099
|
frames.push(message);
|
|
388
1100
|
|
|
389
|
-
if (isResponseIdTypeMismatch(message, 1)) {
|
|
1101
|
+
if (!initialized && isResponseIdTypeMismatch(message, 1)) {
|
|
390
1102
|
clearTimeout(timer);
|
|
391
1103
|
addIssue('error', 'response-id-type-mismatch', `initialize response id ${JSON.stringify(message.id)} does not exactly match request id 1`);
|
|
392
1104
|
finish();
|
|
393
1105
|
return;
|
|
394
1106
|
}
|
|
395
1107
|
|
|
396
|
-
if (message
|
|
1108
|
+
if (!initialized && isResponseIdMismatch(message, 1)) {
|
|
1109
|
+
clearTimeout(timer);
|
|
1110
|
+
addIssue('error', 'response-id-mismatch', `initialize response id ${JSON.stringify(message.id)} does not match request id 1`);
|
|
1111
|
+
finish();
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (!initialized && message.id === 1) {
|
|
397
1116
|
clearTimeout(timer);
|
|
1117
|
+
initializeResponseAt = Date.now();
|
|
398
1118
|
if (!isJsonRpcResponse(message)) {
|
|
399
1119
|
addIssue('error', 'stdout-unexpected-request-id', 'stdout frame with id 1 is not an initialize response');
|
|
400
1120
|
finish();
|
|
@@ -407,41 +1127,36 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
407
1127
|
return;
|
|
408
1128
|
}
|
|
409
1129
|
|
|
1130
|
+
const initializeIssues = validateInitializeResult(message.result);
|
|
1131
|
+
for (const issue of initializeIssues) {
|
|
1132
|
+
addIssue(issue.severity, issue.code, issue.message);
|
|
1133
|
+
}
|
|
1134
|
+
if (initializeIssues.some((issue) => issue.severity === 'error')) {
|
|
1135
|
+
finish();
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
410
1139
|
initialized = true;
|
|
1140
|
+
configureCapabilityChecks(message.result.capabilities);
|
|
1141
|
+
result.process.phase = 'post-initialize';
|
|
411
1142
|
result.negotiatedProtocol = message.result?.protocolVersion || '';
|
|
412
1143
|
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
id: 2,
|
|
417
|
-
method: operation.method
|
|
418
|
-
};
|
|
419
|
-
if (operation.params !== undefined) {
|
|
420
|
-
request.params = operation.params;
|
|
421
|
-
}
|
|
422
|
-
send(request);
|
|
423
|
-
armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms`);
|
|
424
|
-
} else {
|
|
425
|
-
finishSoon();
|
|
426
|
-
}
|
|
427
|
-
} else if (operation && isResponseIdTypeMismatch(message, 2)) {
|
|
1144
|
+
enqueuePostInitializeRequests();
|
|
1145
|
+
startNextRequest();
|
|
1146
|
+
} else if (initialized && currentRequest && isResponseIdTypeMismatch(message, currentRequest.id)) {
|
|
428
1147
|
clearTimeout(timer);
|
|
429
|
-
addIssue('error', 'response-id-type-mismatch', `${
|
|
1148
|
+
addIssue('error', 'response-id-type-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not exactly match request id ${currentRequest.id}`);
|
|
430
1149
|
finish();
|
|
431
|
-
} else if (
|
|
1150
|
+
} else if (initialized && currentRequest && isResponseIdMismatch(message, currentRequest.id)) {
|
|
432
1151
|
clearTimeout(timer);
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
result.operation.error = message.error;
|
|
442
|
-
addIssue('warning', 'operation-error', `${operation.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
|
|
443
|
-
}
|
|
444
|
-
finishSoon();
|
|
1152
|
+
addIssue('error', 'response-id-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not match request id ${currentRequest.id}`);
|
|
1153
|
+
finish();
|
|
1154
|
+
} else if (initialized && currentRequest && message.id === currentRequest.id) {
|
|
1155
|
+
handleCurrentRequestResponse(message);
|
|
1156
|
+
} else if (initialized && !currentRequest && isJsonRpcResponse(message)) {
|
|
1157
|
+
clearTimeout(timer);
|
|
1158
|
+
addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
|
|
1159
|
+
finish();
|
|
445
1160
|
}
|
|
446
1161
|
}
|
|
447
1162
|
});
|
|
@@ -467,10 +1182,92 @@ export function detectPythonBufferingIssue(commandWithArgs, env = process.env) {
|
|
|
467
1182
|
return 'Python stdout is buffered when piped; use python -u or PYTHONUNBUFFERED=1 for MCP stdio servers';
|
|
468
1183
|
}
|
|
469
1184
|
|
|
1185
|
+
function defaultProcessInfo(timeoutMs) {
|
|
1186
|
+
return {
|
|
1187
|
+
started: false,
|
|
1188
|
+
pid: null,
|
|
1189
|
+
outcome: 'starting',
|
|
1190
|
+
phase: 'initialize',
|
|
1191
|
+
exitCode: null,
|
|
1192
|
+
signal: null,
|
|
1193
|
+
timedOut: false,
|
|
1194
|
+
timeoutCode: '',
|
|
1195
|
+
timeoutMs,
|
|
1196
|
+
killedByGuard: false,
|
|
1197
|
+
killSignal: '',
|
|
1198
|
+
killReason: '',
|
|
1199
|
+
spawnError: null
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function timeoutIssueDetails(code, timeoutMs, phase) {
|
|
1204
|
+
return {
|
|
1205
|
+
detailCode: code === 'operation-timeout'
|
|
1206
|
+
? 'request-timeout'
|
|
1207
|
+
: code === 'capability-list-timeout'
|
|
1208
|
+
? 'capability-request-timeout'
|
|
1209
|
+
: 'startup-timeout',
|
|
1210
|
+
phase,
|
|
1211
|
+
timeoutMs
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function defaultCapabilityChecks() {
|
|
1216
|
+
return Object.fromEntries(CAPABILITY_DEFINITIONS.map((definition) => [
|
|
1217
|
+
definition.name,
|
|
1218
|
+
{
|
|
1219
|
+
advertised: false,
|
|
1220
|
+
method: definition.method,
|
|
1221
|
+
responded: false,
|
|
1222
|
+
itemCount: null,
|
|
1223
|
+
error: null
|
|
1224
|
+
}
|
|
1225
|
+
]));
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function capabilityNameForMethod(method) {
|
|
1229
|
+
return CAPABILITY_DEFINITIONS.find((definition) => definition.method === method)?.name ?? '';
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function capabilityKeys(capabilities) {
|
|
1233
|
+
return isObjectRecord(capabilities) ? Object.keys(capabilities).sort() : [];
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function isUnsupportedMethodError(error) {
|
|
1237
|
+
return error?.code === -32601 || /method not found/i.test(error?.message || '');
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function exitIssueDetails(position, code, signal) {
|
|
1241
|
+
return {
|
|
1242
|
+
detailCode: exitDetailCode(position, code, signal),
|
|
1243
|
+
phase: position === 'during-operation'
|
|
1244
|
+
? 'operation'
|
|
1245
|
+
: position === 'before-initialize'
|
|
1246
|
+
? 'initialize'
|
|
1247
|
+
: 'post-initialize',
|
|
1248
|
+
exitCode: code,
|
|
1249
|
+
signal
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function exitDetailCode(position, code, signal) {
|
|
1254
|
+
if (signal) return `signal-exit-${position}`;
|
|
1255
|
+
if (code === 0) return `clean-exit-${position}`;
|
|
1256
|
+
return `nonzero-exit-${position}`;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function isAbnormalExit(code, signal) {
|
|
1260
|
+
return signal !== null || (code !== null && code !== 0);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
470
1263
|
function isResponseIdTypeMismatch(message, expectedId) {
|
|
471
1264
|
return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) === String(expectedId);
|
|
472
1265
|
}
|
|
473
1266
|
|
|
1267
|
+
function isResponseIdMismatch(message, expectedId) {
|
|
1268
|
+
return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) !== String(expectedId);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
474
1271
|
function isJsonRpcResponse(message) {
|
|
475
1272
|
return hasResponsePayload(message) && !Object.hasOwn(message, 'method');
|
|
476
1273
|
}
|
|
@@ -493,10 +1290,38 @@ export function validateJsonRpc(message) {
|
|
|
493
1290
|
const hasResult = Object.hasOwn(message, 'result');
|
|
494
1291
|
const hasError = Object.hasOwn(message, 'error');
|
|
495
1292
|
|
|
1293
|
+
if (hasId && !isJsonRpcId(message.id)) {
|
|
1294
|
+
return 'JSON-RPC id must be a string, integer number, or null';
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (hasMethod && hasId && message.id === null) {
|
|
1298
|
+
return 'JSON-RPC request id must not be null';
|
|
1299
|
+
}
|
|
1300
|
+
|
|
496
1301
|
if (hasMethod && (hasResult || hasError)) {
|
|
497
1302
|
return 'request/notification frame must not include result or error';
|
|
498
1303
|
}
|
|
499
1304
|
|
|
1305
|
+
if (!hasMethod && hasResult && hasError) {
|
|
1306
|
+
return 'response frame must not include both result and error';
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if (!hasMethod && !hasId && (hasResult || hasError)) {
|
|
1310
|
+
return 'response frame must include id';
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (!hasMethod && hasError) {
|
|
1314
|
+
if (!message.error || typeof message.error !== 'object' || Array.isArray(message.error)) {
|
|
1315
|
+
return 'JSON-RPC error must be an object';
|
|
1316
|
+
}
|
|
1317
|
+
if (!Number.isInteger(message.error.code)) {
|
|
1318
|
+
return 'JSON-RPC error code must be an integer';
|
|
1319
|
+
}
|
|
1320
|
+
if (typeof message.error.message !== 'string') {
|
|
1321
|
+
return 'JSON-RPC error message must be a string';
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
500
1325
|
if (hasId && !hasMethod && !hasResult && !hasError) {
|
|
501
1326
|
return 'response frame must include result or error';
|
|
502
1327
|
}
|
|
@@ -508,21 +1333,731 @@ export function validateJsonRpc(message) {
|
|
|
508
1333
|
return '';
|
|
509
1334
|
}
|
|
510
1335
|
|
|
511
|
-
function
|
|
512
|
-
|
|
513
|
-
result.staticScan ??= defaultStaticScan();
|
|
514
|
-
result.staticFindings ??= [];
|
|
515
|
-
result.ok = !result.issues.some((issue) => issue.severity === 'error');
|
|
516
|
-
result.checks = buildChecks(result);
|
|
517
|
-
return result;
|
|
1336
|
+
function isJsonRpcId(id) {
|
|
1337
|
+
return id === null || typeof id === 'string' || (typeof id === 'number' && Number.isInteger(id));
|
|
518
1338
|
}
|
|
519
1339
|
|
|
520
|
-
function
|
|
521
|
-
const issues =
|
|
522
|
-
const repeated = Array.isArray(result.runs);
|
|
1340
|
+
function validateInitializeResult(result) {
|
|
1341
|
+
const issues = [];
|
|
523
1342
|
|
|
524
|
-
|
|
525
|
-
|
|
1343
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
1344
|
+
return [{
|
|
1345
|
+
severity: 'error',
|
|
1346
|
+
code: 'initialize-invalid-result',
|
|
1347
|
+
message: 'initialize result must be an object with protocolVersion and capabilities'
|
|
1348
|
+
}];
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
if (!Object.hasOwn(result, 'protocolVersion')) {
|
|
1352
|
+
issues.push({
|
|
1353
|
+
severity: 'error',
|
|
1354
|
+
code: 'initialize-missing-protocol-version',
|
|
1355
|
+
message: 'initialize result is missing protocolVersion'
|
|
1356
|
+
});
|
|
1357
|
+
} else if (typeof result.protocolVersion !== 'string' || !result.protocolVersion) {
|
|
1358
|
+
issues.push({
|
|
1359
|
+
severity: 'error',
|
|
1360
|
+
code: 'initialize-invalid-protocol-version',
|
|
1361
|
+
message: 'initialize result protocolVersion must be a non-empty string'
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (!Object.hasOwn(result, 'capabilities')) {
|
|
1366
|
+
issues.push({
|
|
1367
|
+
severity: 'error',
|
|
1368
|
+
code: 'initialize-missing-capabilities',
|
|
1369
|
+
message: 'initialize result is missing capabilities'
|
|
1370
|
+
});
|
|
1371
|
+
} else if (!result.capabilities || typeof result.capabilities !== 'object' || Array.isArray(result.capabilities)) {
|
|
1372
|
+
issues.push({
|
|
1373
|
+
severity: 'error',
|
|
1374
|
+
code: 'initialize-invalid-capabilities',
|
|
1375
|
+
message: 'initialize result capabilities must be an object'
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
if (!Object.hasOwn(result, 'serverInfo')) {
|
|
1380
|
+
issues.push({
|
|
1381
|
+
severity: 'warning',
|
|
1382
|
+
code: 'initialize-missing-server-info',
|
|
1383
|
+
message: 'initialize result is missing serverInfo'
|
|
1384
|
+
});
|
|
1385
|
+
} else if (!result.serverInfo || typeof result.serverInfo !== 'object' || Array.isArray(result.serverInfo)) {
|
|
1386
|
+
issues.push({
|
|
1387
|
+
severity: 'warning',
|
|
1388
|
+
code: 'initialize-invalid-server-info',
|
|
1389
|
+
message: 'initialize result serverInfo should be an object'
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
return issues;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function validateToolsListResult(result) {
|
|
1397
|
+
const summary = {
|
|
1398
|
+
...defaultToolSchemaValidation(),
|
|
1399
|
+
checked: true
|
|
1400
|
+
};
|
|
1401
|
+
const issues = [];
|
|
1402
|
+
|
|
1403
|
+
if (!isObjectRecord(result) || !Array.isArray(result.tools)) {
|
|
1404
|
+
addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', 'tools/list result must be an object with a tools array');
|
|
1405
|
+
summary.errorCount = 1;
|
|
1406
|
+
return { summary, issues };
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
summary.toolCount = result.tools.length;
|
|
1410
|
+
const seenNames = new Map();
|
|
1411
|
+
|
|
1412
|
+
for (let index = 0; index < result.tools.length; index += 1) {
|
|
1413
|
+
const tool = result.tools[index];
|
|
1414
|
+
let toolValid = true;
|
|
1415
|
+
|
|
1416
|
+
if (!isObjectRecord(tool)) {
|
|
1417
|
+
addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', `tools[${index}] must be an object`, { toolIndex: index });
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const name = validateToolName(tool, index, issues);
|
|
1422
|
+
toolValid = Boolean(name);
|
|
1423
|
+
if (name) {
|
|
1424
|
+
summary.toolNames.push(name);
|
|
1425
|
+
if (seenNames.has(name)) {
|
|
1426
|
+
const firstToolIndex = seenNames.get(name);
|
|
1427
|
+
toolValid = false;
|
|
1428
|
+
summary.duplicateNames.push(name);
|
|
1429
|
+
addToolSchemaIssue(
|
|
1430
|
+
issues,
|
|
1431
|
+
'error',
|
|
1432
|
+
'tool-name-duplicate',
|
|
1433
|
+
`tools/list returned duplicate tool name ${JSON.stringify(name)}`,
|
|
1434
|
+
{ toolIndex: index, firstToolIndex, toolName: name }
|
|
1435
|
+
);
|
|
1436
|
+
} else {
|
|
1437
|
+
seenNames.set(name, index);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
if (typeof tool.description !== 'string' || !tool.description.trim()) {
|
|
1442
|
+
addToolSchemaIssue(
|
|
1443
|
+
issues,
|
|
1444
|
+
'warning',
|
|
1445
|
+
'tool-description-missing',
|
|
1446
|
+
name
|
|
1447
|
+
? `tool ${JSON.stringify(name)} is missing a non-empty description`
|
|
1448
|
+
: `tools[${index}] is missing a non-empty description`,
|
|
1449
|
+
{ toolIndex: index, toolName: name || '' }
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (!Object.hasOwn(tool, 'inputSchema')) {
|
|
1454
|
+
addToolSchemaIssue(
|
|
1455
|
+
issues,
|
|
1456
|
+
'error',
|
|
1457
|
+
'tool-input-schema-invalid',
|
|
1458
|
+
name
|
|
1459
|
+
? `tool ${JSON.stringify(name)} is missing inputSchema`
|
|
1460
|
+
: `tools[${index}] is missing inputSchema`,
|
|
1461
|
+
{ toolIndex: index, toolName: name || '', schemaPath: 'inputSchema' }
|
|
1462
|
+
);
|
|
1463
|
+
toolValid = false;
|
|
1464
|
+
} else {
|
|
1465
|
+
toolValid = validateInputSchema(tool.inputSchema, {
|
|
1466
|
+
toolIndex: index,
|
|
1467
|
+
toolName: name || '',
|
|
1468
|
+
schemaPath: 'inputSchema'
|
|
1469
|
+
}, issues) && toolValid;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
if (toolValid) {
|
|
1473
|
+
summary.validToolCount += 1;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
summary.duplicateNames = [...new Set(summary.duplicateNames)].sort();
|
|
1478
|
+
summary.toolNames = [...new Set(summary.toolNames)].sort();
|
|
1479
|
+
summary.errorCount = issues.filter((issue) => issue.severity === 'error').length;
|
|
1480
|
+
summary.warningCount = issues.filter((issue) => issue.severity === 'warning').length;
|
|
1481
|
+
return { summary, issues };
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function validateToolName(tool, index, issues) {
|
|
1485
|
+
if (typeof tool.name !== 'string' || !tool.name.trim() || tool.name !== tool.name.trim()) {
|
|
1486
|
+
addToolSchemaIssue(issues, 'error', 'tool-name-invalid', `tools[${index}] name must be a stable non-empty string`, {
|
|
1487
|
+
toolIndex: index,
|
|
1488
|
+
toolName: typeof tool.name === 'string' ? tool.name : ''
|
|
1489
|
+
});
|
|
1490
|
+
return '';
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
return tool.name;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function validateInputSchema(schema, context, issues) {
|
|
1497
|
+
return validateSchemaValue(schema, context, issues);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function validateSchemaValue(schema, context, issues) {
|
|
1501
|
+
if (typeof schema === 'boolean') {
|
|
1502
|
+
return true;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
return validateSchemaObject(schema, context, issues);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function validateSchemaObject(schema, context, issues) {
|
|
1509
|
+
if (!isObjectRecord(schema)) {
|
|
1510
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a JSON Schema object or boolean schema`, context);
|
|
1511
|
+
return false;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
let valid = true;
|
|
1515
|
+
|
|
1516
|
+
if (Object.hasOwn(schema, 'type') && !isValidJsonSchemaType(schema.type)) {
|
|
1517
|
+
valid = false;
|
|
1518
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.type must be a string or array of strings`, context);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
if (Object.hasOwn(schema, 'properties')) {
|
|
1522
|
+
if (!isObjectRecord(schema.properties)) {
|
|
1523
|
+
valid = false;
|
|
1524
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.properties must be an object`, context);
|
|
1525
|
+
} else {
|
|
1526
|
+
for (const [propertyName, propertySchema] of Object.entries(schema.properties)) {
|
|
1527
|
+
valid = validateSchemaValue(propertySchema, {
|
|
1528
|
+
...context,
|
|
1529
|
+
schemaPath: `${context.schemaPath}.properties.${propertyName}`
|
|
1530
|
+
}, issues) && valid;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
if (Object.hasOwn(schema, 'required')) {
|
|
1536
|
+
valid = validateRequired(schema, context, issues) && valid;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
if (Object.hasOwn(schema, 'items')) {
|
|
1540
|
+
valid = validateSchemaValueOrArray(schema.items, {
|
|
1541
|
+
...context,
|
|
1542
|
+
schemaPath: `${context.schemaPath}.items`
|
|
1543
|
+
}, issues) && valid;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
if (Object.hasOwn(schema, 'additionalProperties') && typeof schema.additionalProperties !== 'boolean') {
|
|
1547
|
+
valid = validateSchemaObject(schema.additionalProperties, {
|
|
1548
|
+
...context,
|
|
1549
|
+
schemaPath: `${context.schemaPath}.additionalProperties`
|
|
1550
|
+
}, issues) && valid;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
|
|
1554
|
+
if (Object.hasOwn(schema, keyword)) {
|
|
1555
|
+
valid = validateSchemaArray(schema[keyword], {
|
|
1556
|
+
...context,
|
|
1557
|
+
schemaPath: `${context.schemaPath}.${keyword}`
|
|
1558
|
+
}, issues) && valid;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (Object.hasOwn(schema, 'enum') && !Array.isArray(schema.enum)) {
|
|
1563
|
+
valid = false;
|
|
1564
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.enum must be an array`, context);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
return valid;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function validateSchemaValueOrArray(value, context, issues) {
|
|
1571
|
+
if (Array.isArray(value)) {
|
|
1572
|
+
return validateSchemaArray(value, context, issues);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
return validateSchemaValue(value, context, issues);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function validateSchemaArray(value, context, issues) {
|
|
1579
|
+
if (!Array.isArray(value) || !value.length) {
|
|
1580
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a non-empty array of schemas`, context);
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
let valid = true;
|
|
1585
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1586
|
+
valid = validateSchemaValue(value[index], {
|
|
1587
|
+
...context,
|
|
1588
|
+
schemaPath: `${context.schemaPath}[${index}]`
|
|
1589
|
+
}, issues) && valid;
|
|
1590
|
+
}
|
|
1591
|
+
return valid;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function validateRequired(schema, context, issues) {
|
|
1595
|
+
if (!Array.isArray(schema.required) || schema.required.some((entry) => typeof entry !== 'string' || !entry)) {
|
|
1596
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must be an array of non-empty strings`, context);
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
let valid = true;
|
|
1601
|
+
const seen = new Set();
|
|
1602
|
+
for (const propertyName of schema.required) {
|
|
1603
|
+
if (seen.has(propertyName)) {
|
|
1604
|
+
valid = false;
|
|
1605
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must not contain duplicate entries`, {
|
|
1606
|
+
...context,
|
|
1607
|
+
propertyName
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
seen.add(propertyName);
|
|
1611
|
+
|
|
1612
|
+
if (!isObjectRecord(schema.properties) || !Object.hasOwn(schema.properties, propertyName)) {
|
|
1613
|
+
valid = false;
|
|
1614
|
+
addToolSchemaIssue(
|
|
1615
|
+
issues,
|
|
1616
|
+
'error',
|
|
1617
|
+
'tool-input-schema-required-missing',
|
|
1618
|
+
`${context.schemaPath}.required references missing property ${JSON.stringify(propertyName)}`,
|
|
1619
|
+
{ ...context, propertyName }
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
return valid;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function isValidJsonSchemaType(value) {
|
|
1628
|
+
if (typeof value === 'string') {
|
|
1629
|
+
return JSON_SCHEMA_TYPES.has(value);
|
|
1630
|
+
}
|
|
1631
|
+
return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === 'string' && JSON_SCHEMA_TYPES.has(entry));
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function addToolSchemaIssue(issues, severity, code, message, details = {}) {
|
|
1635
|
+
issues.push({
|
|
1636
|
+
severity,
|
|
1637
|
+
code,
|
|
1638
|
+
message,
|
|
1639
|
+
details
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function isObjectRecord(value) {
|
|
1644
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
export function classifyIssueCode(code) {
|
|
1648
|
+
return ISSUE_CLASS_BY_CODE.get(code) ?? ISSUE_CLASSES.MCP_PROTOCOL;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
export function createFingerprint(commandWithArgs, options = {}) {
|
|
1652
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
1653
|
+
const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
|
|
1654
|
+
|
|
1655
|
+
return {
|
|
1656
|
+
guard: {
|
|
1657
|
+
name: 'mcp-stdio-guard',
|
|
1658
|
+
version: VERSION
|
|
1659
|
+
},
|
|
1660
|
+
command: {
|
|
1661
|
+
executable: commandWithArgs[0] || '',
|
|
1662
|
+
args: redactArgv(commandWithArgs.slice(1)),
|
|
1663
|
+
argv: redactArgv(commandWithArgs)
|
|
1664
|
+
},
|
|
1665
|
+
cwd: {
|
|
1666
|
+
requested: String(options.cwd ?? process.cwd()),
|
|
1667
|
+
resolved: cwd,
|
|
1668
|
+
exists: fs.existsSync(cwd)
|
|
1669
|
+
},
|
|
1670
|
+
protocol: options.protocol ?? DEFAULT_PROTOCOL,
|
|
1671
|
+
config: options.config ?? defaultConfigMetadata(),
|
|
1672
|
+
profile: options.profile ?? DEFAULT_PROFILE,
|
|
1673
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT,
|
|
1674
|
+
repeat: options.repeat ?? 1,
|
|
1675
|
+
capabilityProbes: options.probeCapabilities ?? true,
|
|
1676
|
+
operation: operations.length === 1
|
|
1677
|
+
? {
|
|
1678
|
+
method: operations[0].method,
|
|
1679
|
+
hasParams: operations[0].params !== undefined,
|
|
1680
|
+
source: operations[0].source,
|
|
1681
|
+
safeToolCallName: operations[0].safeToolCallName
|
|
1682
|
+
}
|
|
1683
|
+
: null,
|
|
1684
|
+
operations: operations.map((operation) => ({
|
|
1685
|
+
method: operation.method,
|
|
1686
|
+
hasParams: operation.params !== undefined,
|
|
1687
|
+
source: operation.source,
|
|
1688
|
+
safeToolCallName: operation.safeToolCallName
|
|
1689
|
+
})),
|
|
1690
|
+
system: {
|
|
1691
|
+
platform: process.platform,
|
|
1692
|
+
arch: process.arch,
|
|
1693
|
+
osRelease: os.release()
|
|
1694
|
+
},
|
|
1695
|
+
runtimes: detectRuntimeVersions(commandWithArgs),
|
|
1696
|
+
package: detectPackageMetadata(commandWithArgs, cwd),
|
|
1697
|
+
env: redactEnvMetadata(options.env ?? {}),
|
|
1698
|
+
staticScan: defaultStaticScan(),
|
|
1699
|
+
timings: {
|
|
1700
|
+
startupMs: null,
|
|
1701
|
+
totalMs: null
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function redactEnvMetadata(env) {
|
|
1707
|
+
const names = Object.keys(env).sort();
|
|
1708
|
+
return {
|
|
1709
|
+
inherited: true,
|
|
1710
|
+
names,
|
|
1711
|
+
values: Object.fromEntries(names.map((name) => [name, REDACTED]))
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function redactArgv(argv) {
|
|
1716
|
+
const redacted = [];
|
|
1717
|
+
let redactNext = false;
|
|
1718
|
+
|
|
1719
|
+
for (const arg of argv) {
|
|
1720
|
+
if (redactNext) {
|
|
1721
|
+
redacted.push(REDACTED);
|
|
1722
|
+
redactNext = false;
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
const secretAssignment = redactSecretAssignment(arg);
|
|
1727
|
+
if (secretAssignment) {
|
|
1728
|
+
redacted.push(secretAssignment);
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
redacted.push(arg);
|
|
1733
|
+
if (isSecretFlag(arg)) {
|
|
1734
|
+
redactNext = true;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
return redacted;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function redactSecretAssignment(arg) {
|
|
1742
|
+
const match = /^([^=\s]+)=(.*)$/.exec(arg);
|
|
1743
|
+
if (!match) return '';
|
|
1744
|
+
|
|
1745
|
+
const [, name] = match;
|
|
1746
|
+
return isSecretName(name) ? `${name}=${REDACTED}` : '';
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
function isSecretFlag(arg) {
|
|
1750
|
+
if (!arg.startsWith('-')) return false;
|
|
1751
|
+
const name = arg.replace(/^-+/, '').split(/[=\s]/)[0];
|
|
1752
|
+
return isSecretName(name);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function isSecretName(name) {
|
|
1756
|
+
return /(^|[-_])(api[-_]?key|auth|bearer|cookie|credential|password|passwd|private[-_]?key|pwd|secret|session|token)([-_]|$)/i.test(name);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
function detectRuntimeVersions(commandWithArgs) {
|
|
1760
|
+
const command = commandWithArgs[0] || '';
|
|
1761
|
+
const base = path.basename(command).toLowerCase();
|
|
1762
|
+
const runtimes = {
|
|
1763
|
+
node: {
|
|
1764
|
+
version: process.version,
|
|
1765
|
+
role: isNodeCommand(command) ? 'guard-and-target' : 'guard'
|
|
1766
|
+
}
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
if (isPythonCommand(command)) {
|
|
1770
|
+
runtimes.python = executableVersion(command, ['--version']);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
if (isNpmCommand(base) || isNpxCommand(base)) {
|
|
1774
|
+
runtimes.npm = executableVersion('npm', ['--version']);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
if (base === 'uv' || base === 'uvx') {
|
|
1778
|
+
runtimes.uv = executableVersion(base === 'uvx' ? 'uv' : command, ['--version']);
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
if (base === 'docker') {
|
|
1782
|
+
runtimes.docker = executableVersion(command, ['--version']);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
return runtimes;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function executableVersion(command, args) {
|
|
1789
|
+
const cacheKey = JSON.stringify([command, args]);
|
|
1790
|
+
if (VERSION_PROBE_CACHE.has(cacheKey)) {
|
|
1791
|
+
return { ...VERSION_PROBE_CACHE.get(cacheKey) };
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
const result = spawnSync(command, args, {
|
|
1795
|
+
encoding: 'utf8',
|
|
1796
|
+
timeout: 1000,
|
|
1797
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
1798
|
+
});
|
|
1799
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
1800
|
+
const version = {
|
|
1801
|
+
command,
|
|
1802
|
+
version: output.split(/\r?\n/)[0] || '',
|
|
1803
|
+
available: !result.error && result.status === 0 && result.signal === null,
|
|
1804
|
+
status: result.status,
|
|
1805
|
+
signal: result.signal
|
|
1806
|
+
};
|
|
1807
|
+
VERSION_PROBE_CACHE.set(cacheKey, version);
|
|
1808
|
+
return { ...version };
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function detectPackageMetadata(commandWithArgs, cwd) {
|
|
1812
|
+
const command = commandWithArgs[0] || '';
|
|
1813
|
+
const args = commandWithArgs.slice(1);
|
|
1814
|
+
const base = path.basename(command).toLowerCase();
|
|
1815
|
+
|
|
1816
|
+
if (isNpxCommand(base)) {
|
|
1817
|
+
return packageFromSpec('npm', firstPackageSpec(args));
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
if (isNpmCommand(base)) {
|
|
1821
|
+
const subcommand = args[0] || '';
|
|
1822
|
+
if (subcommand === 'exec' || subcommand === 'x') {
|
|
1823
|
+
return packageFromSpec('npm', firstPackageSpec(args.slice(1)));
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
if (base === 'uvx') {
|
|
1828
|
+
return packageFromSpec('uv', firstPackageSpec(args));
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
if (base === 'docker') {
|
|
1832
|
+
const image = dockerImageSpec(args);
|
|
1833
|
+
return image ? { manager: 'docker', name: image, versionSpec: '' } : null;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
if (isNodeCommand(command)) {
|
|
1837
|
+
const entrypoint = nodeEntrypointArg(args);
|
|
1838
|
+
return entrypoint ? localPackageMetadata(path.resolve(cwd, entrypoint)) : null;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return null;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function firstPackageSpec(args) {
|
|
1845
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1846
|
+
const arg = args[index];
|
|
1847
|
+
if (arg === '--') continue;
|
|
1848
|
+
|
|
1849
|
+
if (arg.startsWith('--package=')) {
|
|
1850
|
+
return arg.slice('--package='.length);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
if (arg === '--package' || arg === '-p') {
|
|
1854
|
+
return args[index + 1] || '';
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
if (arg.startsWith('-')) {
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
return arg;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
return '';
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function nodeEntrypointArg(args) {
|
|
1868
|
+
let afterSeparator = false;
|
|
1869
|
+
|
|
1870
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1871
|
+
const arg = args[index];
|
|
1872
|
+
|
|
1873
|
+
if (!afterSeparator && arg === '--') {
|
|
1874
|
+
afterSeparator = true;
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
if (!afterSeparator && arg.startsWith('-')) {
|
|
1879
|
+
const optionName = arg.split('=')[0];
|
|
1880
|
+
if (NODE_EVAL_OPTIONS.has(optionName)) {
|
|
1881
|
+
return '';
|
|
1882
|
+
}
|
|
1883
|
+
if (NODE_OPTIONS_WITH_VALUES.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
|
|
1884
|
+
index += 1;
|
|
1885
|
+
}
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
return arg;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
return '';
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function packageFromSpec(manager, spec) {
|
|
1896
|
+
if (!spec) return null;
|
|
1897
|
+
const parsed = parsePackageSpec(spec);
|
|
1898
|
+
return {
|
|
1899
|
+
manager,
|
|
1900
|
+
name: parsed.name,
|
|
1901
|
+
versionSpec: parsed.versionSpec
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function parsePackageSpec(spec) {
|
|
1906
|
+
if (spec.startsWith('@')) {
|
|
1907
|
+
const versionAt = spec.indexOf('@', 1);
|
|
1908
|
+
if (versionAt > -1) {
|
|
1909
|
+
return {
|
|
1910
|
+
name: spec.slice(0, versionAt),
|
|
1911
|
+
versionSpec: spec.slice(versionAt + 1)
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
return { name: spec, versionSpec: '' };
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
const versionAt = spec.lastIndexOf('@');
|
|
1918
|
+
if (versionAt > 0) {
|
|
1919
|
+
return {
|
|
1920
|
+
name: spec.slice(0, versionAt),
|
|
1921
|
+
versionSpec: spec.slice(versionAt + 1)
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
return { name: spec, versionSpec: '' };
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function dockerImageSpec(args) {
|
|
1929
|
+
const runIndex = args.indexOf('run');
|
|
1930
|
+
if (runIndex === -1) return '';
|
|
1931
|
+
const optionsWithValues = new Set([
|
|
1932
|
+
'-e',
|
|
1933
|
+
'--env',
|
|
1934
|
+
'-h',
|
|
1935
|
+
'--hostname',
|
|
1936
|
+
'-p',
|
|
1937
|
+
'--publish',
|
|
1938
|
+
'-u',
|
|
1939
|
+
'--user',
|
|
1940
|
+
'-v',
|
|
1941
|
+
'--volume',
|
|
1942
|
+
'-w',
|
|
1943
|
+
'--workdir',
|
|
1944
|
+
'--entrypoint',
|
|
1945
|
+
'--name',
|
|
1946
|
+
'--network',
|
|
1947
|
+
'--platform'
|
|
1948
|
+
]);
|
|
1949
|
+
|
|
1950
|
+
for (let index = runIndex + 1; index < args.length; index += 1) {
|
|
1951
|
+
const arg = args[index];
|
|
1952
|
+
if (arg === '--') continue;
|
|
1953
|
+
if (arg.startsWith('-')) {
|
|
1954
|
+
const optionName = arg.split('=')[0];
|
|
1955
|
+
if (optionsWithValues.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
|
|
1956
|
+
index += 1;
|
|
1957
|
+
}
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
return arg;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
return '';
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function localPackageMetadata(entry) {
|
|
1967
|
+
const packageJson = findNearestPackageJson(fs.existsSync(entry) && fs.statSync(entry).isDirectory() ? entry : path.dirname(entry));
|
|
1968
|
+
if (!packageJson) return null;
|
|
1969
|
+
|
|
1970
|
+
try {
|
|
1971
|
+
const parsed = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
|
|
1972
|
+
return {
|
|
1973
|
+
manager: 'local',
|
|
1974
|
+
name: typeof parsed.name === 'string' ? parsed.name : '',
|
|
1975
|
+
versionSpec: typeof parsed.version === 'string' ? parsed.version : ''
|
|
1976
|
+
};
|
|
1977
|
+
} catch {
|
|
1978
|
+
return null;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function findNearestPackageJson(start) {
|
|
1983
|
+
let dir = path.resolve(start);
|
|
1984
|
+
while (dir !== path.dirname(dir)) {
|
|
1985
|
+
const candidate = path.join(dir, 'package.json');
|
|
1986
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
1987
|
+
dir = path.dirname(dir);
|
|
1988
|
+
}
|
|
1989
|
+
return '';
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function isNodeCommand(command) {
|
|
1993
|
+
const base = path.basename(command).toLowerCase();
|
|
1994
|
+
return base === 'node' || base === 'node.exe' || command === process.execPath;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
function isPythonCommand(command) {
|
|
1998
|
+
const base = path.basename(command).toLowerCase();
|
|
1999
|
+
return /^python(?:\d+(?:\.\d+)*)?(?:\.exe)?$/.test(base);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function isNpmCommand(base) {
|
|
2003
|
+
return base === 'npm' || base === 'npm.cmd' || base === 'npm-cli.js';
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function isNpxCommand(base) {
|
|
2007
|
+
return base === 'npx' || base === 'npx.cmd';
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
function finalizeResult(result) {
|
|
2011
|
+
result.schemaVersion = JSON_SCHEMA_VERSION;
|
|
2012
|
+
result.config ??= defaultConfigMetadata();
|
|
2013
|
+
result.profile ??= DEFAULT_PROFILE;
|
|
2014
|
+
result.capabilityProbes ??= true;
|
|
2015
|
+
result.operations ??= result.operation ? [{
|
|
2016
|
+
index: 0,
|
|
2017
|
+
source: result.operation.source ?? 'request',
|
|
2018
|
+
method: result.operation.method,
|
|
2019
|
+
safeToolCallName: result.operation.safeToolCallName ?? '',
|
|
2020
|
+
responded: Boolean(result.operation.responded),
|
|
2021
|
+
error: result.operation.error ?? null
|
|
2022
|
+
}] : [];
|
|
2023
|
+
result.staticScan ??= defaultStaticScan();
|
|
2024
|
+
result.staticFindings ??= [];
|
|
2025
|
+
result.issues = normalizeIssues(result.issues ?? []);
|
|
2026
|
+
result.ok = !result.issues.some((issue) => issue.severity === 'error');
|
|
2027
|
+
result.checks = buildChecks(result);
|
|
2028
|
+
result.issueClasses = buildIssueClasses(result.issues);
|
|
2029
|
+
finalizeFingerprint(result);
|
|
2030
|
+
return result;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function finalizeFingerprint(result) {
|
|
2034
|
+
if (!result.fingerprint) return;
|
|
2035
|
+
result.fingerprint.timings ??= {};
|
|
2036
|
+
result.fingerprint.timings.totalMs = result.durationMs ?? result.fingerprint.timings.totalMs ?? null;
|
|
2037
|
+
|
|
2038
|
+
if (Array.isArray(result.runs)) {
|
|
2039
|
+
result.fingerprint.runs = result.runs.map((run) => ({
|
|
2040
|
+
run: run.run,
|
|
2041
|
+
ok: run.ok,
|
|
2042
|
+
startupMs: run.fingerprint?.timings?.startupMs ?? null,
|
|
2043
|
+
totalMs: run.durationMs ?? run.fingerprint?.timings?.totalMs ?? null
|
|
2044
|
+
}));
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function normalizeIssues(issues) {
|
|
2049
|
+
return issues.map((issue) => ({
|
|
2050
|
+
...issue,
|
|
2051
|
+
class: classifyIssueCode(issue.code)
|
|
2052
|
+
}));
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function buildChecks(result) {
|
|
2056
|
+
const issues = result.issues ?? [];
|
|
2057
|
+
const repeated = Array.isArray(result.runs);
|
|
2058
|
+
|
|
2059
|
+
return {
|
|
2060
|
+
initialize: repeated
|
|
526
2061
|
? aggregateRunCheck(result, 'initialize')
|
|
527
2062
|
: buildInitializeCheck(result, issues),
|
|
528
2063
|
stdout: buildIssueCheck(issues, (issue) => STDOUT_ISSUE_CODES.has(issue.code)),
|
|
@@ -530,6 +2065,12 @@ function buildChecks(result) {
|
|
|
530
2065
|
operation: repeated
|
|
531
2066
|
? aggregateRunCheck(result, 'operation')
|
|
532
2067
|
: buildOperationCheck(result, issues),
|
|
2068
|
+
capabilities: repeated
|
|
2069
|
+
? aggregateCapabilityChecks(result)
|
|
2070
|
+
: buildCapabilityChecks(result, issues),
|
|
2071
|
+
toolSchema: repeated
|
|
2072
|
+
? aggregateRunCheck(result, 'toolSchema')
|
|
2073
|
+
: buildToolSchemaCheck(result, issues),
|
|
533
2074
|
process: buildIssueCheck(issues, (issue) => PROCESS_ISSUE_CODES.has(issue.code)),
|
|
534
2075
|
pythonBuffering: buildIssueCheck(issues, (issue) => issue.code === 'python-buffered-stdio'),
|
|
535
2076
|
staticScan: buildStaticScanCheck(result, issues),
|
|
@@ -537,6 +2078,13 @@ function buildChecks(result) {
|
|
|
537
2078
|
};
|
|
538
2079
|
}
|
|
539
2080
|
|
|
2081
|
+
function buildIssueClasses(issues) {
|
|
2082
|
+
return Object.fromEntries(ISSUE_CLASS_NAMES.map((className) => [
|
|
2083
|
+
className,
|
|
2084
|
+
buildIssueCheck(issues, (issue) => issue.class === className)
|
|
2085
|
+
]));
|
|
2086
|
+
}
|
|
2087
|
+
|
|
540
2088
|
function buildInitializeCheck(result, issues) {
|
|
541
2089
|
const matched = issues.filter((issue) => (
|
|
542
2090
|
INITIALIZE_ISSUE_CODES.has(issue.code)
|
|
@@ -548,7 +2096,13 @@ function buildInitializeCheck(result, issues) {
|
|
|
548
2096
|
}
|
|
549
2097
|
|
|
550
2098
|
function buildOperationCheck(result, issues) {
|
|
551
|
-
|
|
2099
|
+
const operations = result.operations?.length
|
|
2100
|
+
? result.operations
|
|
2101
|
+
: result.operation
|
|
2102
|
+
? [result.operation]
|
|
2103
|
+
: [];
|
|
2104
|
+
|
|
2105
|
+
if (!operations.length) {
|
|
552
2106
|
return makeCheck('skipped', []);
|
|
553
2107
|
}
|
|
554
2108
|
|
|
@@ -558,14 +2112,108 @@ function buildOperationCheck(result, issues) {
|
|
|
558
2112
|
|
|
559
2113
|
const matched = issues.filter((issue) => (
|
|
560
2114
|
OPERATION_ISSUE_CODES.has(issue.code)
|
|
561
|
-
|| (
|
|
562
|
-
|| (
|
|
2115
|
+
|| (operations.some((operation) => !operation.responded) && STDOUT_ISSUE_CODES.has(issue.code))
|
|
2116
|
+
|| (operations.some((operation) => !operation.responded) && JSON_RPC_ISSUE_CODES.has(issue.code))
|
|
563
2117
|
));
|
|
564
2118
|
if (matched.length) {
|
|
565
2119
|
return makeCheck(statusFromIssues(matched), matched);
|
|
566
2120
|
}
|
|
567
2121
|
|
|
568
|
-
return makeCheck(
|
|
2122
|
+
return makeCheck(operations.every((operation) => operation.responded) ? 'pass' : 'fail', []);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
function buildToolSchemaCheck(result, issues) {
|
|
2126
|
+
if (!result.toolSchema?.checked) {
|
|
2127
|
+
return makeCheck('skipped', []);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
const matched = issues.filter((issue) => TOOL_SCHEMA_ISSUE_CODES.has(issue.code));
|
|
2131
|
+
if (matched.length) {
|
|
2132
|
+
return makeCheck(statusFromIssues(matched), matched);
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
return makeCheck('pass', []);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function buildCapabilityChecks(result, issues) {
|
|
2139
|
+
const checks = {};
|
|
2140
|
+
for (const definition of CAPABILITY_DEFINITIONS) {
|
|
2141
|
+
const state = result.capabilityChecks?.[definition.name] ?? defaultCapabilityChecks()[definition.name];
|
|
2142
|
+
const matched = issues.filter((issue) => (
|
|
2143
|
+
CAPABILITY_ISSUE_CODES.has(issue.code)
|
|
2144
|
+
&& issue.capability === definition.name
|
|
2145
|
+
));
|
|
2146
|
+
if (!result.initialized || !state.advertised || result.capabilityProbes === false) {
|
|
2147
|
+
checks[definition.name] = makeCapabilityCheck('skipped', matched, state);
|
|
2148
|
+
} else if (matched.length) {
|
|
2149
|
+
checks[definition.name] = makeCapabilityCheck(statusFromIssues(matched), matched, state);
|
|
2150
|
+
} else {
|
|
2151
|
+
checks[definition.name] = makeCapabilityCheck(state.responded ? 'pass' : 'fail', matched, state);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
const active = Object.values(checks).filter((check) => check.status !== 'skipped');
|
|
2156
|
+
const status = active.length
|
|
2157
|
+
? active.some((check) => check.status === 'fail')
|
|
2158
|
+
? 'fail'
|
|
2159
|
+
: active.some((check) => check.status === 'warning')
|
|
2160
|
+
? 'warning'
|
|
2161
|
+
: 'pass'
|
|
2162
|
+
: 'skipped';
|
|
2163
|
+
return {
|
|
2164
|
+
status,
|
|
2165
|
+
issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
|
|
2166
|
+
...checks
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
function aggregateCapabilityChecks(result) {
|
|
2171
|
+
const checks = {};
|
|
2172
|
+
for (const definition of CAPABILITY_DEFINITIONS) {
|
|
2173
|
+
const runChecks = result.runs
|
|
2174
|
+
.map((run) => run.checks?.capabilities?.[definition.name])
|
|
2175
|
+
.filter(Boolean);
|
|
2176
|
+
const activeRunChecks = runChecks.filter((check) => check.status !== 'skipped');
|
|
2177
|
+
const state = aggregateCapabilityState(definition, runChecks);
|
|
2178
|
+
if (!activeRunChecks.length) {
|
|
2179
|
+
checks[definition.name] = makeAggregatedCapabilityCheck('skipped', [], state);
|
|
2180
|
+
} else {
|
|
2181
|
+
const status = activeRunChecks.some((check) => check.status === 'fail')
|
|
2182
|
+
? 'fail'
|
|
2183
|
+
: activeRunChecks.some((check) => check.status === 'warning')
|
|
2184
|
+
? 'warning'
|
|
2185
|
+
: 'pass';
|
|
2186
|
+
checks[definition.name] = makeAggregatedCapabilityCheck(status, activeRunChecks, state);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
const active = Object.values(checks).filter((check) => check.status !== 'skipped');
|
|
2191
|
+
const status = active.length
|
|
2192
|
+
? active.some((check) => check.status === 'fail')
|
|
2193
|
+
? 'fail'
|
|
2194
|
+
: active.some((check) => check.status === 'warning')
|
|
2195
|
+
? 'warning'
|
|
2196
|
+
: 'pass'
|
|
2197
|
+
: 'skipped';
|
|
2198
|
+
return {
|
|
2199
|
+
status,
|
|
2200
|
+
issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
|
|
2201
|
+
...checks
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function aggregateCapabilityState(definition, runChecks) {
|
|
2206
|
+
const itemCounts = [...new Set(
|
|
2207
|
+
runChecks
|
|
2208
|
+
.map((check) => check.itemCount)
|
|
2209
|
+
.filter((itemCount) => Number.isInteger(itemCount))
|
|
2210
|
+
)];
|
|
2211
|
+
return {
|
|
2212
|
+
advertised: runChecks.some((check) => check.advertised),
|
|
2213
|
+
method: definition.method,
|
|
2214
|
+
responded: runChecks.some((check) => check.responded),
|
|
2215
|
+
itemCount: itemCounts.length === 1 ? itemCounts[0] : null
|
|
2216
|
+
};
|
|
569
2217
|
}
|
|
570
2218
|
|
|
571
2219
|
function buildStaticScanCheck(result, issues) {
|
|
@@ -591,8 +2239,16 @@ function buildRepeatCheck(result) {
|
|
|
591
2239
|
}
|
|
592
2240
|
|
|
593
2241
|
const failedRuns = result.runs.filter((run) => !run.ok).map((run) => run.run);
|
|
2242
|
+
const repeatIssues = (result.issues ?? []).filter((issue) => (
|
|
2243
|
+
issue.run || REPEAT_DRIFT_ISSUE_CODES.has(issue.code)
|
|
2244
|
+
));
|
|
2245
|
+
const status = failedRuns.length
|
|
2246
|
+
? 'fail'
|
|
2247
|
+
: repeatIssues.some((issue) => issue.severity === 'warning')
|
|
2248
|
+
? 'warning'
|
|
2249
|
+
: 'pass';
|
|
594
2250
|
return {
|
|
595
|
-
...makeCheck(
|
|
2251
|
+
...makeCheck(status, repeatIssues),
|
|
596
2252
|
runs: result.runs.length,
|
|
597
2253
|
passedRuns: result.runs.length - failedRuns.length,
|
|
598
2254
|
failedRuns
|
|
@@ -634,6 +2290,42 @@ function makeCheck(status, issues) {
|
|
|
634
2290
|
};
|
|
635
2291
|
}
|
|
636
2292
|
|
|
2293
|
+
function makeCapabilityCheck(status, issues, state) {
|
|
2294
|
+
return {
|
|
2295
|
+
...makeCheck(status, issues),
|
|
2296
|
+
advertised: Boolean(state.advertised),
|
|
2297
|
+
method: state.method,
|
|
2298
|
+
responded: Boolean(state.responded),
|
|
2299
|
+
itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function makeAggregatedCapabilityCheck(status, checks, state) {
|
|
2304
|
+
return {
|
|
2305
|
+
status,
|
|
2306
|
+
issueCodes: [...new Set(checks.flatMap((check) => check.issueCodes ?? []))].sort(),
|
|
2307
|
+
advertised: Boolean(state.advertised),
|
|
2308
|
+
method: state.method,
|
|
2309
|
+
responded: Boolean(state.responded),
|
|
2310
|
+
itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
function defaultConfigMetadata() {
|
|
2315
|
+
return {
|
|
2316
|
+
enabled: false,
|
|
2317
|
+
path: '',
|
|
2318
|
+
resolvedPath: '',
|
|
2319
|
+
checks: {
|
|
2320
|
+
command: false,
|
|
2321
|
+
cwd: false,
|
|
2322
|
+
envNames: [],
|
|
2323
|
+
requests: [],
|
|
2324
|
+
safeToolCalls: []
|
|
2325
|
+
}
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
|
|
637
2329
|
function defaultStaticScan() {
|
|
638
2330
|
return {
|
|
639
2331
|
enabled: false,
|
|
@@ -642,6 +2334,253 @@ function defaultStaticScan() {
|
|
|
642
2334
|
};
|
|
643
2335
|
}
|
|
644
2336
|
|
|
2337
|
+
function defaultToolSchemaValidation() {
|
|
2338
|
+
return {
|
|
2339
|
+
checked: false,
|
|
2340
|
+
toolCount: 0,
|
|
2341
|
+
toolNames: [],
|
|
2342
|
+
validToolCount: 0,
|
|
2343
|
+
warningCount: 0,
|
|
2344
|
+
errorCount: 0,
|
|
2345
|
+
duplicateNames: []
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
function aggregateRunToolSchemaValidation(runs) {
|
|
2350
|
+
const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
|
|
2351
|
+
if (!checkedRuns.length) {
|
|
2352
|
+
return defaultToolSchemaValidation();
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
return {
|
|
2356
|
+
checked: true,
|
|
2357
|
+
runs: checkedRuns.length,
|
|
2358
|
+
toolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.toolCount, 0),
|
|
2359
|
+
validToolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.validToolCount, 0),
|
|
2360
|
+
warningCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.warningCount, 0),
|
|
2361
|
+
errorCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.errorCount, 0),
|
|
2362
|
+
toolNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.toolNames ?? []))].sort(),
|
|
2363
|
+
duplicateNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.duplicateNames))].sort()
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function defaultRepeatDrift() {
|
|
2368
|
+
return {
|
|
2369
|
+
checked: false,
|
|
2370
|
+
status: 'skipped',
|
|
2371
|
+
issueCodes: [],
|
|
2372
|
+
baselineRun: null,
|
|
2373
|
+
comparedRuns: [],
|
|
2374
|
+
negotiatedProtocol: driftSection(),
|
|
2375
|
+
capabilities: driftSection(),
|
|
2376
|
+
tools: driftSection(),
|
|
2377
|
+
lists: {
|
|
2378
|
+
resources: driftSection(),
|
|
2379
|
+
prompts: driftSection()
|
|
2380
|
+
}
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
function driftSection() {
|
|
2385
|
+
return {
|
|
2386
|
+
status: 'skipped',
|
|
2387
|
+
issueCodes: [],
|
|
2388
|
+
baseline: null,
|
|
2389
|
+
values: [],
|
|
2390
|
+
changedRuns: []
|
|
2391
|
+
};
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function buildRepeatDrift(runs) {
|
|
2395
|
+
const drift = defaultRepeatDrift();
|
|
2396
|
+
if (!Array.isArray(runs) || runs.length < 2) {
|
|
2397
|
+
return drift;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
drift.checked = true;
|
|
2401
|
+
const comparableRuns = runs.filter((run) => run.initialized);
|
|
2402
|
+
drift.comparedRuns = comparableRuns.map((run) => run.run);
|
|
2403
|
+
if (comparableRuns.length < 2) {
|
|
2404
|
+
return drift;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
drift.baselineRun = comparableRuns[0].run;
|
|
2408
|
+
drift.negotiatedProtocol = compareScalarDrift(
|
|
2409
|
+
comparableRuns,
|
|
2410
|
+
(run) => run.negotiatedProtocol || '',
|
|
2411
|
+
'repeat-protocol-drift'
|
|
2412
|
+
);
|
|
2413
|
+
drift.capabilities = compareSetDrift(
|
|
2414
|
+
comparableRuns,
|
|
2415
|
+
(run) => run.capabilityKeys ?? advertisedCapabilityKeys(run.capabilityChecks),
|
|
2416
|
+
'repeat-capability-drift'
|
|
2417
|
+
);
|
|
2418
|
+
drift.tools = compareToolDrift(comparableRuns);
|
|
2419
|
+
drift.lists.resources = compareListShapeDrift(comparableRuns, 'resources');
|
|
2420
|
+
drift.lists.prompts = compareListShapeDrift(comparableRuns, 'prompts');
|
|
2421
|
+
|
|
2422
|
+
const sections = [
|
|
2423
|
+
drift.negotiatedProtocol,
|
|
2424
|
+
drift.capabilities,
|
|
2425
|
+
drift.tools,
|
|
2426
|
+
drift.lists.resources,
|
|
2427
|
+
drift.lists.prompts
|
|
2428
|
+
];
|
|
2429
|
+
const active = sections.filter((section) => section.status !== 'skipped');
|
|
2430
|
+
drift.status = active.length
|
|
2431
|
+
? active.some((section) => section.status === 'warning')
|
|
2432
|
+
? 'warning'
|
|
2433
|
+
: 'pass'
|
|
2434
|
+
: 'skipped';
|
|
2435
|
+
drift.issueCodes = [...new Set(active.flatMap((section) => section.issueCodes))].sort();
|
|
2436
|
+
return drift;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function compareScalarDrift(runs, valueForRun, issueCode) {
|
|
2440
|
+
const section = driftSection();
|
|
2441
|
+
section.values = runs.map((run) => ({
|
|
2442
|
+
run: run.run,
|
|
2443
|
+
value: valueForRun(run)
|
|
2444
|
+
}));
|
|
2445
|
+
section.baseline = section.values[0].value;
|
|
2446
|
+
section.changedRuns = section.values
|
|
2447
|
+
.slice(1)
|
|
2448
|
+
.filter((entry) => entry.value !== section.baseline)
|
|
2449
|
+
.map((entry) => ({
|
|
2450
|
+
run: entry.run,
|
|
2451
|
+
expected: section.baseline,
|
|
2452
|
+
actual: entry.value
|
|
2453
|
+
}));
|
|
2454
|
+
section.status = section.changedRuns.length ? 'warning' : 'pass';
|
|
2455
|
+
section.issueCodes = section.changedRuns.length ? [issueCode] : [];
|
|
2456
|
+
return section;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
function compareSetDrift(runs, valuesForRun, issueCode) {
|
|
2460
|
+
const section = driftSection();
|
|
2461
|
+
section.values = runs.map((run) => ({
|
|
2462
|
+
run: run.run,
|
|
2463
|
+
values: sortedUnique(valuesForRun(run))
|
|
2464
|
+
}));
|
|
2465
|
+
section.baseline = section.values[0].values;
|
|
2466
|
+
section.changedRuns = section.values
|
|
2467
|
+
.slice(1)
|
|
2468
|
+
.map((entry) => ({
|
|
2469
|
+
run: entry.run,
|
|
2470
|
+
added: arrayDifference(entry.values, section.baseline),
|
|
2471
|
+
removed: arrayDifference(section.baseline, entry.values)
|
|
2472
|
+
}))
|
|
2473
|
+
.filter((entry) => entry.added.length || entry.removed.length);
|
|
2474
|
+
section.status = section.changedRuns.length ? 'warning' : 'pass';
|
|
2475
|
+
section.issueCodes = section.changedRuns.length ? [issueCode] : [];
|
|
2476
|
+
return section;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
function compareToolDrift(runs) {
|
|
2480
|
+
const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
|
|
2481
|
+
if (checkedRuns.length < 2) {
|
|
2482
|
+
return driftSection();
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
const section = compareSetDrift(
|
|
2486
|
+
checkedRuns,
|
|
2487
|
+
(run) => run.toolSchema.toolNames ?? [],
|
|
2488
|
+
'repeat-tool-drift'
|
|
2489
|
+
);
|
|
2490
|
+
section.values = checkedRuns.map((run) => ({
|
|
2491
|
+
run: run.run,
|
|
2492
|
+
count: run.toolSchema.toolCount,
|
|
2493
|
+
values: sortedUnique(run.toolSchema.toolNames ?? [])
|
|
2494
|
+
}));
|
|
2495
|
+
section.baselineCount = section.values[0].count;
|
|
2496
|
+
const countDrift = section.values
|
|
2497
|
+
.slice(1)
|
|
2498
|
+
.filter((entry) => entry.count !== section.baselineCount)
|
|
2499
|
+
.map((entry) => ({
|
|
2500
|
+
run: entry.run,
|
|
2501
|
+
expected: section.baselineCount,
|
|
2502
|
+
actual: entry.count
|
|
2503
|
+
}));
|
|
2504
|
+
if (countDrift.length) {
|
|
2505
|
+
section.countChangedRuns = countDrift;
|
|
2506
|
+
section.status = 'warning';
|
|
2507
|
+
section.issueCodes = ['repeat-tool-drift'];
|
|
2508
|
+
}
|
|
2509
|
+
return section;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function compareListShapeDrift(runs, capability) {
|
|
2513
|
+
const checkedRuns = runs.filter((run) => Number.isInteger(run.capabilityChecks?.[capability]?.itemCount));
|
|
2514
|
+
if (checkedRuns.length < 2) {
|
|
2515
|
+
return driftSection();
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
const section = compareScalarDrift(
|
|
2519
|
+
checkedRuns,
|
|
2520
|
+
(run) => run.capabilityChecks[capability].itemCount,
|
|
2521
|
+
'repeat-list-shape-drift'
|
|
2522
|
+
);
|
|
2523
|
+
section.capability = capability;
|
|
2524
|
+
return section;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
function repeatDriftIssues(drift) {
|
|
2528
|
+
if (!drift?.checked || drift.status !== 'warning') {
|
|
2529
|
+
return [];
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
const issues = [];
|
|
2533
|
+
if (drift.negotiatedProtocol.status === 'warning') {
|
|
2534
|
+
issues.push(repeatDriftIssue('repeat-protocol-drift', 'negotiated protocol changed across repeat runs', {
|
|
2535
|
+
drift: drift.negotiatedProtocol
|
|
2536
|
+
}));
|
|
2537
|
+
}
|
|
2538
|
+
if (drift.capabilities.status === 'warning') {
|
|
2539
|
+
issues.push(repeatDriftIssue('repeat-capability-drift', 'advertised capability keys changed across repeat runs', {
|
|
2540
|
+
drift: drift.capabilities
|
|
2541
|
+
}));
|
|
2542
|
+
}
|
|
2543
|
+
if (drift.tools.status === 'warning') {
|
|
2544
|
+
issues.push(repeatDriftIssue('repeat-tool-drift', 'tools/list tool names or counts changed across repeat runs', {
|
|
2545
|
+
drift: drift.tools
|
|
2546
|
+
}));
|
|
2547
|
+
}
|
|
2548
|
+
for (const capability of ['resources', 'prompts']) {
|
|
2549
|
+
if (drift.lists[capability].status === 'warning') {
|
|
2550
|
+
issues.push(repeatDriftIssue('repeat-list-shape-drift', `${capability}/list item count changed across repeat runs`, {
|
|
2551
|
+
capability,
|
|
2552
|
+
drift: drift.lists[capability]
|
|
2553
|
+
}));
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
return issues;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function repeatDriftIssue(code, message, details) {
|
|
2560
|
+
return {
|
|
2561
|
+
severity: 'warning',
|
|
2562
|
+
code,
|
|
2563
|
+
message,
|
|
2564
|
+
...details
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
function advertisedCapabilityKeys(capabilityChecks = {}) {
|
|
2569
|
+
return Object.entries(capabilityChecks)
|
|
2570
|
+
.filter(([, check]) => check?.advertised)
|
|
2571
|
+
.map(([name]) => name)
|
|
2572
|
+
.sort();
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
function sortedUnique(values) {
|
|
2576
|
+
return [...new Set((values ?? []).filter((value) => typeof value === 'string'))].sort();
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
function arrayDifference(left, right) {
|
|
2580
|
+
const rightSet = new Set(right);
|
|
2581
|
+
return left.filter((value) => !rightSet.has(value));
|
|
2582
|
+
}
|
|
2583
|
+
|
|
645
2584
|
export function scanSource(root) {
|
|
646
2585
|
const findings = [];
|
|
647
2586
|
const absoluteRoot = path.resolve(root);
|
|
@@ -651,12 +2590,14 @@ export function scanSource(root) {
|
|
|
651
2590
|
const text = fs.readFileSync(file, 'utf8');
|
|
652
2591
|
const lines = text.split(/\r?\n/);
|
|
653
2592
|
for (let index = 0; index < lines.length; index += 1) {
|
|
654
|
-
const
|
|
655
|
-
if (
|
|
2593
|
+
const finding = detectStdoutRisk(file, lines[index]);
|
|
2594
|
+
if (finding) {
|
|
656
2595
|
findings.push({
|
|
657
2596
|
file: path.relative(process.cwd(), file).split(path.sep).join('/'),
|
|
658
2597
|
line: index + 1,
|
|
659
|
-
|
|
2598
|
+
language: finding.language,
|
|
2599
|
+
reason: finding.reason,
|
|
2600
|
+
message: finding.message
|
|
660
2601
|
});
|
|
661
2602
|
}
|
|
662
2603
|
}
|
|
@@ -665,34 +2606,254 @@ export function scanSource(root) {
|
|
|
665
2606
|
return findings;
|
|
666
2607
|
}
|
|
667
2608
|
|
|
668
|
-
function
|
|
669
|
-
const ext = path.extname(file);
|
|
2609
|
+
function detectStdoutRisk(file, line) {
|
|
2610
|
+
const ext = path.extname(file).toLowerCase();
|
|
670
2611
|
const stripped = line.trim();
|
|
671
2612
|
if (!stripped || stripped.startsWith('//') || stripped.startsWith('#')) return '';
|
|
2613
|
+
const code = normalizeSourceLine(line, ext);
|
|
2614
|
+
|
|
2615
|
+
if (isJavaScriptSource(ext)) {
|
|
2616
|
+
if (/\bconsole\.(log|info)\s*\(/.test(code)) {
|
|
2617
|
+
return staticFinding(
|
|
2618
|
+
'javascript',
|
|
2619
|
+
'javascript-console-stdout',
|
|
2620
|
+
'console.log/info writes to stdout; use console.error for MCP stdio diagnostics'
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
if (/\bprocess\.stdout\.write\s*\(/.test(code)) {
|
|
2625
|
+
return staticFinding(
|
|
2626
|
+
'javascript',
|
|
2627
|
+
'javascript-process-stdout-write',
|
|
2628
|
+
'direct process.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
if (/\bprocess\.stdout\.(clearLine|cursorTo|moveCursor)\s*\(/.test(code)) {
|
|
2633
|
+
return staticFinding(
|
|
2634
|
+
'javascript',
|
|
2635
|
+
'javascript-stdout-terminal-control',
|
|
2636
|
+
'process.stdout terminal control writes to stdout; keep MCP stdout reserved for JSON-RPC frames'
|
|
2637
|
+
);
|
|
2638
|
+
}
|
|
672
2639
|
|
|
673
|
-
|
|
674
|
-
|
|
2640
|
+
if (/\bdotenv\.config\s*\(\s*\{[^}]*\bdebug\s*:\s*true\b/.test(code)) {
|
|
2641
|
+
return staticFinding(
|
|
2642
|
+
'javascript',
|
|
2643
|
+
'javascript-dotenv-debug-output',
|
|
2644
|
+
'dotenv debug output can print during startup; keep MCP diagnostics on stderr'
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
if (/\b(stream|file)\s*:\s*process\.stdout\b/.test(code)) {
|
|
2649
|
+
return staticFinding(
|
|
2650
|
+
'javascript',
|
|
2651
|
+
'javascript-progress-stdout',
|
|
2652
|
+
'progress or spinner output is configured for stdout; MCP stdout must stay JSON-RPC only'
|
|
2653
|
+
);
|
|
2654
|
+
}
|
|
675
2655
|
}
|
|
676
2656
|
|
|
677
|
-
if (ext === '.py'
|
|
678
|
-
|
|
2657
|
+
if (ext === '.py') {
|
|
2658
|
+
if (/(^|[^\w.])print\s*\(/.test(code) && !/file\s*=\s*sys\.stderr/.test(code)) {
|
|
2659
|
+
return staticFinding(
|
|
2660
|
+
'python',
|
|
2661
|
+
'python-print-stdout',
|
|
2662
|
+
'print() writes to stdout; pass file=sys.stderr for MCP stdio diagnostics'
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
if (/\bsys\.stdout\.write\s*\(/.test(code)) {
|
|
2667
|
+
return staticFinding(
|
|
2668
|
+
'python',
|
|
2669
|
+
'python-sys-stdout-write',
|
|
2670
|
+
'direct sys.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
|
|
2671
|
+
);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
if (/\blogging\.StreamHandler\s*\(\s*sys\.stdout\s*\)/.test(code)
|
|
2675
|
+
|| /\blogging\.basicConfig\s*\([^)]*\bstream\s*=\s*sys\.stdout\b/.test(code)) {
|
|
2676
|
+
return staticFinding(
|
|
2677
|
+
'python',
|
|
2678
|
+
'python-logging-stdout-handler',
|
|
2679
|
+
'Python logging is configured to write to stdout; route MCP diagnostics to stderr'
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
if (/\btqdm\s*\([^)]*\bfile\s*=\s*sys\.stdout\b/.test(code)) {
|
|
2684
|
+
return staticFinding(
|
|
2685
|
+
'python',
|
|
2686
|
+
'python-progress-stdout',
|
|
2687
|
+
'progress output is configured for stdout; MCP stdout must stay JSON-RPC only'
|
|
2688
|
+
);
|
|
2689
|
+
}
|
|
679
2690
|
}
|
|
680
2691
|
|
|
681
|
-
if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(
|
|
682
|
-
return
|
|
2692
|
+
if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(code)) {
|
|
2693
|
+
return staticFinding(
|
|
2694
|
+
'go',
|
|
2695
|
+
'go-fmt-stdout',
|
|
2696
|
+
'fmt.Print* writes to stdout; use stderr for MCP stdio diagnostics'
|
|
2697
|
+
);
|
|
683
2698
|
}
|
|
684
2699
|
|
|
685
|
-
if (ext === '.rs' && /\bprintln!\s*\(/.test(
|
|
686
|
-
return
|
|
2700
|
+
if (ext === '.rs' && /\bprintln!\s*\(/.test(code)) {
|
|
2701
|
+
return staticFinding(
|
|
2702
|
+
'rust',
|
|
2703
|
+
'rust-println-stdout',
|
|
2704
|
+
'println! writes to stdout; use eprintln! for MCP stdio diagnostics'
|
|
2705
|
+
);
|
|
687
2706
|
}
|
|
688
2707
|
|
|
689
|
-
if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(
|
|
690
|
-
return
|
|
2708
|
+
if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(code)) {
|
|
2709
|
+
return staticFinding(
|
|
2710
|
+
'jvm',
|
|
2711
|
+
'jvm-system-out',
|
|
2712
|
+
'System.out writes to stdout; use stderr for MCP stdio diagnostics'
|
|
2713
|
+
);
|
|
691
2714
|
}
|
|
692
2715
|
|
|
693
2716
|
return '';
|
|
694
2717
|
}
|
|
695
2718
|
|
|
2719
|
+
function staticFinding(language, reason, message) {
|
|
2720
|
+
return { language, reason, message };
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
function isJavaScriptSource(ext) {
|
|
2724
|
+
return ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext);
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
function normalizeSourceLine(line, ext) {
|
|
2728
|
+
return stripInlineComment(maskStringLiterals(line), ext);
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
function stripInlineComment(line, ext) {
|
|
2732
|
+
if (ext === '.py') {
|
|
2733
|
+
return line.split('#')[0];
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
if (isJavaScriptSource(ext) || ['.go', '.rs', '.java', '.kt'].includes(ext)) {
|
|
2737
|
+
return stripJavaScriptLikeInlineComment(line);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
return line;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function stripJavaScriptLikeInlineComment(line) {
|
|
2744
|
+
let inRegex = false;
|
|
2745
|
+
let escaped = false;
|
|
2746
|
+
let inCharClass = false;
|
|
2747
|
+
|
|
2748
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
2749
|
+
const char = line[index];
|
|
2750
|
+
const next = line[index + 1];
|
|
2751
|
+
|
|
2752
|
+
if (inRegex) {
|
|
2753
|
+
if (escaped) {
|
|
2754
|
+
escaped = false;
|
|
2755
|
+
} else if (char === '\\') {
|
|
2756
|
+
escaped = true;
|
|
2757
|
+
} else if (char === '[') {
|
|
2758
|
+
inCharClass = true;
|
|
2759
|
+
} else if (char === ']') {
|
|
2760
|
+
inCharClass = false;
|
|
2761
|
+
} else if (char === '/' && !inCharClass) {
|
|
2762
|
+
inRegex = false;
|
|
2763
|
+
}
|
|
2764
|
+
continue;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
if (char === '/' && next === '/') {
|
|
2768
|
+
return line.slice(0, index);
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
if (char === '/' && next === '*') {
|
|
2772
|
+
return line.slice(0, index);
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
if (char === '/' && canStartJavaScriptRegex(line, index)) {
|
|
2776
|
+
inRegex = true;
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
return line;
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
function canStartJavaScriptRegex(line, index) {
|
|
2784
|
+
const before = line.slice(0, index).trimEnd();
|
|
2785
|
+
if (!before) return true;
|
|
2786
|
+
|
|
2787
|
+
const last = before[before.length - 1];
|
|
2788
|
+
if ('=(:,[!&|?;{}>'.includes(last)) return true;
|
|
2789
|
+
|
|
2790
|
+
return /\b(await|case|delete|of|return|throw|typeof|void|yield)$/.test(before);
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
function maskStringLiterals(line) {
|
|
2794
|
+
let quote = '';
|
|
2795
|
+
let escaped = false;
|
|
2796
|
+
let masked = '';
|
|
2797
|
+
let templateExpressionDepth = 0;
|
|
2798
|
+
|
|
2799
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
2800
|
+
const char = line[index];
|
|
2801
|
+
const next = line[index + 1];
|
|
2802
|
+
|
|
2803
|
+
if (quote) {
|
|
2804
|
+
if (quote === '`' && char === '$' && next === '{') {
|
|
2805
|
+
quote = '';
|
|
2806
|
+
templateExpressionDepth += 1;
|
|
2807
|
+
masked += ' ';
|
|
2808
|
+
index += 1;
|
|
2809
|
+
continue;
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
if (escaped) {
|
|
2813
|
+
escaped = false;
|
|
2814
|
+
} else if (char === '\\') {
|
|
2815
|
+
escaped = true;
|
|
2816
|
+
} else if (char === quote) {
|
|
2817
|
+
quote = '';
|
|
2818
|
+
}
|
|
2819
|
+
masked += ' ';
|
|
2820
|
+
continue;
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
if (templateExpressionDepth > 0) {
|
|
2824
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
2825
|
+
quote = char;
|
|
2826
|
+
masked += ' ';
|
|
2827
|
+
continue;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
if (char === '{') {
|
|
2831
|
+
templateExpressionDepth += 1;
|
|
2832
|
+
} else if (char === '}') {
|
|
2833
|
+
templateExpressionDepth -= 1;
|
|
2834
|
+
if (templateExpressionDepth === 0) {
|
|
2835
|
+
quote = '`';
|
|
2836
|
+
masked += ' ';
|
|
2837
|
+
continue;
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
masked += char;
|
|
2842
|
+
continue;
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
2846
|
+
quote = char;
|
|
2847
|
+
masked += ' ';
|
|
2848
|
+
continue;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
masked += char;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
return masked;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
696
2857
|
function listSourceFiles(root) {
|
|
697
2858
|
const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', '.next', '.cache']);
|
|
698
2859
|
const files = [];
|
|
@@ -738,6 +2899,10 @@ function formatTextResult(result) {
|
|
|
738
2899
|
lines.push(`request: ${result.operation.method} ${state}`);
|
|
739
2900
|
}
|
|
740
2901
|
|
|
2902
|
+
if (result.toolSchema?.checked) {
|
|
2903
|
+
lines.push(`tool schemas: ${result.toolSchema.validToolCount}/${result.toolSchema.toolCount} valid`);
|
|
2904
|
+
}
|
|
2905
|
+
|
|
741
2906
|
if (result.staticFindings.length) {
|
|
742
2907
|
lines.push(`static findings: ${result.staticFindings.length}`);
|
|
743
2908
|
for (const finding of result.staticFindings.slice(0, 10)) {
|
|
@@ -760,11 +2925,19 @@ function formatRepeatedTextResult(result) {
|
|
|
760
2925
|
`runs: ${passedRuns}/${result.runs.length} passed`
|
|
761
2926
|
];
|
|
762
2927
|
|
|
2928
|
+
if (result.drift?.checked && result.drift.status !== 'skipped') {
|
|
2929
|
+
const issueCodes = result.drift.issueCodes.length ? ` (${result.drift.issueCodes.join(', ')})` : '';
|
|
2930
|
+
lines.push(`drift: ${result.drift.status}${issueCodes}`);
|
|
2931
|
+
}
|
|
2932
|
+
|
|
763
2933
|
for (const run of result.runs) {
|
|
764
2934
|
const runStatus = run.ok ? 'PASS' : 'FAIL';
|
|
765
2935
|
const invalidFrames = run.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
|
|
766
2936
|
const stderrLines = run.stderr ? run.stderr.trim().split(/\r?\n/).filter(Boolean).length : 0;
|
|
767
|
-
|
|
2937
|
+
const toolSchemas = run.toolSchema?.checked
|
|
2938
|
+
? `, tool schemas ${run.toolSchema.validToolCount}/${run.toolSchema.toolCount} valid`
|
|
2939
|
+
: '';
|
|
2940
|
+
lines.push(`run ${run.run}: ${runStatus}, initialize ${run.initialized ? 'ok' : 'failed'}, frames ${run.frames.length} stdout / ${invalidFrames} invalid, stderr ${stderrLines} lines${toolSchemas}`);
|
|
768
2941
|
}
|
|
769
2942
|
|
|
770
2943
|
if (result.staticFindings.length) {
|
|
@@ -812,6 +2985,8 @@ Usage:
|
|
|
812
2985
|
mcp-stdio-guard [options] -- <command> [args...]
|
|
813
2986
|
|
|
814
2987
|
Options:
|
|
2988
|
+
--config <path> read JSON config for registry runs and safe tool calls
|
|
2989
|
+
--profile <name> guard profile: custom, smoke, registry, ci, strict
|
|
815
2990
|
--protocol <version> MCP protocol version, default ${DEFAULT_PROTOCOL}
|
|
816
2991
|
--timeout <ms> initialize and request timeout, default ${DEFAULT_TIMEOUT}
|
|
817
2992
|
--repeat <count> run the guard multiple times, default 1
|