mcp-stdio-guard 0.3.0 → 0.5.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 +176 -14
- package/package.json +1 -1
- package/src/index.js +2210 -103
package/src/index.js
CHANGED
|
@@ -6,17 +6,36 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
6
6
|
function loadVersion() {
|
|
7
7
|
try {
|
|
8
8
|
const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
|
-
return typeof packageJson.version === 'string' ? packageJson.version : '0.
|
|
9
|
+
return typeof packageJson.version === 'string' ? packageJson.version : '0.5.0';
|
|
10
10
|
} catch {
|
|
11
|
-
return '0.
|
|
11
|
+
return '0.5.0';
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const DEFAULT_PROTOCOL = '2025-11-25';
|
|
16
16
|
const DEFAULT_TIMEOUT = 5000;
|
|
17
|
+
const DEFAULT_PROFILE = 'custom';
|
|
17
18
|
const VERSION = loadVersion();
|
|
18
19
|
const JSON_SCHEMA_VERSION = 1;
|
|
19
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 with opt-in adversarial protocol probes'
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const GUARD_PROFILE_NAMES = Object.keys(GUARD_PROFILES);
|
|
20
39
|
const VERSION_PROBE_CACHE = new Map();
|
|
21
40
|
const NODE_OPTIONS_WITH_VALUES = new Set([
|
|
22
41
|
'--conditions',
|
|
@@ -70,6 +89,80 @@ const JSON_RPC_ISSUE_CODES = new Set([
|
|
|
70
89
|
'stdout-invalid-json-rpc',
|
|
71
90
|
'stdout-unexpected-request-id'
|
|
72
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 ADVERSARIAL_OBSERVATION_MS = 150;
|
|
98
|
+
const BUILTIN_ADVERSARIAL_PROBES = Object.freeze({
|
|
99
|
+
'invalid-method': {
|
|
100
|
+
name: 'invalid-method',
|
|
101
|
+
source: 'builtin',
|
|
102
|
+
method: 'mcp_stdio_guard/invalid_method',
|
|
103
|
+
params: { reason: 'adversarial-probe' },
|
|
104
|
+
expectation: 'error',
|
|
105
|
+
failureCode: 'adversarial-invalid-method-result',
|
|
106
|
+
description: 'invalid method requests return structured JSON-RPC errors',
|
|
107
|
+
risk: 'Sends one unknown JSON-RPC method after initialize; servers should answer with an error instead of crashing or returning success.'
|
|
108
|
+
},
|
|
109
|
+
'invalid-params': {
|
|
110
|
+
name: 'invalid-params',
|
|
111
|
+
source: 'builtin',
|
|
112
|
+
method: 'tools/list',
|
|
113
|
+
params: [],
|
|
114
|
+
expectation: 'error',
|
|
115
|
+
failureCode: 'adversarial-invalid-params-result',
|
|
116
|
+
description: 'invalid params return structured JSON-RPC errors',
|
|
117
|
+
risk: 'Sends deliberately invalid params to a common MCP method; tolerant servers may need to opt out of this stricter probe.'
|
|
118
|
+
},
|
|
119
|
+
notification: {
|
|
120
|
+
name: 'notification',
|
|
121
|
+
source: 'builtin',
|
|
122
|
+
method: 'notifications/mcp_stdio_guard_probe',
|
|
123
|
+
params: { reason: 'adversarial-probe' },
|
|
124
|
+
omitId: true,
|
|
125
|
+
expectation: 'no-response',
|
|
126
|
+
failureCode: 'adversarial-notification-response',
|
|
127
|
+
description: 'notifications do not receive responses',
|
|
128
|
+
risk: 'Sends one unknown JSON-RPC notification; servers should not reply to notifications even when the method is unknown.'
|
|
129
|
+
},
|
|
130
|
+
'malformed-json': {
|
|
131
|
+
name: 'malformed-json',
|
|
132
|
+
source: 'builtin',
|
|
133
|
+
method: '',
|
|
134
|
+
rawLine: '{"jsonrpc":"2.0","id":"mcp-stdio-guard-malformed","method":',
|
|
135
|
+
expectation: 'error-or-no-response',
|
|
136
|
+
failureCode: 'adversarial-malformed-json-result',
|
|
137
|
+
description: 'malformed JSON does not crash the server',
|
|
138
|
+
risk: 'Writes one malformed line to stdin; servers may return a structured parse error or ignore it, but should not crash.'
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
const BUILTIN_ADVERSARIAL_PROBE_NAMES = Object.freeze(Object.keys(BUILTIN_ADVERSARIAL_PROBES));
|
|
142
|
+
const STRICT_ADVERSARIAL_PROBE_NAMES = BUILTIN_ADVERSARIAL_PROBE_NAMES;
|
|
143
|
+
const SUPPORTED_ADVERSARIAL_EXPECTATIONS = new Set(['error', 'no-response', 'error-or-no-response']);
|
|
144
|
+
const CAPABILITY_ISSUE_CODES = new Set([
|
|
145
|
+
'capability-list-error',
|
|
146
|
+
'capability-list-missing-response',
|
|
147
|
+
'capability-list-timeout',
|
|
148
|
+
'capability-list-unsupported'
|
|
149
|
+
]);
|
|
150
|
+
const ADVERSARIAL_ISSUE_CODES = new Set([
|
|
151
|
+
'adversarial-invalid-method-result',
|
|
152
|
+
'adversarial-invalid-params-result',
|
|
153
|
+
'adversarial-malformed-json-result',
|
|
154
|
+
'adversarial-notification-response',
|
|
155
|
+
'adversarial-probe-crash',
|
|
156
|
+
'adversarial-probe-invalid-stdout',
|
|
157
|
+
'adversarial-probe-timeout',
|
|
158
|
+
'adversarial-tool-call-result'
|
|
159
|
+
]);
|
|
160
|
+
const REPEAT_DRIFT_ISSUE_CODES = new Set([
|
|
161
|
+
'repeat-capability-drift',
|
|
162
|
+
'repeat-list-shape-drift',
|
|
163
|
+
'repeat-protocol-drift',
|
|
164
|
+
'repeat-tool-drift'
|
|
165
|
+
]);
|
|
73
166
|
const INITIALIZE_ISSUE_CODES = new Set([
|
|
74
167
|
'initialize-error',
|
|
75
168
|
'initialize-invalid-capabilities',
|
|
@@ -88,6 +181,15 @@ const OPERATION_ISSUE_CODES = new Set([
|
|
|
88
181
|
'operation-missing-response',
|
|
89
182
|
'operation-timeout'
|
|
90
183
|
]);
|
|
184
|
+
const TOOL_SCHEMA_ISSUE_CODES = new Set([
|
|
185
|
+
'tool-description-missing',
|
|
186
|
+
'tool-input-schema-invalid',
|
|
187
|
+
'tool-input-schema-required-missing',
|
|
188
|
+
'tool-name-duplicate',
|
|
189
|
+
'tool-name-invalid',
|
|
190
|
+
'tools-list-invalid-result'
|
|
191
|
+
]);
|
|
192
|
+
const JSON_SCHEMA_TYPES = new Set(['array', 'boolean', 'integer', 'null', 'number', 'object', 'string']);
|
|
91
193
|
const PROCESS_ISSUE_CODES = new Set([
|
|
92
194
|
'server-crashed',
|
|
93
195
|
'server-exited',
|
|
@@ -117,6 +219,28 @@ const ISSUE_CLASS_BY_CODE = new Map([
|
|
|
117
219
|
['initialize-missing-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
118
220
|
['initialize-missing-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
119
221
|
['operation-error', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
222
|
+
['capability-list-error', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
223
|
+
['capability-list-missing-response', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
224
|
+
['capability-list-timeout', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
225
|
+
['capability-list-unsupported', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
226
|
+
['repeat-capability-drift', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
227
|
+
['repeat-list-shape-drift', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
228
|
+
['repeat-protocol-drift', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
229
|
+
['repeat-tool-drift', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
230
|
+
['tool-description-missing', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
231
|
+
['tool-input-schema-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
232
|
+
['tool-input-schema-required-missing', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
233
|
+
['tool-name-duplicate', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
234
|
+
['tool-name-invalid', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
235
|
+
['tools-list-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
236
|
+
['adversarial-invalid-method-result', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
237
|
+
['adversarial-invalid-params-result', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
238
|
+
['adversarial-malformed-json-result', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
239
|
+
['adversarial-notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
240
|
+
['adversarial-probe-crash', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
241
|
+
['adversarial-probe-invalid-stdout', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
242
|
+
['adversarial-probe-timeout', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
243
|
+
['adversarial-tool-call-result', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
120
244
|
['notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
121
245
|
['response-id-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
122
246
|
['response-id-type-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
@@ -142,13 +266,19 @@ export async function runCli(argv) {
|
|
|
142
266
|
}
|
|
143
267
|
|
|
144
268
|
const guardOptions = {
|
|
269
|
+
config: options.config,
|
|
270
|
+
profile: options.profile,
|
|
145
271
|
protocol: options.protocol,
|
|
146
272
|
timeoutMs: options.timeoutMs,
|
|
147
273
|
cwd: options.cwd,
|
|
148
|
-
|
|
274
|
+
env: options.env,
|
|
275
|
+
probeCapabilities: options.probeCapabilities,
|
|
276
|
+
adversarialProbes: options.adversarialProbes,
|
|
277
|
+
operations: options.operations,
|
|
278
|
+
operation: options.operations.length === 1
|
|
149
279
|
? {
|
|
150
|
-
method: options.
|
|
151
|
-
params: options.
|
|
280
|
+
method: options.operations[0].method,
|
|
281
|
+
params: options.operations[0].params
|
|
152
282
|
}
|
|
153
283
|
: null
|
|
154
284
|
};
|
|
@@ -194,8 +324,17 @@ export async function runCli(argv) {
|
|
|
194
324
|
export function parseArgs(argv) {
|
|
195
325
|
const options = {
|
|
196
326
|
command: [],
|
|
327
|
+
configPath: '',
|
|
328
|
+
config: defaultConfigMetadata(),
|
|
329
|
+
env: {},
|
|
330
|
+
operations: [],
|
|
331
|
+
configOperations: [],
|
|
332
|
+
adversarialProbeSpecs: [],
|
|
333
|
+
adversarialProbes: [],
|
|
197
334
|
protocol: DEFAULT_PROTOCOL,
|
|
198
335
|
timeoutMs: DEFAULT_TIMEOUT,
|
|
336
|
+
profile: DEFAULT_PROFILE,
|
|
337
|
+
probeCapabilities: true,
|
|
199
338
|
scanPath: '',
|
|
200
339
|
failOnStatic: false,
|
|
201
340
|
requestMethod: '',
|
|
@@ -206,12 +345,14 @@ export function parseArgs(argv) {
|
|
|
206
345
|
version: false,
|
|
207
346
|
cwd: process.cwd()
|
|
208
347
|
};
|
|
348
|
+
const specifiedOptions = new Set();
|
|
209
349
|
|
|
210
350
|
for (let index = 0; index < argv.length; index += 1) {
|
|
211
351
|
const arg = argv[index];
|
|
212
352
|
|
|
213
353
|
if (arg === '--') {
|
|
214
354
|
options.command = argv.slice(index + 1);
|
|
355
|
+
specifiedOptions.add('command');
|
|
215
356
|
break;
|
|
216
357
|
}
|
|
217
358
|
|
|
@@ -221,34 +362,66 @@ export function parseArgs(argv) {
|
|
|
221
362
|
options.version = true;
|
|
222
363
|
} else if (arg === '--json') {
|
|
223
364
|
options.json = true;
|
|
365
|
+
specifiedOptions.add('json');
|
|
366
|
+
} else if (arg === '--config') {
|
|
367
|
+
options.configPath = path.resolve(readOptionValue(argv, index, arg));
|
|
368
|
+
specifiedOptions.add('configPath');
|
|
369
|
+
index += 1;
|
|
370
|
+
} else if (arg === '--profile') {
|
|
371
|
+
options.profile = readOptionValue(argv, index, arg);
|
|
372
|
+
specifiedOptions.add('profile');
|
|
373
|
+
index += 1;
|
|
224
374
|
} else if (arg === '--fail-on-static') {
|
|
225
375
|
options.failOnStatic = true;
|
|
376
|
+
specifiedOptions.add('failOnStatic');
|
|
226
377
|
} else if (arg === '--request') {
|
|
227
378
|
options.requestMethod = readOptionValue(argv, index, arg);
|
|
379
|
+
specifiedOptions.add('requestMethod');
|
|
228
380
|
index += 1;
|
|
229
381
|
} else if (arg === '--params') {
|
|
230
382
|
options.requestParams = parseJsonOption(readOptionValue(argv, index, arg), arg);
|
|
383
|
+
specifiedOptions.add('requestParams');
|
|
384
|
+
index += 1;
|
|
385
|
+
} else if (arg === '--adversarial-probe' || arg === '--adversarial-probes') {
|
|
386
|
+
options.adversarialProbeSpecs.push(...expandAdversarialProbeList(readOptionValue(argv, index, arg), arg));
|
|
387
|
+
specifiedOptions.add('adversarialProbes');
|
|
231
388
|
index += 1;
|
|
232
389
|
} else if (arg === '--protocol') {
|
|
233
390
|
options.protocol = readOptionValue(argv, index, arg);
|
|
391
|
+
specifiedOptions.add('protocol');
|
|
234
392
|
index += 1;
|
|
235
393
|
} else if (arg === '--timeout') {
|
|
236
394
|
options.timeoutMs = Number(readOptionValue(argv, index, arg));
|
|
395
|
+
specifiedOptions.add('timeoutMs');
|
|
237
396
|
index += 1;
|
|
238
397
|
} else if (arg === '--repeat') {
|
|
239
398
|
options.repeat = Number(readOptionValue(argv, index, arg));
|
|
399
|
+
specifiedOptions.add('repeat');
|
|
240
400
|
index += 1;
|
|
241
401
|
} else if (arg === '--scan') {
|
|
242
402
|
options.scanPath = path.resolve(readOptionValue(argv, index, arg));
|
|
403
|
+
specifiedOptions.add('scanPath');
|
|
243
404
|
index += 1;
|
|
244
405
|
} else if (arg === '--cwd') {
|
|
245
406
|
options.cwd = path.resolve(readOptionValue(argv, index, arg));
|
|
407
|
+
specifiedOptions.add('cwd');
|
|
246
408
|
index += 1;
|
|
247
409
|
} else {
|
|
248
410
|
throw new Error(`Unknown option before --: ${arg}`);
|
|
249
411
|
}
|
|
250
412
|
}
|
|
251
413
|
|
|
414
|
+
if (options.help || options.version) {
|
|
415
|
+
return options;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (options.configPath) {
|
|
419
|
+
applyConfigFile(options, loadConfigFile(options.configPath), specifiedOptions);
|
|
420
|
+
}
|
|
421
|
+
applyProfileDefaults(options, specifiedOptions);
|
|
422
|
+
options.operations = buildConfiguredOperations(options);
|
|
423
|
+
options.adversarialProbes = normalizeAdversarialProbeSpecs(options.adversarialProbeSpecs);
|
|
424
|
+
|
|
252
425
|
if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 100) {
|
|
253
426
|
throw new Error('--timeout must be an integer >= 100');
|
|
254
427
|
}
|
|
@@ -264,9 +437,475 @@ export function parseArgs(argv) {
|
|
|
264
437
|
return options;
|
|
265
438
|
}
|
|
266
439
|
|
|
440
|
+
function applyProfileDefaults(options, specifiedOptions) {
|
|
441
|
+
if (!GUARD_PROFILE_NAMES.includes(options.profile)) {
|
|
442
|
+
throw new Error(`--profile must be one of: ${GUARD_PROFILE_NAMES.join(', ')}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (options.profile === 'smoke') {
|
|
446
|
+
options.probeCapabilities = false;
|
|
447
|
+
return options;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
options.probeCapabilities = true;
|
|
451
|
+
|
|
452
|
+
if (options.profile === 'registry' && !specifiedOptions.has('repeat')) {
|
|
453
|
+
options.repeat = 2;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (options.profile === 'ci') {
|
|
457
|
+
options.json = true;
|
|
458
|
+
options.failOnStatic = true;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (options.profile === 'strict') {
|
|
462
|
+
options.json = true;
|
|
463
|
+
options.failOnStatic = true;
|
|
464
|
+
if (!specifiedOptions.has('repeat')) {
|
|
465
|
+
options.repeat = 2;
|
|
466
|
+
}
|
|
467
|
+
if (!specifiedOptions.has('adversarialProbes')) {
|
|
468
|
+
options.adversarialProbeSpecs.push(...STRICT_ADVERSARIAL_PROBE_NAMES);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return options;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function loadConfigFile(configPath) {
|
|
476
|
+
let raw;
|
|
477
|
+
try {
|
|
478
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
479
|
+
} catch (error) {
|
|
480
|
+
throw new Error(`Failed to read --config ${configPath}: ${error.message}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const parsed = JSON.parse(raw);
|
|
485
|
+
if (!isObjectRecord(parsed)) {
|
|
486
|
+
throw new Error('top-level value must be an object');
|
|
487
|
+
}
|
|
488
|
+
return parsed;
|
|
489
|
+
} catch (error) {
|
|
490
|
+
throw new Error(`--config ${configPath} must be valid JSON: ${error.message}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function applyConfigFile(options, config, specifiedOptions) {
|
|
495
|
+
const configDir = path.dirname(options.configPath);
|
|
496
|
+
const command = normalizeConfigCommand(config);
|
|
497
|
+
const env = normalizeConfigEnv(config);
|
|
498
|
+
const requests = normalizeConfigRequests(config);
|
|
499
|
+
const safeToolCalls = normalizeSafeToolCalls(config);
|
|
500
|
+
const adversarialProbes = normalizeConfigAdversarialProbes(config);
|
|
501
|
+
const adversarialToolCalls = normalizeConfigAdversarialToolCalls(config);
|
|
502
|
+
const usesConfigCommand = command.length > 0 && !specifiedOptions.has('command');
|
|
503
|
+
const usesConfigCwd = typeof config.cwd === 'string' && !specifiedOptions.has('cwd');
|
|
504
|
+
const usesConfigOperations = !specifiedOptions.has('requestMethod') && !specifiedOptions.has('requestParams');
|
|
505
|
+
const usesConfigAdversarialProbes = !specifiedOptions.has('adversarialProbes')
|
|
506
|
+
&& (Object.hasOwn(config, 'adversarialProbes') || Object.hasOwn(config, 'adversarialToolCalls'));
|
|
507
|
+
|
|
508
|
+
if (usesConfigCommand) {
|
|
509
|
+
options.command = command;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (usesConfigCwd) {
|
|
513
|
+
options.cwd = resolveConfigPath(config.cwd, configDir);
|
|
514
|
+
} else if (config.cwd !== undefined && typeof config.cwd !== 'string') {
|
|
515
|
+
throw new Error('--config cwd must be a string');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (config.protocol !== undefined && !specifiedOptions.has('protocol')) {
|
|
519
|
+
if (typeof config.protocol !== 'string' || !config.protocol) {
|
|
520
|
+
throw new Error('--config protocol must be a non-empty string');
|
|
521
|
+
}
|
|
522
|
+
options.protocol = config.protocol;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const timeoutField = Object.hasOwn(config, 'timeoutMs') ? 'timeoutMs' : 'timeout';
|
|
526
|
+
const timeoutValue = config[timeoutField];
|
|
527
|
+
if (timeoutValue !== undefined && !specifiedOptions.has('timeoutMs')) {
|
|
528
|
+
options.timeoutMs = normalizeConfigInteger(timeoutValue, timeoutField, 100);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (config.repeat !== undefined && !specifiedOptions.has('repeat')) {
|
|
532
|
+
options.repeat = normalizeConfigInteger(config.repeat, 'repeat', 1);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (config.profile !== undefined && !specifiedOptions.has('profile')) {
|
|
536
|
+
if (typeof config.profile !== 'string') {
|
|
537
|
+
throw new Error('--config profile must be a string');
|
|
538
|
+
}
|
|
539
|
+
options.profile = config.profile;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (config.json !== undefined && !specifiedOptions.has('json')) {
|
|
543
|
+
if (typeof config.json !== 'boolean') {
|
|
544
|
+
throw new Error('--config json must be a boolean');
|
|
545
|
+
}
|
|
546
|
+
options.json = config.json;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const scanPath = config.scanPath ?? config.scan;
|
|
550
|
+
if (scanPath !== undefined && !specifiedOptions.has('scanPath')) {
|
|
551
|
+
if (typeof scanPath !== 'string') {
|
|
552
|
+
throw new Error('--config scan must be a string path');
|
|
553
|
+
}
|
|
554
|
+
options.scanPath = resolveConfigPath(scanPath, configDir);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (config.failOnStatic !== undefined && !specifiedOptions.has('failOnStatic')) {
|
|
558
|
+
if (typeof config.failOnStatic !== 'boolean') {
|
|
559
|
+
throw new Error('--config failOnStatic must be a boolean');
|
|
560
|
+
}
|
|
561
|
+
options.failOnStatic = config.failOnStatic;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (Object.keys(env).length) {
|
|
565
|
+
options.env = { ...env, ...options.env };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (usesConfigOperations) {
|
|
569
|
+
options.configOperations = [...requests, ...safeToolCalls];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (usesConfigAdversarialProbes) {
|
|
573
|
+
options.adversarialProbeSpecs = [...adversarialProbes, ...adversarialToolCalls];
|
|
574
|
+
specifiedOptions.add('adversarialProbes');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
options.config = {
|
|
578
|
+
enabled: true,
|
|
579
|
+
path: options.configPath,
|
|
580
|
+
resolvedPath: options.configPath,
|
|
581
|
+
checks: {
|
|
582
|
+
command: usesConfigCommand,
|
|
583
|
+
cwd: usesConfigCwd,
|
|
584
|
+
envNames: Object.keys(env).sort(),
|
|
585
|
+
requests: usesConfigOperations ? requests.map((request) => request.method) : [],
|
|
586
|
+
safeToolCalls: usesConfigOperations ? safeToolCalls.map((request) => request.safeToolCallName) : [],
|
|
587
|
+
adversarialProbes: usesConfigAdversarialProbes ? adversarialProbes.map((probe) => probe.name ?? probe) : [],
|
|
588
|
+
adversarialToolCalls: usesConfigAdversarialProbes ? adversarialToolCalls.map((probe) => probe.safeToolCallName) : []
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function normalizeConfigCommand(config) {
|
|
594
|
+
if (config.command === undefined && config.args === undefined) return [];
|
|
595
|
+
|
|
596
|
+
if (Array.isArray(config.command)) {
|
|
597
|
+
if (!config.command.every((entry) => typeof entry === 'string' && entry)) {
|
|
598
|
+
throw new Error('--config command array entries must be non-empty strings');
|
|
599
|
+
}
|
|
600
|
+
if (config.args !== undefined) {
|
|
601
|
+
throw new Error('--config args cannot be used when command is an array');
|
|
602
|
+
}
|
|
603
|
+
return config.command;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (typeof config.command === 'string' && config.command) {
|
|
607
|
+
const args = config.args ?? [];
|
|
608
|
+
if (!Array.isArray(args) || !args.every((entry) => typeof entry === 'string')) {
|
|
609
|
+
throw new Error('--config args must be an array of strings');
|
|
610
|
+
}
|
|
611
|
+
return [config.command, ...args];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
throw new Error('--config command must be a non-empty string or array of strings');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function normalizeConfigEnv(config) {
|
|
618
|
+
if (config.env === undefined) return {};
|
|
619
|
+
if (!isObjectRecord(config.env)) {
|
|
620
|
+
throw new Error('--config env must be an object');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return Object.fromEntries(Object.entries(config.env).map(([name, value]) => {
|
|
624
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
625
|
+
throw new Error(`--config env contains invalid variable name: ${name}`);
|
|
626
|
+
}
|
|
627
|
+
if (!['string', 'number', 'boolean'].includes(typeof value)) {
|
|
628
|
+
throw new Error(`--config env.${name} must be a string, number, or boolean`);
|
|
629
|
+
}
|
|
630
|
+
return [name, String(value)];
|
|
631
|
+
}));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function normalizeConfigInteger(value, field, minimum) {
|
|
635
|
+
const normalized = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
|
|
636
|
+
if (!Number.isInteger(normalized) || normalized < minimum) {
|
|
637
|
+
throw new Error(`--config ${field} must be an integer >= ${minimum}`);
|
|
638
|
+
}
|
|
639
|
+
return normalized;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function normalizeConfigRequests(config) {
|
|
643
|
+
const values = [];
|
|
644
|
+
if (config.request !== undefined) {
|
|
645
|
+
values.push(config.request);
|
|
646
|
+
}
|
|
647
|
+
if (config.requests !== undefined) {
|
|
648
|
+
if (!Array.isArray(config.requests)) {
|
|
649
|
+
throw new Error('--config requests must be an array');
|
|
650
|
+
}
|
|
651
|
+
values.push(...config.requests);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return values.map((request, index) => {
|
|
655
|
+
if (!isObjectRecord(request)) {
|
|
656
|
+
throw new Error(`--config requests[${index}] must be an object`);
|
|
657
|
+
}
|
|
658
|
+
if (typeof request.method !== 'string' || !request.method) {
|
|
659
|
+
throw new Error(`--config requests[${index}].method must be a non-empty string`);
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
source: 'config-request',
|
|
663
|
+
method: request.method,
|
|
664
|
+
params: request.params
|
|
665
|
+
};
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function normalizeSafeToolCalls(config) {
|
|
670
|
+
if (config.safeToolCalls === undefined) return [];
|
|
671
|
+
if (!Array.isArray(config.safeToolCalls)) {
|
|
672
|
+
throw new Error('--config safeToolCalls must be an array');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return config.safeToolCalls.map((call, index) => {
|
|
676
|
+
if (!isObjectRecord(call)) {
|
|
677
|
+
throw new Error(`--config safeToolCalls[${index}] must be an object`);
|
|
678
|
+
}
|
|
679
|
+
if (typeof call.name !== 'string' || !call.name) {
|
|
680
|
+
throw new Error(`--config safeToolCalls[${index}].name must be a non-empty string`);
|
|
681
|
+
}
|
|
682
|
+
const argumentsValue = call.arguments ?? {};
|
|
683
|
+
if (!isObjectRecord(argumentsValue)) {
|
|
684
|
+
throw new Error(`--config safeToolCalls[${index}].arguments must be an object`);
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
source: 'safe-tool-call',
|
|
688
|
+
method: 'tools/call',
|
|
689
|
+
params: {
|
|
690
|
+
name: call.name,
|
|
691
|
+
arguments: argumentsValue
|
|
692
|
+
},
|
|
693
|
+
safeToolCallName: call.name
|
|
694
|
+
};
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function normalizeConfigAdversarialProbes(config) {
|
|
699
|
+
if (config.adversarialProbes === undefined) return [];
|
|
700
|
+
|
|
701
|
+
if (config.adversarialProbes === true) {
|
|
702
|
+
return [...BUILTIN_ADVERSARIAL_PROBE_NAMES];
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (config.adversarialProbes === false) {
|
|
706
|
+
return [];
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (typeof config.adversarialProbes === 'string') {
|
|
710
|
+
return expandAndValidateAdversarialProbeList(config.adversarialProbes, '--config adversarialProbes');
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (!Array.isArray(config.adversarialProbes)) {
|
|
714
|
+
throw new Error('--config adversarialProbes must be a boolean, string, or array of strings');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return config.adversarialProbes.flatMap((probe, index) => {
|
|
718
|
+
if (typeof probe !== 'string') {
|
|
719
|
+
throw new Error(`--config adversarialProbes[${index}] must be a string`);
|
|
720
|
+
}
|
|
721
|
+
return expandAndValidateAdversarialProbeList(probe, `--config adversarialProbes[${index}]`);
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function normalizeConfigAdversarialToolCalls(config) {
|
|
726
|
+
if (config.adversarialToolCalls === undefined) return [];
|
|
727
|
+
if (!Array.isArray(config.adversarialToolCalls)) {
|
|
728
|
+
throw new Error('--config adversarialToolCalls must be an array');
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return config.adversarialToolCalls.map((call, index) => {
|
|
732
|
+
if (!isObjectRecord(call)) {
|
|
733
|
+
throw new Error(`--config adversarialToolCalls[${index}] must be an object`);
|
|
734
|
+
}
|
|
735
|
+
if (typeof call.name !== 'string' || !call.name) {
|
|
736
|
+
throw new Error(`--config adversarialToolCalls[${index}].name must be a non-empty string`);
|
|
737
|
+
}
|
|
738
|
+
const argumentsValue = call.arguments ?? {};
|
|
739
|
+
if (!isObjectRecord(argumentsValue)) {
|
|
740
|
+
throw new Error(`--config adversarialToolCalls[${index}].arguments must be an object`);
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
name: `safe-tool-invalid-args:${call.name}`,
|
|
744
|
+
source: 'adversarial-tool-call',
|
|
745
|
+
method: 'tools/call',
|
|
746
|
+
params: {
|
|
747
|
+
name: call.name,
|
|
748
|
+
arguments: argumentsValue
|
|
749
|
+
},
|
|
750
|
+
safeToolCallName: call.name,
|
|
751
|
+
expectation: 'error',
|
|
752
|
+
failureCode: 'adversarial-tool-call-result',
|
|
753
|
+
description: `tools/call for ${call.name} with invalid arguments returns a structured error`,
|
|
754
|
+
risk: 'Calls a configured safe tool with intentionally invalid arguments; only use for idempotent tools.'
|
|
755
|
+
};
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function buildConfiguredOperations(options) {
|
|
760
|
+
if (options.requestMethod) {
|
|
761
|
+
return [{
|
|
762
|
+
source: 'cli-request',
|
|
763
|
+
method: options.requestMethod,
|
|
764
|
+
params: options.requestParams
|
|
765
|
+
}];
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return options.configOperations;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function normalizeGuardOperations(operations) {
|
|
772
|
+
return operations.map((operation) => ({
|
|
773
|
+
source: operation.source ?? 'request',
|
|
774
|
+
method: operation.method,
|
|
775
|
+
params: operation.params,
|
|
776
|
+
safeToolCallName: operation.safeToolCallName ?? ''
|
|
777
|
+
}));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function expandAdversarialProbeList(rawValue, option) {
|
|
781
|
+
const names = String(rawValue)
|
|
782
|
+
.split(',')
|
|
783
|
+
.map((name) => name.trim())
|
|
784
|
+
.filter(Boolean);
|
|
785
|
+
|
|
786
|
+
if (!names.length) {
|
|
787
|
+
throw new Error(`${option} requires at least one probe name`);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (names.includes('none')) {
|
|
791
|
+
if (names.length > 1) {
|
|
792
|
+
throw new Error(`${option} cannot combine none with other probes`);
|
|
793
|
+
}
|
|
794
|
+
return [];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (names.includes('all')) {
|
|
798
|
+
if (names.length > 1) {
|
|
799
|
+
throw new Error(`${option} cannot combine all with other probes`);
|
|
800
|
+
}
|
|
801
|
+
return [...BUILTIN_ADVERSARIAL_PROBE_NAMES];
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return names;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function expandAndValidateAdversarialProbeList(rawValue, option) {
|
|
808
|
+
return expandAdversarialProbeList(rawValue, option).map((name) => validateAdversarialProbeName(name, option));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function validateAdversarialProbeName(name, option) {
|
|
812
|
+
if (!BUILTIN_ADVERSARIAL_PROBES[name]) {
|
|
813
|
+
throw new Error(`${option} must be one of: ${[...BUILTIN_ADVERSARIAL_PROBE_NAMES, 'all', 'none'].join(', ')}`);
|
|
814
|
+
}
|
|
815
|
+
return name;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function normalizeAdversarialProbeSpecs(specs) {
|
|
819
|
+
const probes = [];
|
|
820
|
+
const seenBuiltins = new Set();
|
|
821
|
+
|
|
822
|
+
for (const spec of specs ?? []) {
|
|
823
|
+
if (typeof spec === 'string') {
|
|
824
|
+
for (const name of expandAdversarialProbeList(spec, '--adversarial-probe')) {
|
|
825
|
+
const definition = BUILTIN_ADVERSARIAL_PROBES[validateAdversarialProbeName(name, '--adversarial-probe')];
|
|
826
|
+
if (seenBuiltins.has(name)) continue;
|
|
827
|
+
seenBuiltins.add(name);
|
|
828
|
+
probes.push({ ...definition });
|
|
829
|
+
}
|
|
830
|
+
} else if (isObjectRecord(spec)) {
|
|
831
|
+
probes.push(normalizeAdversarialProbeObject(spec, '--adversarial-probe'));
|
|
832
|
+
} else {
|
|
833
|
+
throw new Error('--adversarial-probe entries must be strings or configured adversarial tool calls');
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return probes.map((probe, index) => ({
|
|
838
|
+
index,
|
|
839
|
+
safeToolCallName: '',
|
|
840
|
+
rawLine: '',
|
|
841
|
+
omitId: false,
|
|
842
|
+
...probe
|
|
843
|
+
}));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function normalizeAdversarialProbeObject(spec, option) {
|
|
847
|
+
if (typeof spec.name !== 'string' || !spec.name.trim()) {
|
|
848
|
+
throw new Error(`${option}.name must be a non-empty string`);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (typeof spec.expectation !== 'string' || !SUPPORTED_ADVERSARIAL_EXPECTATIONS.has(spec.expectation)) {
|
|
852
|
+
throw new Error(`${option}.expectation must be one of: ${[...SUPPORTED_ADVERSARIAL_EXPECTATIONS].join(', ')}`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (typeof spec.failureCode !== 'string' || !spec.failureCode) {
|
|
856
|
+
throw new Error(`${option}.failureCode must be a non-empty string`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const rawLine = spec.rawLine ?? '';
|
|
860
|
+
if (typeof rawLine !== 'string') {
|
|
861
|
+
throw new Error(`${option}.rawLine must be a string when provided`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const method = spec.method ?? '';
|
|
865
|
+
if (typeof method !== 'string') {
|
|
866
|
+
throw new Error(`${option}.method must be a string when provided`);
|
|
867
|
+
}
|
|
868
|
+
if (!rawLine && !method) {
|
|
869
|
+
throw new Error(`${option}.method must be a non-empty string when rawLine is not set`);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (spec.source !== undefined && typeof spec.source !== 'string') {
|
|
873
|
+
throw new Error(`${option}.source must be a string when provided`);
|
|
874
|
+
}
|
|
875
|
+
if (spec.safeToolCallName !== undefined && typeof spec.safeToolCallName !== 'string') {
|
|
876
|
+
throw new Error(`${option}.safeToolCallName must be a string when provided`);
|
|
877
|
+
}
|
|
878
|
+
if (spec.description !== undefined && typeof spec.description !== 'string') {
|
|
879
|
+
throw new Error(`${option}.description must be a string when provided`);
|
|
880
|
+
}
|
|
881
|
+
if (spec.risk !== undefined && typeof spec.risk !== 'string') {
|
|
882
|
+
throw new Error(`${option}.risk must be a string when provided`);
|
|
883
|
+
}
|
|
884
|
+
if (spec.omitId !== undefined && typeof spec.omitId !== 'boolean') {
|
|
885
|
+
throw new Error(`${option}.omitId must be a boolean when provided`);
|
|
886
|
+
}
|
|
887
|
+
if (spec.quietMs !== undefined && (!Number.isInteger(spec.quietMs) || spec.quietMs < 1)) {
|
|
888
|
+
throw new Error(`${option}.quietMs must be an integer >= 1 when provided`);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
...spec,
|
|
893
|
+
name: spec.name.trim(),
|
|
894
|
+
source: spec.source ?? 'custom',
|
|
895
|
+
method,
|
|
896
|
+
rawLine,
|
|
897
|
+
safeToolCallName: spec.safeToolCallName ?? ''
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function resolveConfigPath(value, configDir) {
|
|
902
|
+
return path.resolve(configDir, value);
|
|
903
|
+
}
|
|
904
|
+
|
|
267
905
|
export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
268
906
|
const startedAt = Date.now();
|
|
269
907
|
const repeat = options.repeat ?? 1;
|
|
908
|
+
const adversarialProbes = normalizeAdversarialProbeSpecs(options.adversarialProbes ?? []);
|
|
270
909
|
const runs = [];
|
|
271
910
|
const issues = [];
|
|
272
911
|
|
|
@@ -274,7 +913,7 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
|
274
913
|
throw new Error('repeat must be an integer >= 1');
|
|
275
914
|
}
|
|
276
915
|
|
|
277
|
-
const singleRunOptions = { ...options, repeat: 1 };
|
|
916
|
+
const singleRunOptions = { ...options, adversarialProbes, repeat: 1 };
|
|
278
917
|
|
|
279
918
|
for (let index = 1; index <= repeat; index += 1) {
|
|
280
919
|
const run = await guardStdioServer(commandWithArgs, singleRunOptions);
|
|
@@ -285,18 +924,29 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
|
285
924
|
}
|
|
286
925
|
}
|
|
287
926
|
|
|
927
|
+
const drift = buildRepeatDrift(runs);
|
|
928
|
+
for (const issue of repeatDriftIssues(drift)) {
|
|
929
|
+
issues.push(issue);
|
|
930
|
+
}
|
|
931
|
+
|
|
288
932
|
const durationMs = Date.now() - startedAt;
|
|
289
933
|
const result = {
|
|
290
934
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
291
935
|
ok: !issues.some((issue) => issue.severity === 'error'),
|
|
936
|
+
profile: options.profile ?? DEFAULT_PROFILE,
|
|
292
937
|
command: commandWithArgs,
|
|
938
|
+
config: options.config ?? defaultConfigMetadata(),
|
|
293
939
|
protocol: options.protocol ?? DEFAULT_PROTOCOL,
|
|
294
940
|
repeat,
|
|
295
941
|
runs,
|
|
296
942
|
issues,
|
|
297
943
|
checks: {},
|
|
944
|
+
capabilityProbes: options.probeCapabilities ?? true,
|
|
945
|
+
adversarial: aggregateRunAdversarial(runs, adversarialProbes),
|
|
946
|
+
drift,
|
|
298
947
|
staticScan: defaultStaticScan(),
|
|
299
948
|
staticFindings: [],
|
|
949
|
+
toolSchema: aggregateRunToolSchemaValidation(runs),
|
|
300
950
|
durationMs,
|
|
301
951
|
fingerprint: createFingerprint(commandWithArgs, options)
|
|
302
952
|
};
|
|
@@ -310,7 +960,9 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
310
960
|
const args = commandWithArgs.slice(1);
|
|
311
961
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
312
962
|
const protocol = options.protocol ?? DEFAULT_PROTOCOL;
|
|
313
|
-
const
|
|
963
|
+
const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
|
|
964
|
+
const adversarialProbes = normalizeAdversarialProbeSpecs(options.adversarialProbes ?? []);
|
|
965
|
+
const probeCapabilities = options.probeCapabilities ?? true;
|
|
314
966
|
const env = { ...process.env, ...(options.env ?? {}) };
|
|
315
967
|
const issues = [];
|
|
316
968
|
const frames = [];
|
|
@@ -319,36 +971,60 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
319
971
|
let initialized = false;
|
|
320
972
|
let endedByGuard = false;
|
|
321
973
|
let initializeResponseAt = 0;
|
|
974
|
+
let currentRequest = null;
|
|
975
|
+
let requestQueue = [];
|
|
976
|
+
let nextRequestId = 2;
|
|
322
977
|
let timer;
|
|
323
978
|
let child;
|
|
324
979
|
|
|
325
980
|
const result = {
|
|
326
981
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
327
982
|
ok: false,
|
|
983
|
+
profile: options.profile ?? DEFAULT_PROFILE,
|
|
328
984
|
command: commandWithArgs,
|
|
985
|
+
config: options.config ?? defaultConfigMetadata(),
|
|
329
986
|
protocol,
|
|
330
987
|
negotiatedProtocol: '',
|
|
331
988
|
initialized: false,
|
|
332
|
-
operation:
|
|
989
|
+
operation: operations.length === 1
|
|
333
990
|
? {
|
|
334
|
-
method:
|
|
991
|
+
method: operations[0].method,
|
|
992
|
+
source: operations[0].source,
|
|
993
|
+
safeToolCallName: operations[0].safeToolCallName,
|
|
335
994
|
responded: false,
|
|
336
995
|
error: null
|
|
337
996
|
}
|
|
338
997
|
: null,
|
|
998
|
+
operations: operations.map((operation, index) => ({
|
|
999
|
+
index,
|
|
1000
|
+
source: operation.source,
|
|
1001
|
+
method: operation.method,
|
|
1002
|
+
safeToolCallName: operation.safeToolCallName,
|
|
1003
|
+
responded: false,
|
|
1004
|
+
error: null
|
|
1005
|
+
})),
|
|
339
1006
|
frames,
|
|
340
1007
|
issues,
|
|
341
1008
|
checks: {},
|
|
1009
|
+
capabilityProbes: probeCapabilities,
|
|
1010
|
+
capabilityKeys: [],
|
|
1011
|
+
capabilityChecks: defaultCapabilityChecks(),
|
|
1012
|
+
adversarial: defaultAdversarialResult(adversarialProbes),
|
|
342
1013
|
stderr: '',
|
|
343
1014
|
process: defaultProcessInfo(timeoutMs),
|
|
344
1015
|
staticScan: defaultStaticScan(),
|
|
345
1016
|
staticFindings: [],
|
|
1017
|
+
toolSchema: defaultToolSchemaValidation(),
|
|
346
1018
|
durationMs: 0,
|
|
347
1019
|
fingerprint: createFingerprint(commandWithArgs, {
|
|
348
1020
|
protocol,
|
|
349
1021
|
timeoutMs,
|
|
350
1022
|
cwd: options.cwd,
|
|
351
|
-
|
|
1023
|
+
config: options.config,
|
|
1024
|
+
profile: options.profile,
|
|
1025
|
+
probeCapabilities,
|
|
1026
|
+
adversarialProbes,
|
|
1027
|
+
operations,
|
|
352
1028
|
env: options.env
|
|
353
1029
|
})
|
|
354
1030
|
};
|
|
@@ -358,7 +1034,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
358
1034
|
issues.push({ ...details, severity, code, message });
|
|
359
1035
|
}
|
|
360
1036
|
|
|
361
|
-
function armTimeout(code, message, timeoutPhase) {
|
|
1037
|
+
function armTimeout(code, message, timeoutPhase, details = {}) {
|
|
362
1038
|
clearTimeout(timer);
|
|
363
1039
|
result.process.phase = timeoutPhase;
|
|
364
1040
|
timer = setTimeout(() => {
|
|
@@ -366,7 +1042,10 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
366
1042
|
result.process.timeoutCode = code;
|
|
367
1043
|
result.process.timeoutMs = timeoutMs;
|
|
368
1044
|
result.process.outcome = 'timeout';
|
|
369
|
-
addIssue('error', code, message,
|
|
1045
|
+
addIssue('error', code, message, {
|
|
1046
|
+
...timeoutIssueDetails(code, timeoutMs, timeoutPhase),
|
|
1047
|
+
...details
|
|
1048
|
+
});
|
|
370
1049
|
finish();
|
|
371
1050
|
}, timeoutMs);
|
|
372
1051
|
}
|
|
@@ -406,6 +1085,346 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
406
1085
|
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
407
1086
|
}
|
|
408
1087
|
|
|
1088
|
+
function sendRaw(line) {
|
|
1089
|
+
if (!child?.stdin?.writable) return;
|
|
1090
|
+
child.stdin.write(`${line}\n`);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function enqueueRequest(request) {
|
|
1094
|
+
const needsId = !request.omitId && !request.rawLine;
|
|
1095
|
+
requestQueue.push({
|
|
1096
|
+
...request,
|
|
1097
|
+
id: needsId ? nextRequestId : null
|
|
1098
|
+
});
|
|
1099
|
+
if (needsId) {
|
|
1100
|
+
nextRequestId += 1;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function startNextRequest() {
|
|
1105
|
+
if (result.durationMs || currentRequest) return;
|
|
1106
|
+
currentRequest = requestQueue.shift() || null;
|
|
1107
|
+
if (!currentRequest) {
|
|
1108
|
+
result.process.phase = 'post-initialize';
|
|
1109
|
+
finishSoon();
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
result.process.phase = 'operation';
|
|
1114
|
+
currentRequest.startedAt = Date.now();
|
|
1115
|
+
|
|
1116
|
+
if (currentRequest.kind === 'adversarial') {
|
|
1117
|
+
result.process.phase = 'adversarial';
|
|
1118
|
+
markAdversarialProbeRunning(currentRequest);
|
|
1119
|
+
if (currentRequest.rawLine) {
|
|
1120
|
+
sendRaw(currentRequest.rawLine);
|
|
1121
|
+
} else {
|
|
1122
|
+
const request = {
|
|
1123
|
+
jsonrpc: '2.0',
|
|
1124
|
+
method: currentRequest.method
|
|
1125
|
+
};
|
|
1126
|
+
if (!currentRequest.omitId) {
|
|
1127
|
+
request.id = currentRequest.id;
|
|
1128
|
+
}
|
|
1129
|
+
if (currentRequest.params !== undefined) {
|
|
1130
|
+
request.params = currentRequest.params;
|
|
1131
|
+
}
|
|
1132
|
+
send(request);
|
|
1133
|
+
}
|
|
1134
|
+
if (currentRequest.expectation === 'no-response' || currentRequest.expectation === 'error-or-no-response') {
|
|
1135
|
+
armAdversarialQuietTimer(currentRequest);
|
|
1136
|
+
} else {
|
|
1137
|
+
armAdversarialTimeout(currentRequest);
|
|
1138
|
+
}
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const request = {
|
|
1143
|
+
jsonrpc: '2.0',
|
|
1144
|
+
id: currentRequest.id,
|
|
1145
|
+
method: currentRequest.method
|
|
1146
|
+
};
|
|
1147
|
+
if (currentRequest.params !== undefined) {
|
|
1148
|
+
request.params = currentRequest.params;
|
|
1149
|
+
}
|
|
1150
|
+
send(request);
|
|
1151
|
+
armTimeout(
|
|
1152
|
+
currentRequest.timeoutCode,
|
|
1153
|
+
currentRequest.timeoutMessage,
|
|
1154
|
+
'operation',
|
|
1155
|
+
currentRequest.timeoutDetails
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function armAdversarialQuietTimer(request) {
|
|
1160
|
+
clearTimeout(timer);
|
|
1161
|
+
timer = setTimeout(() => {
|
|
1162
|
+
completeAdversarialProbe(request, 'pass');
|
|
1163
|
+
currentRequest = null;
|
|
1164
|
+
startNextRequest();
|
|
1165
|
+
}, request.quietMs);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function armAdversarialTimeout(request) {
|
|
1169
|
+
clearTimeout(timer);
|
|
1170
|
+
result.process.phase = 'adversarial';
|
|
1171
|
+
timer = setTimeout(() => {
|
|
1172
|
+
result.process.timedOut = true;
|
|
1173
|
+
result.process.timeoutCode = 'adversarial-probe-timeout';
|
|
1174
|
+
result.process.timeoutMs = timeoutMs;
|
|
1175
|
+
result.process.outcome = 'timeout';
|
|
1176
|
+
failAdversarialProbe(
|
|
1177
|
+
request,
|
|
1178
|
+
'adversarial-probe-timeout',
|
|
1179
|
+
`${request.probeName} adversarial probe did not receive a structured error within ${timeoutMs}ms`,
|
|
1180
|
+
{
|
|
1181
|
+
detailCode: 'adversarial-probe-timeout',
|
|
1182
|
+
phase: 'adversarial',
|
|
1183
|
+
timeoutMs
|
|
1184
|
+
}
|
|
1185
|
+
);
|
|
1186
|
+
finish();
|
|
1187
|
+
}, timeoutMs);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function configureCapabilityChecks(capabilities) {
|
|
1191
|
+
result.capabilityKeys = capabilityKeys(capabilities);
|
|
1192
|
+
for (const definition of CAPABILITY_DEFINITIONS) {
|
|
1193
|
+
const check = result.capabilityChecks[definition.name];
|
|
1194
|
+
check.advertised = result.capabilityKeys.includes(definition.name);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function enqueuePostInitializeRequests() {
|
|
1199
|
+
const operationCapabilities = new Set(operations.map((operation) => capabilityNameForMethod(operation.method)).filter(Boolean));
|
|
1200
|
+
for (let operationIndex = 0; operationIndex < operations.length; operationIndex += 1) {
|
|
1201
|
+
const operation = operations[operationIndex];
|
|
1202
|
+
const operationCapability = capabilityNameForMethod(operation.method);
|
|
1203
|
+
enqueueRequest({
|
|
1204
|
+
kind: 'operation',
|
|
1205
|
+
operationIndex,
|
|
1206
|
+
capability: result.capabilityChecks[operationCapability]?.advertised ? operationCapability : '',
|
|
1207
|
+
method: operation.method,
|
|
1208
|
+
params: operation.params,
|
|
1209
|
+
timeoutCode: 'operation-timeout',
|
|
1210
|
+
timeoutMessage: `no ${operation.method} response within ${timeoutMs}ms`,
|
|
1211
|
+
timeoutDetails: {}
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (probeCapabilities) {
|
|
1216
|
+
for (const definition of CAPABILITY_DEFINITIONS) {
|
|
1217
|
+
const check = result.capabilityChecks[definition.name];
|
|
1218
|
+
if (!check.advertised || operationCapabilities.has(definition.name)) continue;
|
|
1219
|
+
enqueueRequest({
|
|
1220
|
+
kind: 'capability',
|
|
1221
|
+
capability: definition.name,
|
|
1222
|
+
method: definition.method,
|
|
1223
|
+
timeoutCode: 'capability-list-timeout',
|
|
1224
|
+
timeoutMessage: `${definition.method} did not receive a response for advertised ${definition.name} capability within ${timeoutMs}ms`,
|
|
1225
|
+
timeoutDetails: {
|
|
1226
|
+
capability: definition.name,
|
|
1227
|
+
method: definition.method,
|
|
1228
|
+
detailCode: 'capability-request-timeout'
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
for (let probeIndex = 0; probeIndex < adversarialProbes.length; probeIndex += 1) {
|
|
1235
|
+
const probe = adversarialProbes[probeIndex];
|
|
1236
|
+
enqueueRequest({
|
|
1237
|
+
kind: 'adversarial',
|
|
1238
|
+
probeIndex,
|
|
1239
|
+
probeName: probe.name,
|
|
1240
|
+
method: probe.method,
|
|
1241
|
+
params: probe.params,
|
|
1242
|
+
rawLine: probe.rawLine,
|
|
1243
|
+
omitId: probe.omitId,
|
|
1244
|
+
expectation: probe.expectation,
|
|
1245
|
+
failureCode: probe.failureCode,
|
|
1246
|
+
quietMs: Math.min(timeoutMs, probe.quietMs ?? ADVERSARIAL_OBSERVATION_MS)
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function handleCurrentRequestResponse(message) {
|
|
1252
|
+
clearTimeout(timer);
|
|
1253
|
+
const request = currentRequest;
|
|
1254
|
+
if (!isJsonRpcResponse(message)) {
|
|
1255
|
+
addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id ${request.id} is not a ${request.method} response`);
|
|
1256
|
+
finish();
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (request.kind === 'operation') {
|
|
1261
|
+
const operationResult = result.operations[request.operationIndex];
|
|
1262
|
+
operationResult.responded = true;
|
|
1263
|
+
if (result.operation && request.operationIndex === 0) {
|
|
1264
|
+
result.operation.responded = true;
|
|
1265
|
+
}
|
|
1266
|
+
if (message.error) {
|
|
1267
|
+
operationResult.error = message.error;
|
|
1268
|
+
if (result.operation && request.operationIndex === 0) {
|
|
1269
|
+
result.operation.error = message.error;
|
|
1270
|
+
}
|
|
1271
|
+
addIssue('warning', 'operation-error', `${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
if (!message.error && request.method === 'tools/list') {
|
|
1276
|
+
const validation = validateToolsListResult(message.result);
|
|
1277
|
+
result.toolSchema = validation.summary;
|
|
1278
|
+
for (const issue of validation.issues) {
|
|
1279
|
+
addIssue(issue.severity, issue.code, issue.message, issue.details);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (request.capability) {
|
|
1284
|
+
recordCapabilityListShape(request, message.result);
|
|
1285
|
+
handleCapabilityResponse(request, message);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
currentRequest = null;
|
|
1289
|
+
startNextRequest();
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function handleCapabilityResponse(request, message) {
|
|
1293
|
+
const check = result.capabilityChecks[request.capability];
|
|
1294
|
+
check.responded = true;
|
|
1295
|
+
if (!message.error) return;
|
|
1296
|
+
|
|
1297
|
+
check.error = message.error;
|
|
1298
|
+
const unsupported = isUnsupportedMethodError(message.error);
|
|
1299
|
+
const code = unsupported ? 'capability-list-unsupported' : 'capability-list-error';
|
|
1300
|
+
const messageText = unsupported
|
|
1301
|
+
? `${request.capability} capability is advertised but ${request.method} returned Method not found`
|
|
1302
|
+
: `${request.capability} capability is advertised but ${request.method} returned error: ${message.error.message || JSON.stringify(message.error)}`;
|
|
1303
|
+
addIssue('error', code, messageText, {
|
|
1304
|
+
capability: request.capability,
|
|
1305
|
+
method: request.method,
|
|
1306
|
+
errorCode: message.error.code ?? null
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function markAdversarialProbeRunning(request) {
|
|
1311
|
+
const probe = result.adversarial.probes[request.probeIndex];
|
|
1312
|
+
if (!probe) return;
|
|
1313
|
+
probe.status = 'running';
|
|
1314
|
+
probe.started = true;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function completeAdversarialProbe(request, status, issueCodes = [], response = {}) {
|
|
1318
|
+
const probe = result.adversarial.probes[request.probeIndex];
|
|
1319
|
+
if (!probe) return;
|
|
1320
|
+
probe.status = status;
|
|
1321
|
+
probe.responded = Boolean(response.responded);
|
|
1322
|
+
probe.error = response.error ?? null;
|
|
1323
|
+
probe.issueCodes = [...new Set(issueCodes)].sort();
|
|
1324
|
+
probe.durationMs = request.startedAt ? Date.now() - request.startedAt : null;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function failAdversarialProbe(request, code, message, details = {}, response = {}) {
|
|
1328
|
+
completeAdversarialProbe(request, 'fail', [code], response);
|
|
1329
|
+
addIssue('error', code, message, {
|
|
1330
|
+
probe: request.probeName,
|
|
1331
|
+
method: request.method || '',
|
|
1332
|
+
...details
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function handleAdversarialFrame(message) {
|
|
1337
|
+
clearTimeout(timer);
|
|
1338
|
+
const request = currentRequest;
|
|
1339
|
+
|
|
1340
|
+
if (request.expectation === 'no-response') {
|
|
1341
|
+
failAdversarialProbe(
|
|
1342
|
+
request,
|
|
1343
|
+
request.failureCode,
|
|
1344
|
+
`${request.probeName} adversarial probe received a response to a notification`,
|
|
1345
|
+
{},
|
|
1346
|
+
{ responded: true }
|
|
1347
|
+
);
|
|
1348
|
+
finish();
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (!request.rawLine && !request.omitId && isResponseIdTypeMismatch(message, request.id)) {
|
|
1353
|
+
failAdversarialProbe(
|
|
1354
|
+
request,
|
|
1355
|
+
'response-id-type-mismatch',
|
|
1356
|
+
`${request.probeName} adversarial response id ${JSON.stringify(message.id)} does not exactly match request id ${request.id}`,
|
|
1357
|
+
{},
|
|
1358
|
+
{ responded: true }
|
|
1359
|
+
);
|
|
1360
|
+
finish();
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if (!request.rawLine && !request.omitId && isResponseIdMismatch(message, request.id)) {
|
|
1365
|
+
failAdversarialProbe(
|
|
1366
|
+
request,
|
|
1367
|
+
'response-id-mismatch',
|
|
1368
|
+
`${request.probeName} adversarial response id ${JSON.stringify(message.id)} does not match request id ${request.id}`,
|
|
1369
|
+
{},
|
|
1370
|
+
{ responded: true }
|
|
1371
|
+
);
|
|
1372
|
+
finish();
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (!isJsonRpcResponse(message)) {
|
|
1377
|
+
failAdversarialProbe(
|
|
1378
|
+
request,
|
|
1379
|
+
'adversarial-probe-invalid-stdout',
|
|
1380
|
+
`${request.probeName} adversarial probe received a JSON-RPC frame that was not a response`,
|
|
1381
|
+
{},
|
|
1382
|
+
{ responded: true }
|
|
1383
|
+
);
|
|
1384
|
+
finish();
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if (message.error) {
|
|
1389
|
+
completeAdversarialProbe(request, 'pass', [], {
|
|
1390
|
+
responded: true,
|
|
1391
|
+
error: {
|
|
1392
|
+
code: message.error.code,
|
|
1393
|
+
message: message.error.message
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
currentRequest = null;
|
|
1397
|
+
startNextRequest();
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
failAdversarialProbe(
|
|
1402
|
+
request,
|
|
1403
|
+
request.failureCode,
|
|
1404
|
+
`${request.probeName} adversarial probe returned success where a structured error was expected`,
|
|
1405
|
+
{},
|
|
1406
|
+
{ responded: true }
|
|
1407
|
+
);
|
|
1408
|
+
finish();
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function recordCapabilityListShape(request, responseResult) {
|
|
1412
|
+
if (!isObjectRecord(responseResult)) return;
|
|
1413
|
+
const check = result.capabilityChecks[request.capability];
|
|
1414
|
+
const items = responseResult[request.capability];
|
|
1415
|
+
if (check && Array.isArray(items)) {
|
|
1416
|
+
check.itemCount = items.length;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function addCapabilityMissingResponseIssue(request) {
|
|
1421
|
+
addIssue('error', 'capability-list-missing-response', `${request.method} did not receive a response before server exit`, {
|
|
1422
|
+
capability: request.capability,
|
|
1423
|
+
method: request.method,
|
|
1424
|
+
detailCode: 'capability-missing-response'
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
409
1428
|
const pythonBufferingIssue = detectPythonBufferingIssue(commandWithArgs, env);
|
|
410
1429
|
if (pythonBufferingIssue) {
|
|
411
1430
|
addIssue('warning', 'python-buffered-stdio', pythonBufferingIssue);
|
|
@@ -462,8 +1481,10 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
462
1481
|
if (result.durationMs) return;
|
|
463
1482
|
clearTimeout(timer);
|
|
464
1483
|
const exitPhase = initialized
|
|
465
|
-
?
|
|
466
|
-
? '
|
|
1484
|
+
? currentRequest
|
|
1485
|
+
? currentRequest.kind === 'adversarial'
|
|
1486
|
+
? 'adversarial'
|
|
1487
|
+
: 'operation'
|
|
467
1488
|
: 'post-initialize'
|
|
468
1489
|
: 'initialize';
|
|
469
1490
|
result.process.phase = exitPhase;
|
|
@@ -473,8 +1494,29 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
473
1494
|
if (stdoutBuffer.trim()) {
|
|
474
1495
|
addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
|
|
475
1496
|
}
|
|
476
|
-
if (!endedByGuard && initialized &&
|
|
477
|
-
|
|
1497
|
+
if (!endedByGuard && initialized && currentRequest?.kind === 'adversarial') {
|
|
1498
|
+
result.process.phase = 'adversarial';
|
|
1499
|
+
failAdversarialProbe(
|
|
1500
|
+
currentRequest,
|
|
1501
|
+
'adversarial-probe-crash',
|
|
1502
|
+
`server exited during ${currentRequest.probeName} adversarial probe (code ${code ?? 'null'}, signal ${signal ?? 'null'})`,
|
|
1503
|
+
adversarialExitIssueDetails(code, signal)
|
|
1504
|
+
);
|
|
1505
|
+
finish();
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (!endedByGuard && initialized && currentRequest?.kind === 'operation') {
|
|
1509
|
+
const operationResult = result.operations[currentRequest.operationIndex];
|
|
1510
|
+
if (operationResult && !operationResult.responded) {
|
|
1511
|
+
addIssue('error', 'operation-missing-response', `${operationResult.method} did not receive a response before server exit`, {
|
|
1512
|
+
...exitIssueDetails('during-operation', code, signal),
|
|
1513
|
+
operationIndex: currentRequest.operationIndex,
|
|
1514
|
+
method: operationResult.method
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
if (!endedByGuard && initialized && currentRequest?.capability) {
|
|
1519
|
+
addCapabilityMissingResponseIssue(currentRequest);
|
|
478
1520
|
}
|
|
479
1521
|
if (!endedByGuard && initialized && isAbnormalExit(code, signal)) {
|
|
480
1522
|
addIssue('error', 'server-crashed', `server exited after initialize (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('after-initialize', code, signal));
|
|
@@ -516,17 +1558,42 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
516
1558
|
message = JSON.parse(line);
|
|
517
1559
|
} catch {
|
|
518
1560
|
addIssue('error', 'stdout-non-json', `stdout line ${frames.length + 1} is not JSON-RPC: ${quote(line)}`);
|
|
1561
|
+
if (currentRequest?.kind === 'adversarial') {
|
|
1562
|
+
failAdversarialProbe(
|
|
1563
|
+
currentRequest,
|
|
1564
|
+
'adversarial-probe-invalid-stdout',
|
|
1565
|
+
`${currentRequest.probeName} adversarial probe received non-JSON stdout`,
|
|
1566
|
+
{},
|
|
1567
|
+
{ responded: true }
|
|
1568
|
+
);
|
|
1569
|
+
finish();
|
|
1570
|
+
}
|
|
519
1571
|
return;
|
|
520
1572
|
}
|
|
521
1573
|
|
|
522
1574
|
const validation = validateJsonRpc(message);
|
|
523
1575
|
if (validation) {
|
|
524
1576
|
addIssue('error', 'stdout-invalid-json-rpc', validation);
|
|
1577
|
+
if (currentRequest?.kind === 'adversarial') {
|
|
1578
|
+
failAdversarialProbe(
|
|
1579
|
+
currentRequest,
|
|
1580
|
+
'adversarial-probe-invalid-stdout',
|
|
1581
|
+
`${currentRequest.probeName} adversarial probe received invalid JSON-RPC stdout`,
|
|
1582
|
+
{},
|
|
1583
|
+
{ responded: true }
|
|
1584
|
+
);
|
|
1585
|
+
finish();
|
|
1586
|
+
}
|
|
525
1587
|
return;
|
|
526
1588
|
}
|
|
527
1589
|
|
|
528
1590
|
frames.push(message);
|
|
529
1591
|
|
|
1592
|
+
if (initialized && currentRequest?.kind === 'adversarial') {
|
|
1593
|
+
handleAdversarialFrame(message);
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
530
1597
|
if (!initialized && isResponseIdTypeMismatch(message, 1)) {
|
|
531
1598
|
clearTimeout(timer);
|
|
532
1599
|
addIssue('error', 'response-id-type-mismatch', `initialize response id ${JSON.stringify(message.id)} does not exactly match request id 1`);
|
|
@@ -566,50 +1633,26 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
566
1633
|
}
|
|
567
1634
|
|
|
568
1635
|
initialized = true;
|
|
569
|
-
result.
|
|
1636
|
+
configureCapabilityChecks(message.result.capabilities);
|
|
1637
|
+
result.process.phase = 'post-initialize';
|
|
570
1638
|
result.negotiatedProtocol = message.result?.protocolVersion || '';
|
|
571
1639
|
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
id: 2,
|
|
576
|
-
method: operation.method
|
|
577
|
-
};
|
|
578
|
-
if (operation.params !== undefined) {
|
|
579
|
-
request.params = operation.params;
|
|
580
|
-
}
|
|
581
|
-
send(request);
|
|
582
|
-
armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms`, 'operation');
|
|
583
|
-
} else {
|
|
584
|
-
finishSoon();
|
|
585
|
-
}
|
|
586
|
-
} else if (initialized && !operation && isJsonRpcResponse(message)) {
|
|
1640
|
+
enqueuePostInitializeRequests();
|
|
1641
|
+
startNextRequest();
|
|
1642
|
+
} else if (initialized && currentRequest && isResponseIdTypeMismatch(message, currentRequest.id)) {
|
|
587
1643
|
clearTimeout(timer);
|
|
588
|
-
addIssue('error', '
|
|
1644
|
+
addIssue('error', 'response-id-type-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not exactly match request id ${currentRequest.id}`);
|
|
589
1645
|
finish();
|
|
590
|
-
} else if (initialized &&
|
|
1646
|
+
} else if (initialized && currentRequest && isResponseIdMismatch(message, currentRequest.id)) {
|
|
591
1647
|
clearTimeout(timer);
|
|
592
|
-
addIssue('error', 'response-id-
|
|
1648
|
+
addIssue('error', 'response-id-mismatch', `${currentRequest.method} response id ${JSON.stringify(message.id)} does not match request id ${currentRequest.id}`);
|
|
593
1649
|
finish();
|
|
594
|
-
} else if (initialized &&
|
|
1650
|
+
} else if (initialized && currentRequest && message.id === currentRequest.id) {
|
|
1651
|
+
handleCurrentRequestResponse(message);
|
|
1652
|
+
} else if (initialized && !currentRequest && isJsonRpcResponse(message)) {
|
|
595
1653
|
clearTimeout(timer);
|
|
596
|
-
addIssue('error', 'response
|
|
1654
|
+
addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
|
|
597
1655
|
finish();
|
|
598
|
-
} else if (initialized && operation && message.id === 2) {
|
|
599
|
-
clearTimeout(timer);
|
|
600
|
-
if (!isJsonRpcResponse(message)) {
|
|
601
|
-
addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id 2 is not a ${operation.method} response`);
|
|
602
|
-
finish();
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
result.operation.responded = true;
|
|
607
|
-
result.process.phase = 'post-initialize';
|
|
608
|
-
if (message.error) {
|
|
609
|
-
result.operation.error = message.error;
|
|
610
|
-
addIssue('warning', 'operation-error', `${operation.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
|
|
611
|
-
}
|
|
612
|
-
finishSoon();
|
|
613
1656
|
}
|
|
614
1657
|
}
|
|
615
1658
|
});
|
|
@@ -655,12 +1698,41 @@ function defaultProcessInfo(timeoutMs) {
|
|
|
655
1698
|
|
|
656
1699
|
function timeoutIssueDetails(code, timeoutMs, phase) {
|
|
657
1700
|
return {
|
|
658
|
-
detailCode: code === 'operation-timeout'
|
|
1701
|
+
detailCode: code === 'operation-timeout'
|
|
1702
|
+
? 'request-timeout'
|
|
1703
|
+
: code === 'capability-list-timeout'
|
|
1704
|
+
? 'capability-request-timeout'
|
|
1705
|
+
: 'startup-timeout',
|
|
659
1706
|
phase,
|
|
660
1707
|
timeoutMs
|
|
661
1708
|
};
|
|
662
1709
|
}
|
|
663
1710
|
|
|
1711
|
+
function defaultCapabilityChecks() {
|
|
1712
|
+
return Object.fromEntries(CAPABILITY_DEFINITIONS.map((definition) => [
|
|
1713
|
+
definition.name,
|
|
1714
|
+
{
|
|
1715
|
+
advertised: false,
|
|
1716
|
+
method: definition.method,
|
|
1717
|
+
responded: false,
|
|
1718
|
+
itemCount: null,
|
|
1719
|
+
error: null
|
|
1720
|
+
}
|
|
1721
|
+
]));
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function capabilityNameForMethod(method) {
|
|
1725
|
+
return CAPABILITY_DEFINITIONS.find((definition) => definition.method === method)?.name ?? '';
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function capabilityKeys(capabilities) {
|
|
1729
|
+
return isObjectRecord(capabilities) ? Object.keys(capabilities).sort() : [];
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function isUnsupportedMethodError(error) {
|
|
1733
|
+
return error?.code === -32601 || /method not found/i.test(error?.message || '');
|
|
1734
|
+
}
|
|
1735
|
+
|
|
664
1736
|
function exitIssueDetails(position, code, signal) {
|
|
665
1737
|
return {
|
|
666
1738
|
detailCode: exitDetailCode(position, code, signal),
|
|
@@ -674,6 +1746,15 @@ function exitIssueDetails(position, code, signal) {
|
|
|
674
1746
|
};
|
|
675
1747
|
}
|
|
676
1748
|
|
|
1749
|
+
function adversarialExitIssueDetails(code, signal) {
|
|
1750
|
+
return {
|
|
1751
|
+
detailCode: exitDetailCode('during-adversarial', code, signal),
|
|
1752
|
+
phase: 'adversarial',
|
|
1753
|
+
exitCode: code,
|
|
1754
|
+
signal
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
|
|
677
1758
|
function exitDetailCode(position, code, signal) {
|
|
678
1759
|
if (signal) return `signal-exit-${position}`;
|
|
679
1760
|
if (code === 0) return `clean-exit-${position}`;
|
|
@@ -786,35 +1867,286 @@ function validateInitializeResult(result) {
|
|
|
786
1867
|
});
|
|
787
1868
|
}
|
|
788
1869
|
|
|
789
|
-
if (!Object.hasOwn(result, 'capabilities')) {
|
|
790
|
-
issues.push({
|
|
791
|
-
severity: 'error',
|
|
792
|
-
code: 'initialize-missing-capabilities',
|
|
793
|
-
message: 'initialize result is missing capabilities'
|
|
794
|
-
});
|
|
795
|
-
} else if (!result.capabilities || typeof result.capabilities !== 'object' || Array.isArray(result.capabilities)) {
|
|
796
|
-
issues.push({
|
|
797
|
-
severity: 'error',
|
|
798
|
-
code: 'initialize-invalid-capabilities',
|
|
799
|
-
message: 'initialize result capabilities must be an object'
|
|
800
|
-
});
|
|
1870
|
+
if (!Object.hasOwn(result, 'capabilities')) {
|
|
1871
|
+
issues.push({
|
|
1872
|
+
severity: 'error',
|
|
1873
|
+
code: 'initialize-missing-capabilities',
|
|
1874
|
+
message: 'initialize result is missing capabilities'
|
|
1875
|
+
});
|
|
1876
|
+
} else if (!result.capabilities || typeof result.capabilities !== 'object' || Array.isArray(result.capabilities)) {
|
|
1877
|
+
issues.push({
|
|
1878
|
+
severity: 'error',
|
|
1879
|
+
code: 'initialize-invalid-capabilities',
|
|
1880
|
+
message: 'initialize result capabilities must be an object'
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
if (!Object.hasOwn(result, 'serverInfo')) {
|
|
1885
|
+
issues.push({
|
|
1886
|
+
severity: 'warning',
|
|
1887
|
+
code: 'initialize-missing-server-info',
|
|
1888
|
+
message: 'initialize result is missing serverInfo'
|
|
1889
|
+
});
|
|
1890
|
+
} else if (!result.serverInfo || typeof result.serverInfo !== 'object' || Array.isArray(result.serverInfo)) {
|
|
1891
|
+
issues.push({
|
|
1892
|
+
severity: 'warning',
|
|
1893
|
+
code: 'initialize-invalid-server-info',
|
|
1894
|
+
message: 'initialize result serverInfo should be an object'
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
return issues;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function validateToolsListResult(result) {
|
|
1902
|
+
const summary = {
|
|
1903
|
+
...defaultToolSchemaValidation(),
|
|
1904
|
+
checked: true
|
|
1905
|
+
};
|
|
1906
|
+
const issues = [];
|
|
1907
|
+
|
|
1908
|
+
if (!isObjectRecord(result) || !Array.isArray(result.tools)) {
|
|
1909
|
+
addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', 'tools/list result must be an object with a tools array');
|
|
1910
|
+
summary.errorCount = 1;
|
|
1911
|
+
return { summary, issues };
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
summary.toolCount = result.tools.length;
|
|
1915
|
+
const seenNames = new Map();
|
|
1916
|
+
|
|
1917
|
+
for (let index = 0; index < result.tools.length; index += 1) {
|
|
1918
|
+
const tool = result.tools[index];
|
|
1919
|
+
let toolValid = true;
|
|
1920
|
+
|
|
1921
|
+
if (!isObjectRecord(tool)) {
|
|
1922
|
+
addToolSchemaIssue(issues, 'error', 'tools-list-invalid-result', `tools[${index}] must be an object`, { toolIndex: index });
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const name = validateToolName(tool, index, issues);
|
|
1927
|
+
toolValid = Boolean(name);
|
|
1928
|
+
if (name) {
|
|
1929
|
+
summary.toolNames.push(name);
|
|
1930
|
+
if (seenNames.has(name)) {
|
|
1931
|
+
const firstToolIndex = seenNames.get(name);
|
|
1932
|
+
toolValid = false;
|
|
1933
|
+
summary.duplicateNames.push(name);
|
|
1934
|
+
addToolSchemaIssue(
|
|
1935
|
+
issues,
|
|
1936
|
+
'error',
|
|
1937
|
+
'tool-name-duplicate',
|
|
1938
|
+
`tools/list returned duplicate tool name ${JSON.stringify(name)}`,
|
|
1939
|
+
{ toolIndex: index, firstToolIndex, toolName: name }
|
|
1940
|
+
);
|
|
1941
|
+
} else {
|
|
1942
|
+
seenNames.set(name, index);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
if (typeof tool.description !== 'string' || !tool.description.trim()) {
|
|
1947
|
+
addToolSchemaIssue(
|
|
1948
|
+
issues,
|
|
1949
|
+
'warning',
|
|
1950
|
+
'tool-description-missing',
|
|
1951
|
+
name
|
|
1952
|
+
? `tool ${JSON.stringify(name)} is missing a non-empty description`
|
|
1953
|
+
: `tools[${index}] is missing a non-empty description`,
|
|
1954
|
+
{ toolIndex: index, toolName: name || '' }
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
if (!Object.hasOwn(tool, 'inputSchema')) {
|
|
1959
|
+
addToolSchemaIssue(
|
|
1960
|
+
issues,
|
|
1961
|
+
'error',
|
|
1962
|
+
'tool-input-schema-invalid',
|
|
1963
|
+
name
|
|
1964
|
+
? `tool ${JSON.stringify(name)} is missing inputSchema`
|
|
1965
|
+
: `tools[${index}] is missing inputSchema`,
|
|
1966
|
+
{ toolIndex: index, toolName: name || '', schemaPath: 'inputSchema' }
|
|
1967
|
+
);
|
|
1968
|
+
toolValid = false;
|
|
1969
|
+
} else {
|
|
1970
|
+
toolValid = validateInputSchema(tool.inputSchema, {
|
|
1971
|
+
toolIndex: index,
|
|
1972
|
+
toolName: name || '',
|
|
1973
|
+
schemaPath: 'inputSchema'
|
|
1974
|
+
}, issues) && toolValid;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
if (toolValid) {
|
|
1978
|
+
summary.validToolCount += 1;
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
summary.duplicateNames = [...new Set(summary.duplicateNames)].sort();
|
|
1983
|
+
summary.toolNames = [...new Set(summary.toolNames)].sort();
|
|
1984
|
+
summary.errorCount = issues.filter((issue) => issue.severity === 'error').length;
|
|
1985
|
+
summary.warningCount = issues.filter((issue) => issue.severity === 'warning').length;
|
|
1986
|
+
return { summary, issues };
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function validateToolName(tool, index, issues) {
|
|
1990
|
+
if (typeof tool.name !== 'string' || !tool.name.trim() || tool.name !== tool.name.trim()) {
|
|
1991
|
+
addToolSchemaIssue(issues, 'error', 'tool-name-invalid', `tools[${index}] name must be a stable non-empty string`, {
|
|
1992
|
+
toolIndex: index,
|
|
1993
|
+
toolName: typeof tool.name === 'string' ? tool.name : ''
|
|
1994
|
+
});
|
|
1995
|
+
return '';
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
return tool.name;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function validateInputSchema(schema, context, issues) {
|
|
2002
|
+
return validateSchemaValue(schema, context, issues);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
function validateSchemaValue(schema, context, issues) {
|
|
2006
|
+
if (typeof schema === 'boolean') {
|
|
2007
|
+
return true;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
return validateSchemaObject(schema, context, issues);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function validateSchemaObject(schema, context, issues) {
|
|
2014
|
+
if (!isObjectRecord(schema)) {
|
|
2015
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a JSON Schema object or boolean schema`, context);
|
|
2016
|
+
return false;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
let valid = true;
|
|
2020
|
+
|
|
2021
|
+
if (Object.hasOwn(schema, 'type') && !isValidJsonSchemaType(schema.type)) {
|
|
2022
|
+
valid = false;
|
|
2023
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.type must be a string or array of strings`, context);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
if (Object.hasOwn(schema, 'properties')) {
|
|
2027
|
+
if (!isObjectRecord(schema.properties)) {
|
|
2028
|
+
valid = false;
|
|
2029
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.properties must be an object`, context);
|
|
2030
|
+
} else {
|
|
2031
|
+
for (const [propertyName, propertySchema] of Object.entries(schema.properties)) {
|
|
2032
|
+
valid = validateSchemaValue(propertySchema, {
|
|
2033
|
+
...context,
|
|
2034
|
+
schemaPath: `${context.schemaPath}.properties.${propertyName}`
|
|
2035
|
+
}, issues) && valid;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
if (Object.hasOwn(schema, 'required')) {
|
|
2041
|
+
valid = validateRequired(schema, context, issues) && valid;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
if (Object.hasOwn(schema, 'items')) {
|
|
2045
|
+
valid = validateSchemaValueOrArray(schema.items, {
|
|
2046
|
+
...context,
|
|
2047
|
+
schemaPath: `${context.schemaPath}.items`
|
|
2048
|
+
}, issues) && valid;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
if (Object.hasOwn(schema, 'additionalProperties') && typeof schema.additionalProperties !== 'boolean') {
|
|
2052
|
+
valid = validateSchemaObject(schema.additionalProperties, {
|
|
2053
|
+
...context,
|
|
2054
|
+
schemaPath: `${context.schemaPath}.additionalProperties`
|
|
2055
|
+
}, issues) && valid;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
|
|
2059
|
+
if (Object.hasOwn(schema, keyword)) {
|
|
2060
|
+
valid = validateSchemaArray(schema[keyword], {
|
|
2061
|
+
...context,
|
|
2062
|
+
schemaPath: `${context.schemaPath}.${keyword}`
|
|
2063
|
+
}, issues) && valid;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
if (Object.hasOwn(schema, 'enum') && !Array.isArray(schema.enum)) {
|
|
2068
|
+
valid = false;
|
|
2069
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.enum must be an array`, context);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
return valid;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
function validateSchemaValueOrArray(value, context, issues) {
|
|
2076
|
+
if (Array.isArray(value)) {
|
|
2077
|
+
return validateSchemaArray(value, context, issues);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
return validateSchemaValue(value, context, issues);
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
function validateSchemaArray(value, context, issues) {
|
|
2084
|
+
if (!Array.isArray(value) || !value.length) {
|
|
2085
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath} must be a non-empty array of schemas`, context);
|
|
2086
|
+
return false;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
let valid = true;
|
|
2090
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
2091
|
+
valid = validateSchemaValue(value[index], {
|
|
2092
|
+
...context,
|
|
2093
|
+
schemaPath: `${context.schemaPath}[${index}]`
|
|
2094
|
+
}, issues) && valid;
|
|
2095
|
+
}
|
|
2096
|
+
return valid;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
function validateRequired(schema, context, issues) {
|
|
2100
|
+
if (!Array.isArray(schema.required) || schema.required.some((entry) => typeof entry !== 'string' || !entry)) {
|
|
2101
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must be an array of non-empty strings`, context);
|
|
2102
|
+
return false;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
let valid = true;
|
|
2106
|
+
const seen = new Set();
|
|
2107
|
+
for (const propertyName of schema.required) {
|
|
2108
|
+
if (seen.has(propertyName)) {
|
|
2109
|
+
valid = false;
|
|
2110
|
+
addToolSchemaIssue(issues, 'error', 'tool-input-schema-invalid', `${context.schemaPath}.required must not contain duplicate entries`, {
|
|
2111
|
+
...context,
|
|
2112
|
+
propertyName
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
seen.add(propertyName);
|
|
2116
|
+
|
|
2117
|
+
if (!isObjectRecord(schema.properties) || !Object.hasOwn(schema.properties, propertyName)) {
|
|
2118
|
+
valid = false;
|
|
2119
|
+
addToolSchemaIssue(
|
|
2120
|
+
issues,
|
|
2121
|
+
'error',
|
|
2122
|
+
'tool-input-schema-required-missing',
|
|
2123
|
+
`${context.schemaPath}.required references missing property ${JSON.stringify(propertyName)}`,
|
|
2124
|
+
{ ...context, propertyName }
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
801
2127
|
}
|
|
802
2128
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
} else if (!result.serverInfo || typeof result.serverInfo !== 'object' || Array.isArray(result.serverInfo)) {
|
|
810
|
-
issues.push({
|
|
811
|
-
severity: 'warning',
|
|
812
|
-
code: 'initialize-invalid-server-info',
|
|
813
|
-
message: 'initialize result serverInfo should be an object'
|
|
814
|
-
});
|
|
2129
|
+
return valid;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
function isValidJsonSchemaType(value) {
|
|
2133
|
+
if (typeof value === 'string') {
|
|
2134
|
+
return JSON_SCHEMA_TYPES.has(value);
|
|
815
2135
|
}
|
|
2136
|
+
return Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === 'string' && JSON_SCHEMA_TYPES.has(entry));
|
|
2137
|
+
}
|
|
816
2138
|
|
|
817
|
-
|
|
2139
|
+
function addToolSchemaIssue(issues, severity, code, message, details = {}) {
|
|
2140
|
+
issues.push({
|
|
2141
|
+
severity,
|
|
2142
|
+
code,
|
|
2143
|
+
message,
|
|
2144
|
+
details
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function isObjectRecord(value) {
|
|
2149
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
818
2150
|
}
|
|
819
2151
|
|
|
820
2152
|
export function classifyIssueCode(code) {
|
|
@@ -823,7 +2155,8 @@ export function classifyIssueCode(code) {
|
|
|
823
2155
|
|
|
824
2156
|
export function createFingerprint(commandWithArgs, options = {}) {
|
|
825
2157
|
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
826
|
-
const
|
|
2158
|
+
const operations = normalizeGuardOperations(options.operations ?? (options.operation ? [options.operation] : []));
|
|
2159
|
+
const adversarialProbes = normalizeAdversarialProbeSpecs(options.adversarialProbes ?? []);
|
|
827
2160
|
|
|
828
2161
|
return {
|
|
829
2162
|
guard: {
|
|
@@ -841,14 +2174,32 @@ export function createFingerprint(commandWithArgs, options = {}) {
|
|
|
841
2174
|
exists: fs.existsSync(cwd)
|
|
842
2175
|
},
|
|
843
2176
|
protocol: options.protocol ?? DEFAULT_PROTOCOL,
|
|
2177
|
+
config: options.config ?? defaultConfigMetadata(),
|
|
2178
|
+
profile: options.profile ?? DEFAULT_PROFILE,
|
|
844
2179
|
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT,
|
|
845
2180
|
repeat: options.repeat ?? 1,
|
|
846
|
-
|
|
2181
|
+
capabilityProbes: options.probeCapabilities ?? true,
|
|
2182
|
+
adversarialProbes: adversarialProbes.map((probe) => ({
|
|
2183
|
+
name: probe.name,
|
|
2184
|
+
source: probe.source,
|
|
2185
|
+
method: probe.method || '',
|
|
2186
|
+
safeToolCallName: probe.safeToolCallName || '',
|
|
2187
|
+
expectation: probe.expectation
|
|
2188
|
+
})),
|
|
2189
|
+
operation: operations.length === 1
|
|
847
2190
|
? {
|
|
848
|
-
method:
|
|
849
|
-
hasParams:
|
|
2191
|
+
method: operations[0].method,
|
|
2192
|
+
hasParams: operations[0].params !== undefined,
|
|
2193
|
+
source: operations[0].source,
|
|
2194
|
+
safeToolCallName: operations[0].safeToolCallName
|
|
850
2195
|
}
|
|
851
2196
|
: null,
|
|
2197
|
+
operations: operations.map((operation) => ({
|
|
2198
|
+
method: operation.method,
|
|
2199
|
+
hasParams: operation.params !== undefined,
|
|
2200
|
+
source: operation.source,
|
|
2201
|
+
safeToolCallName: operation.safeToolCallName
|
|
2202
|
+
})),
|
|
852
2203
|
system: {
|
|
853
2204
|
platform: process.platform,
|
|
854
2205
|
arch: process.arch,
|
|
@@ -1171,9 +2522,22 @@ function isNpxCommand(base) {
|
|
|
1171
2522
|
|
|
1172
2523
|
function finalizeResult(result) {
|
|
1173
2524
|
result.schemaVersion = JSON_SCHEMA_VERSION;
|
|
2525
|
+
result.config ??= defaultConfigMetadata();
|
|
2526
|
+
result.profile ??= DEFAULT_PROFILE;
|
|
2527
|
+
result.capabilityProbes ??= true;
|
|
2528
|
+
result.adversarial ??= defaultAdversarialResult();
|
|
2529
|
+
result.operations ??= result.operation ? [{
|
|
2530
|
+
index: 0,
|
|
2531
|
+
source: result.operation.source ?? 'request',
|
|
2532
|
+
method: result.operation.method,
|
|
2533
|
+
safeToolCallName: result.operation.safeToolCallName ?? '',
|
|
2534
|
+
responded: Boolean(result.operation.responded),
|
|
2535
|
+
error: result.operation.error ?? null
|
|
2536
|
+
}] : [];
|
|
1174
2537
|
result.staticScan ??= defaultStaticScan();
|
|
1175
2538
|
result.staticFindings ??= [];
|
|
1176
2539
|
result.issues = normalizeIssues(result.issues ?? []);
|
|
2540
|
+
finalizeAdversarialResult(result);
|
|
1177
2541
|
result.ok = !result.issues.some((issue) => issue.severity === 'error');
|
|
1178
2542
|
result.checks = buildChecks(result);
|
|
1179
2543
|
result.issueClasses = buildIssueClasses(result.issues);
|
|
@@ -1181,6 +2545,17 @@ function finalizeResult(result) {
|
|
|
1181
2545
|
return result;
|
|
1182
2546
|
}
|
|
1183
2547
|
|
|
2548
|
+
function finalizeAdversarialResult(result) {
|
|
2549
|
+
if (!result.adversarial?.enabled) return;
|
|
2550
|
+
for (const probe of result.adversarial.probes) {
|
|
2551
|
+
if (probe.status === 'pending' || probe.status === 'running') {
|
|
2552
|
+
probe.status = 'skipped';
|
|
2553
|
+
probe.durationMs ??= null;
|
|
2554
|
+
}
|
|
2555
|
+
probe.issueCodes = [...new Set(probe.issueCodes ?? [])].sort();
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
1184
2559
|
function finalizeFingerprint(result) {
|
|
1185
2560
|
if (!result.fingerprint) return;
|
|
1186
2561
|
result.fingerprint.timings ??= {};
|
|
@@ -1216,6 +2591,15 @@ function buildChecks(result) {
|
|
|
1216
2591
|
operation: repeated
|
|
1217
2592
|
? aggregateRunCheck(result, 'operation')
|
|
1218
2593
|
: buildOperationCheck(result, issues),
|
|
2594
|
+
capabilities: repeated
|
|
2595
|
+
? aggregateCapabilityChecks(result)
|
|
2596
|
+
: buildCapabilityChecks(result, issues),
|
|
2597
|
+
toolSchema: repeated
|
|
2598
|
+
? aggregateRunCheck(result, 'toolSchema')
|
|
2599
|
+
: buildToolSchemaCheck(result, issues),
|
|
2600
|
+
adversarial: repeated
|
|
2601
|
+
? aggregateRunCheck(result, 'adversarial')
|
|
2602
|
+
: buildAdversarialCheck(result, issues),
|
|
1219
2603
|
process: buildIssueCheck(issues, (issue) => PROCESS_ISSUE_CODES.has(issue.code)),
|
|
1220
2604
|
pythonBuffering: buildIssueCheck(issues, (issue) => issue.code === 'python-buffered-stdio'),
|
|
1221
2605
|
staticScan: buildStaticScanCheck(result, issues),
|
|
@@ -1241,7 +2625,13 @@ function buildInitializeCheck(result, issues) {
|
|
|
1241
2625
|
}
|
|
1242
2626
|
|
|
1243
2627
|
function buildOperationCheck(result, issues) {
|
|
1244
|
-
|
|
2628
|
+
const operations = result.operations?.length
|
|
2629
|
+
? result.operations
|
|
2630
|
+
: result.operation
|
|
2631
|
+
? [result.operation]
|
|
2632
|
+
: [];
|
|
2633
|
+
|
|
2634
|
+
if (!operations.length) {
|
|
1245
2635
|
return makeCheck('skipped', []);
|
|
1246
2636
|
}
|
|
1247
2637
|
|
|
@@ -1251,14 +2641,130 @@ function buildOperationCheck(result, issues) {
|
|
|
1251
2641
|
|
|
1252
2642
|
const matched = issues.filter((issue) => (
|
|
1253
2643
|
OPERATION_ISSUE_CODES.has(issue.code)
|
|
1254
|
-
|| (
|
|
1255
|
-
|| (
|
|
2644
|
+
|| (operations.some((operation) => !operation.responded) && STDOUT_ISSUE_CODES.has(issue.code))
|
|
2645
|
+
|| (operations.some((operation) => !operation.responded) && JSON_RPC_ISSUE_CODES.has(issue.code))
|
|
2646
|
+
));
|
|
2647
|
+
if (matched.length) {
|
|
2648
|
+
return makeCheck(statusFromIssues(matched), matched);
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
return makeCheck(operations.every((operation) => operation.responded) ? 'pass' : 'fail', []);
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
function buildToolSchemaCheck(result, issues) {
|
|
2655
|
+
if (!result.toolSchema?.checked) {
|
|
2656
|
+
return makeCheck('skipped', []);
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
const matched = issues.filter((issue) => TOOL_SCHEMA_ISSUE_CODES.has(issue.code));
|
|
2660
|
+
if (matched.length) {
|
|
2661
|
+
return makeCheck(statusFromIssues(matched), matched);
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
return makeCheck('pass', []);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
function buildAdversarialCheck(result, issues) {
|
|
2668
|
+
if (!result.adversarial?.enabled) {
|
|
2669
|
+
return makeCheck('skipped', []);
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
const probeNames = new Set(result.adversarial.probes.map((probe) => probe.name));
|
|
2673
|
+
const matched = issues.filter((issue) => (
|
|
2674
|
+
ADVERSARIAL_ISSUE_CODES.has(issue.code)
|
|
2675
|
+
|| (probeNames.has(issue.probe) && issue.class === ISSUE_CLASSES.MCP_PROTOCOL)
|
|
1256
2676
|
));
|
|
1257
2677
|
if (matched.length) {
|
|
1258
2678
|
return makeCheck(statusFromIssues(matched), matched);
|
|
1259
2679
|
}
|
|
1260
2680
|
|
|
1261
|
-
|
|
2681
|
+
const active = result.adversarial.probes.filter((probe) => probe.status !== 'skipped');
|
|
2682
|
+
if (!result.initialized || !active.length) {
|
|
2683
|
+
return makeCheck('skipped', []);
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
return makeCheck(active.every((probe) => probe.status === 'pass') ? 'pass' : 'fail', []);
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
function buildCapabilityChecks(result, issues) {
|
|
2690
|
+
const checks = {};
|
|
2691
|
+
for (const definition of CAPABILITY_DEFINITIONS) {
|
|
2692
|
+
const state = result.capabilityChecks?.[definition.name] ?? defaultCapabilityChecks()[definition.name];
|
|
2693
|
+
const matched = issues.filter((issue) => (
|
|
2694
|
+
CAPABILITY_ISSUE_CODES.has(issue.code)
|
|
2695
|
+
&& issue.capability === definition.name
|
|
2696
|
+
));
|
|
2697
|
+
if (!result.initialized || !state.advertised || result.capabilityProbes === false) {
|
|
2698
|
+
checks[definition.name] = makeCapabilityCheck('skipped', matched, state);
|
|
2699
|
+
} else if (matched.length) {
|
|
2700
|
+
checks[definition.name] = makeCapabilityCheck(statusFromIssues(matched), matched, state);
|
|
2701
|
+
} else {
|
|
2702
|
+
checks[definition.name] = makeCapabilityCheck(state.responded ? 'pass' : 'fail', matched, state);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
const active = Object.values(checks).filter((check) => check.status !== 'skipped');
|
|
2707
|
+
const status = active.length
|
|
2708
|
+
? active.some((check) => check.status === 'fail')
|
|
2709
|
+
? 'fail'
|
|
2710
|
+
: active.some((check) => check.status === 'warning')
|
|
2711
|
+
? 'warning'
|
|
2712
|
+
: 'pass'
|
|
2713
|
+
: 'skipped';
|
|
2714
|
+
return {
|
|
2715
|
+
status,
|
|
2716
|
+
issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
|
|
2717
|
+
...checks
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
function aggregateCapabilityChecks(result) {
|
|
2722
|
+
const checks = {};
|
|
2723
|
+
for (const definition of CAPABILITY_DEFINITIONS) {
|
|
2724
|
+
const runChecks = result.runs
|
|
2725
|
+
.map((run) => run.checks?.capabilities?.[definition.name])
|
|
2726
|
+
.filter(Boolean);
|
|
2727
|
+
const activeRunChecks = runChecks.filter((check) => check.status !== 'skipped');
|
|
2728
|
+
const state = aggregateCapabilityState(definition, runChecks);
|
|
2729
|
+
if (!activeRunChecks.length) {
|
|
2730
|
+
checks[definition.name] = makeAggregatedCapabilityCheck('skipped', [], state);
|
|
2731
|
+
} else {
|
|
2732
|
+
const status = activeRunChecks.some((check) => check.status === 'fail')
|
|
2733
|
+
? 'fail'
|
|
2734
|
+
: activeRunChecks.some((check) => check.status === 'warning')
|
|
2735
|
+
? 'warning'
|
|
2736
|
+
: 'pass';
|
|
2737
|
+
checks[definition.name] = makeAggregatedCapabilityCheck(status, activeRunChecks, state);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
const active = Object.values(checks).filter((check) => check.status !== 'skipped');
|
|
2742
|
+
const status = active.length
|
|
2743
|
+
? active.some((check) => check.status === 'fail')
|
|
2744
|
+
? 'fail'
|
|
2745
|
+
: active.some((check) => check.status === 'warning')
|
|
2746
|
+
? 'warning'
|
|
2747
|
+
: 'pass'
|
|
2748
|
+
: 'skipped';
|
|
2749
|
+
return {
|
|
2750
|
+
status,
|
|
2751
|
+
issueCodes: [...new Set(active.flatMap((check) => check.issueCodes))].sort(),
|
|
2752
|
+
...checks
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function aggregateCapabilityState(definition, runChecks) {
|
|
2757
|
+
const itemCounts = [...new Set(
|
|
2758
|
+
runChecks
|
|
2759
|
+
.map((check) => check.itemCount)
|
|
2760
|
+
.filter((itemCount) => Number.isInteger(itemCount))
|
|
2761
|
+
)];
|
|
2762
|
+
return {
|
|
2763
|
+
advertised: runChecks.some((check) => check.advertised),
|
|
2764
|
+
method: definition.method,
|
|
2765
|
+
responded: runChecks.some((check) => check.responded),
|
|
2766
|
+
itemCount: itemCounts.length === 1 ? itemCounts[0] : null
|
|
2767
|
+
};
|
|
1262
2768
|
}
|
|
1263
2769
|
|
|
1264
2770
|
function buildStaticScanCheck(result, issues) {
|
|
@@ -1284,8 +2790,16 @@ function buildRepeatCheck(result) {
|
|
|
1284
2790
|
}
|
|
1285
2791
|
|
|
1286
2792
|
const failedRuns = result.runs.filter((run) => !run.ok).map((run) => run.run);
|
|
2793
|
+
const repeatIssues = (result.issues ?? []).filter((issue) => (
|
|
2794
|
+
issue.run || REPEAT_DRIFT_ISSUE_CODES.has(issue.code)
|
|
2795
|
+
));
|
|
2796
|
+
const status = failedRuns.length
|
|
2797
|
+
? 'fail'
|
|
2798
|
+
: repeatIssues.some((issue) => issue.severity === 'warning')
|
|
2799
|
+
? 'warning'
|
|
2800
|
+
: 'pass';
|
|
1287
2801
|
return {
|
|
1288
|
-
...makeCheck(
|
|
2802
|
+
...makeCheck(status, repeatIssues),
|
|
1289
2803
|
runs: result.runs.length,
|
|
1290
2804
|
passedRuns: result.runs.length - failedRuns.length,
|
|
1291
2805
|
failedRuns
|
|
@@ -1327,6 +2841,102 @@ function makeCheck(status, issues) {
|
|
|
1327
2841
|
};
|
|
1328
2842
|
}
|
|
1329
2843
|
|
|
2844
|
+
function makeCapabilityCheck(status, issues, state) {
|
|
2845
|
+
return {
|
|
2846
|
+
...makeCheck(status, issues),
|
|
2847
|
+
advertised: Boolean(state.advertised),
|
|
2848
|
+
method: state.method,
|
|
2849
|
+
responded: Boolean(state.responded),
|
|
2850
|
+
itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
function makeAggregatedCapabilityCheck(status, checks, state) {
|
|
2855
|
+
return {
|
|
2856
|
+
status,
|
|
2857
|
+
issueCodes: [...new Set(checks.flatMap((check) => check.issueCodes ?? []))].sort(),
|
|
2858
|
+
advertised: Boolean(state.advertised),
|
|
2859
|
+
method: state.method,
|
|
2860
|
+
responded: Boolean(state.responded),
|
|
2861
|
+
itemCount: Number.isInteger(state.itemCount) ? state.itemCount : null
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
function defaultConfigMetadata() {
|
|
2866
|
+
return {
|
|
2867
|
+
enabled: false,
|
|
2868
|
+
path: '',
|
|
2869
|
+
resolvedPath: '',
|
|
2870
|
+
checks: {
|
|
2871
|
+
command: false,
|
|
2872
|
+
cwd: false,
|
|
2873
|
+
envNames: [],
|
|
2874
|
+
requests: [],
|
|
2875
|
+
safeToolCalls: [],
|
|
2876
|
+
adversarialProbes: [],
|
|
2877
|
+
adversarialToolCalls: []
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
function defaultAdversarialResult(probes = []) {
|
|
2883
|
+
return {
|
|
2884
|
+
enabled: probes.length > 0,
|
|
2885
|
+
probes: probes.map((probe, index) => ({
|
|
2886
|
+
index,
|
|
2887
|
+
name: probe.name,
|
|
2888
|
+
source: probe.source,
|
|
2889
|
+
method: probe.method || '',
|
|
2890
|
+
safeToolCallName: probe.safeToolCallName || '',
|
|
2891
|
+
expectation: probe.expectation,
|
|
2892
|
+
description: probe.description,
|
|
2893
|
+
risk: probe.risk,
|
|
2894
|
+
status: 'pending',
|
|
2895
|
+
started: false,
|
|
2896
|
+
responded: false,
|
|
2897
|
+
error: null,
|
|
2898
|
+
issueCodes: [],
|
|
2899
|
+
durationMs: null
|
|
2900
|
+
}))
|
|
2901
|
+
};
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
function aggregateRunAdversarial(runs, probes = []) {
|
|
2905
|
+
if (!probes.length) {
|
|
2906
|
+
return defaultAdversarialResult();
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
return {
|
|
2910
|
+
enabled: true,
|
|
2911
|
+
probes: probes.map((probe, index) => {
|
|
2912
|
+
const runProbes = runs
|
|
2913
|
+
.map((run) => run.adversarial?.probes?.[index])
|
|
2914
|
+
.filter(Boolean);
|
|
2915
|
+
const active = runProbes.filter((runProbe) => runProbe.status !== 'skipped');
|
|
2916
|
+
const status = active.length
|
|
2917
|
+
? active.some((runProbe) => runProbe.status === 'fail')
|
|
2918
|
+
? 'fail'
|
|
2919
|
+
: active.every((runProbe) => runProbe.status === 'pass')
|
|
2920
|
+
? 'pass'
|
|
2921
|
+
: 'warning'
|
|
2922
|
+
: 'skipped';
|
|
2923
|
+
return {
|
|
2924
|
+
index,
|
|
2925
|
+
name: probe.name,
|
|
2926
|
+
source: probe.source,
|
|
2927
|
+
method: probe.method || '',
|
|
2928
|
+
safeToolCallName: probe.safeToolCallName || '',
|
|
2929
|
+
expectation: probe.expectation,
|
|
2930
|
+
description: probe.description,
|
|
2931
|
+
risk: probe.risk,
|
|
2932
|
+
status,
|
|
2933
|
+
runs: runProbes.length,
|
|
2934
|
+
issueCodes: [...new Set(runProbes.flatMap((runProbe) => runProbe.issueCodes ?? []))].sort()
|
|
2935
|
+
};
|
|
2936
|
+
})
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
|
|
1330
2940
|
function defaultStaticScan() {
|
|
1331
2941
|
return {
|
|
1332
2942
|
enabled: false,
|
|
@@ -1335,6 +2945,253 @@ function defaultStaticScan() {
|
|
|
1335
2945
|
};
|
|
1336
2946
|
}
|
|
1337
2947
|
|
|
2948
|
+
function defaultToolSchemaValidation() {
|
|
2949
|
+
return {
|
|
2950
|
+
checked: false,
|
|
2951
|
+
toolCount: 0,
|
|
2952
|
+
toolNames: [],
|
|
2953
|
+
validToolCount: 0,
|
|
2954
|
+
warningCount: 0,
|
|
2955
|
+
errorCount: 0,
|
|
2956
|
+
duplicateNames: []
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
function aggregateRunToolSchemaValidation(runs) {
|
|
2961
|
+
const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
|
|
2962
|
+
if (!checkedRuns.length) {
|
|
2963
|
+
return defaultToolSchemaValidation();
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
return {
|
|
2967
|
+
checked: true,
|
|
2968
|
+
runs: checkedRuns.length,
|
|
2969
|
+
toolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.toolCount, 0),
|
|
2970
|
+
validToolCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.validToolCount, 0),
|
|
2971
|
+
warningCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.warningCount, 0),
|
|
2972
|
+
errorCount: checkedRuns.reduce((sum, run) => sum + run.toolSchema.errorCount, 0),
|
|
2973
|
+
toolNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.toolNames ?? []))].sort(),
|
|
2974
|
+
duplicateNames: [...new Set(checkedRuns.flatMap((run) => run.toolSchema.duplicateNames))].sort()
|
|
2975
|
+
};
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
function defaultRepeatDrift() {
|
|
2979
|
+
return {
|
|
2980
|
+
checked: false,
|
|
2981
|
+
status: 'skipped',
|
|
2982
|
+
issueCodes: [],
|
|
2983
|
+
baselineRun: null,
|
|
2984
|
+
comparedRuns: [],
|
|
2985
|
+
negotiatedProtocol: driftSection(),
|
|
2986
|
+
capabilities: driftSection(),
|
|
2987
|
+
tools: driftSection(),
|
|
2988
|
+
lists: {
|
|
2989
|
+
resources: driftSection(),
|
|
2990
|
+
prompts: driftSection()
|
|
2991
|
+
}
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
function driftSection() {
|
|
2996
|
+
return {
|
|
2997
|
+
status: 'skipped',
|
|
2998
|
+
issueCodes: [],
|
|
2999
|
+
baseline: null,
|
|
3000
|
+
values: [],
|
|
3001
|
+
changedRuns: []
|
|
3002
|
+
};
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
function buildRepeatDrift(runs) {
|
|
3006
|
+
const drift = defaultRepeatDrift();
|
|
3007
|
+
if (!Array.isArray(runs) || runs.length < 2) {
|
|
3008
|
+
return drift;
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
drift.checked = true;
|
|
3012
|
+
const comparableRuns = runs.filter((run) => run.initialized);
|
|
3013
|
+
drift.comparedRuns = comparableRuns.map((run) => run.run);
|
|
3014
|
+
if (comparableRuns.length < 2) {
|
|
3015
|
+
return drift;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
drift.baselineRun = comparableRuns[0].run;
|
|
3019
|
+
drift.negotiatedProtocol = compareScalarDrift(
|
|
3020
|
+
comparableRuns,
|
|
3021
|
+
(run) => run.negotiatedProtocol || '',
|
|
3022
|
+
'repeat-protocol-drift'
|
|
3023
|
+
);
|
|
3024
|
+
drift.capabilities = compareSetDrift(
|
|
3025
|
+
comparableRuns,
|
|
3026
|
+
(run) => run.capabilityKeys ?? advertisedCapabilityKeys(run.capabilityChecks),
|
|
3027
|
+
'repeat-capability-drift'
|
|
3028
|
+
);
|
|
3029
|
+
drift.tools = compareToolDrift(comparableRuns);
|
|
3030
|
+
drift.lists.resources = compareListShapeDrift(comparableRuns, 'resources');
|
|
3031
|
+
drift.lists.prompts = compareListShapeDrift(comparableRuns, 'prompts');
|
|
3032
|
+
|
|
3033
|
+
const sections = [
|
|
3034
|
+
drift.negotiatedProtocol,
|
|
3035
|
+
drift.capabilities,
|
|
3036
|
+
drift.tools,
|
|
3037
|
+
drift.lists.resources,
|
|
3038
|
+
drift.lists.prompts
|
|
3039
|
+
];
|
|
3040
|
+
const active = sections.filter((section) => section.status !== 'skipped');
|
|
3041
|
+
drift.status = active.length
|
|
3042
|
+
? active.some((section) => section.status === 'warning')
|
|
3043
|
+
? 'warning'
|
|
3044
|
+
: 'pass'
|
|
3045
|
+
: 'skipped';
|
|
3046
|
+
drift.issueCodes = [...new Set(active.flatMap((section) => section.issueCodes))].sort();
|
|
3047
|
+
return drift;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
function compareScalarDrift(runs, valueForRun, issueCode) {
|
|
3051
|
+
const section = driftSection();
|
|
3052
|
+
section.values = runs.map((run) => ({
|
|
3053
|
+
run: run.run,
|
|
3054
|
+
value: valueForRun(run)
|
|
3055
|
+
}));
|
|
3056
|
+
section.baseline = section.values[0].value;
|
|
3057
|
+
section.changedRuns = section.values
|
|
3058
|
+
.slice(1)
|
|
3059
|
+
.filter((entry) => entry.value !== section.baseline)
|
|
3060
|
+
.map((entry) => ({
|
|
3061
|
+
run: entry.run,
|
|
3062
|
+
expected: section.baseline,
|
|
3063
|
+
actual: entry.value
|
|
3064
|
+
}));
|
|
3065
|
+
section.status = section.changedRuns.length ? 'warning' : 'pass';
|
|
3066
|
+
section.issueCodes = section.changedRuns.length ? [issueCode] : [];
|
|
3067
|
+
return section;
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
function compareSetDrift(runs, valuesForRun, issueCode) {
|
|
3071
|
+
const section = driftSection();
|
|
3072
|
+
section.values = runs.map((run) => ({
|
|
3073
|
+
run: run.run,
|
|
3074
|
+
values: sortedUnique(valuesForRun(run))
|
|
3075
|
+
}));
|
|
3076
|
+
section.baseline = section.values[0].values;
|
|
3077
|
+
section.changedRuns = section.values
|
|
3078
|
+
.slice(1)
|
|
3079
|
+
.map((entry) => ({
|
|
3080
|
+
run: entry.run,
|
|
3081
|
+
added: arrayDifference(entry.values, section.baseline),
|
|
3082
|
+
removed: arrayDifference(section.baseline, entry.values)
|
|
3083
|
+
}))
|
|
3084
|
+
.filter((entry) => entry.added.length || entry.removed.length);
|
|
3085
|
+
section.status = section.changedRuns.length ? 'warning' : 'pass';
|
|
3086
|
+
section.issueCodes = section.changedRuns.length ? [issueCode] : [];
|
|
3087
|
+
return section;
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
function compareToolDrift(runs) {
|
|
3091
|
+
const checkedRuns = runs.filter((run) => run.toolSchema?.checked);
|
|
3092
|
+
if (checkedRuns.length < 2) {
|
|
3093
|
+
return driftSection();
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
const section = compareSetDrift(
|
|
3097
|
+
checkedRuns,
|
|
3098
|
+
(run) => run.toolSchema.toolNames ?? [],
|
|
3099
|
+
'repeat-tool-drift'
|
|
3100
|
+
);
|
|
3101
|
+
section.values = checkedRuns.map((run) => ({
|
|
3102
|
+
run: run.run,
|
|
3103
|
+
count: run.toolSchema.toolCount,
|
|
3104
|
+
values: sortedUnique(run.toolSchema.toolNames ?? [])
|
|
3105
|
+
}));
|
|
3106
|
+
section.baselineCount = section.values[0].count;
|
|
3107
|
+
const countDrift = section.values
|
|
3108
|
+
.slice(1)
|
|
3109
|
+
.filter((entry) => entry.count !== section.baselineCount)
|
|
3110
|
+
.map((entry) => ({
|
|
3111
|
+
run: entry.run,
|
|
3112
|
+
expected: section.baselineCount,
|
|
3113
|
+
actual: entry.count
|
|
3114
|
+
}));
|
|
3115
|
+
if (countDrift.length) {
|
|
3116
|
+
section.countChangedRuns = countDrift;
|
|
3117
|
+
section.status = 'warning';
|
|
3118
|
+
section.issueCodes = ['repeat-tool-drift'];
|
|
3119
|
+
}
|
|
3120
|
+
return section;
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
function compareListShapeDrift(runs, capability) {
|
|
3124
|
+
const checkedRuns = runs.filter((run) => Number.isInteger(run.capabilityChecks?.[capability]?.itemCount));
|
|
3125
|
+
if (checkedRuns.length < 2) {
|
|
3126
|
+
return driftSection();
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
const section = compareScalarDrift(
|
|
3130
|
+
checkedRuns,
|
|
3131
|
+
(run) => run.capabilityChecks[capability].itemCount,
|
|
3132
|
+
'repeat-list-shape-drift'
|
|
3133
|
+
);
|
|
3134
|
+
section.capability = capability;
|
|
3135
|
+
return section;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
function repeatDriftIssues(drift) {
|
|
3139
|
+
if (!drift?.checked || drift.status !== 'warning') {
|
|
3140
|
+
return [];
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
const issues = [];
|
|
3144
|
+
if (drift.negotiatedProtocol.status === 'warning') {
|
|
3145
|
+
issues.push(repeatDriftIssue('repeat-protocol-drift', 'negotiated protocol changed across repeat runs', {
|
|
3146
|
+
drift: drift.negotiatedProtocol
|
|
3147
|
+
}));
|
|
3148
|
+
}
|
|
3149
|
+
if (drift.capabilities.status === 'warning') {
|
|
3150
|
+
issues.push(repeatDriftIssue('repeat-capability-drift', 'advertised capability keys changed across repeat runs', {
|
|
3151
|
+
drift: drift.capabilities
|
|
3152
|
+
}));
|
|
3153
|
+
}
|
|
3154
|
+
if (drift.tools.status === 'warning') {
|
|
3155
|
+
issues.push(repeatDriftIssue('repeat-tool-drift', 'tools/list tool names or counts changed across repeat runs', {
|
|
3156
|
+
drift: drift.tools
|
|
3157
|
+
}));
|
|
3158
|
+
}
|
|
3159
|
+
for (const capability of ['resources', 'prompts']) {
|
|
3160
|
+
if (drift.lists[capability].status === 'warning') {
|
|
3161
|
+
issues.push(repeatDriftIssue('repeat-list-shape-drift', `${capability}/list item count changed across repeat runs`, {
|
|
3162
|
+
capability,
|
|
3163
|
+
drift: drift.lists[capability]
|
|
3164
|
+
}));
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
return issues;
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
function repeatDriftIssue(code, message, details) {
|
|
3171
|
+
return {
|
|
3172
|
+
severity: 'warning',
|
|
3173
|
+
code,
|
|
3174
|
+
message,
|
|
3175
|
+
...details
|
|
3176
|
+
};
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
function advertisedCapabilityKeys(capabilityChecks = {}) {
|
|
3180
|
+
return Object.entries(capabilityChecks)
|
|
3181
|
+
.filter(([, check]) => check?.advertised)
|
|
3182
|
+
.map(([name]) => name)
|
|
3183
|
+
.sort();
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
function sortedUnique(values) {
|
|
3187
|
+
return [...new Set((values ?? []).filter((value) => typeof value === 'string'))].sort();
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
function arrayDifference(left, right) {
|
|
3191
|
+
const rightSet = new Set(right);
|
|
3192
|
+
return left.filter((value) => !rightSet.has(value));
|
|
3193
|
+
}
|
|
3194
|
+
|
|
1338
3195
|
export function scanSource(root) {
|
|
1339
3196
|
const findings = [];
|
|
1340
3197
|
const absoluteRoot = path.resolve(root);
|
|
@@ -1344,12 +3201,14 @@ export function scanSource(root) {
|
|
|
1344
3201
|
const text = fs.readFileSync(file, 'utf8');
|
|
1345
3202
|
const lines = text.split(/\r?\n/);
|
|
1346
3203
|
for (let index = 0; index < lines.length; index += 1) {
|
|
1347
|
-
const
|
|
1348
|
-
if (
|
|
3204
|
+
const finding = detectStdoutRisk(file, lines[index]);
|
|
3205
|
+
if (finding) {
|
|
1349
3206
|
findings.push({
|
|
1350
3207
|
file: path.relative(process.cwd(), file).split(path.sep).join('/'),
|
|
1351
3208
|
line: index + 1,
|
|
1352
|
-
|
|
3209
|
+
language: finding.language,
|
|
3210
|
+
reason: finding.reason,
|
|
3211
|
+
message: finding.message
|
|
1353
3212
|
});
|
|
1354
3213
|
}
|
|
1355
3214
|
}
|
|
@@ -1358,34 +3217,254 @@ export function scanSource(root) {
|
|
|
1358
3217
|
return findings;
|
|
1359
3218
|
}
|
|
1360
3219
|
|
|
1361
|
-
function
|
|
1362
|
-
const ext = path.extname(file);
|
|
3220
|
+
function detectStdoutRisk(file, line) {
|
|
3221
|
+
const ext = path.extname(file).toLowerCase();
|
|
1363
3222
|
const stripped = line.trim();
|
|
1364
3223
|
if (!stripped || stripped.startsWith('//') || stripped.startsWith('#')) return '';
|
|
3224
|
+
const code = normalizeSourceLine(line, ext);
|
|
3225
|
+
|
|
3226
|
+
if (isJavaScriptSource(ext)) {
|
|
3227
|
+
if (/\bconsole\.(log|info)\s*\(/.test(code)) {
|
|
3228
|
+
return staticFinding(
|
|
3229
|
+
'javascript',
|
|
3230
|
+
'javascript-console-stdout',
|
|
3231
|
+
'console.log/info writes to stdout; use console.error for MCP stdio diagnostics'
|
|
3232
|
+
);
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
if (/\bprocess\.stdout\.write\s*\(/.test(code)) {
|
|
3236
|
+
return staticFinding(
|
|
3237
|
+
'javascript',
|
|
3238
|
+
'javascript-process-stdout-write',
|
|
3239
|
+
'direct process.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
|
|
3240
|
+
);
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
if (/\bprocess\.stdout\.(clearLine|cursorTo|moveCursor)\s*\(/.test(code)) {
|
|
3244
|
+
return staticFinding(
|
|
3245
|
+
'javascript',
|
|
3246
|
+
'javascript-stdout-terminal-control',
|
|
3247
|
+
'process.stdout terminal control writes to stdout; keep MCP stdout reserved for JSON-RPC frames'
|
|
3248
|
+
);
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
if (/\bdotenv\.config\s*\(\s*\{[^}]*\bdebug\s*:\s*true\b/.test(code)) {
|
|
3252
|
+
return staticFinding(
|
|
3253
|
+
'javascript',
|
|
3254
|
+
'javascript-dotenv-debug-output',
|
|
3255
|
+
'dotenv debug output can print during startup; keep MCP diagnostics on stderr'
|
|
3256
|
+
);
|
|
3257
|
+
}
|
|
1365
3258
|
|
|
1366
|
-
|
|
1367
|
-
|
|
3259
|
+
if (/\b(stream|file)\s*:\s*process\.stdout\b/.test(code)) {
|
|
3260
|
+
return staticFinding(
|
|
3261
|
+
'javascript',
|
|
3262
|
+
'javascript-progress-stdout',
|
|
3263
|
+
'progress or spinner output is configured for stdout; MCP stdout must stay JSON-RPC only'
|
|
3264
|
+
);
|
|
3265
|
+
}
|
|
1368
3266
|
}
|
|
1369
3267
|
|
|
1370
|
-
if (ext === '.py'
|
|
1371
|
-
|
|
3268
|
+
if (ext === '.py') {
|
|
3269
|
+
if (/(^|[^\w.])print\s*\(/.test(code) && !/file\s*=\s*sys\.stderr/.test(code)) {
|
|
3270
|
+
return staticFinding(
|
|
3271
|
+
'python',
|
|
3272
|
+
'python-print-stdout',
|
|
3273
|
+
'print() writes to stdout; pass file=sys.stderr for MCP stdio diagnostics'
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
if (/\bsys\.stdout\.write\s*\(/.test(code)) {
|
|
3278
|
+
return staticFinding(
|
|
3279
|
+
'python',
|
|
3280
|
+
'python-sys-stdout-write',
|
|
3281
|
+
'direct sys.stdout.write can pollute MCP stdout unless it only writes JSON-RPC frames; route diagnostics to stderr'
|
|
3282
|
+
);
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
if (/\blogging\.StreamHandler\s*\(\s*sys\.stdout\s*\)/.test(code)
|
|
3286
|
+
|| /\blogging\.basicConfig\s*\([^)]*\bstream\s*=\s*sys\.stdout\b/.test(code)) {
|
|
3287
|
+
return staticFinding(
|
|
3288
|
+
'python',
|
|
3289
|
+
'python-logging-stdout-handler',
|
|
3290
|
+
'Python logging is configured to write to stdout; route MCP diagnostics to stderr'
|
|
3291
|
+
);
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
if (/\btqdm\s*\([^)]*\bfile\s*=\s*sys\.stdout\b/.test(code)) {
|
|
3295
|
+
return staticFinding(
|
|
3296
|
+
'python',
|
|
3297
|
+
'python-progress-stdout',
|
|
3298
|
+
'progress output is configured for stdout; MCP stdout must stay JSON-RPC only'
|
|
3299
|
+
);
|
|
3300
|
+
}
|
|
1372
3301
|
}
|
|
1373
3302
|
|
|
1374
|
-
if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(
|
|
1375
|
-
return
|
|
3303
|
+
if (ext === '.go' && /\bfmt\.(Print|Printf|Println)\s*\(/.test(code)) {
|
|
3304
|
+
return staticFinding(
|
|
3305
|
+
'go',
|
|
3306
|
+
'go-fmt-stdout',
|
|
3307
|
+
'fmt.Print* writes to stdout; use stderr for MCP stdio diagnostics'
|
|
3308
|
+
);
|
|
1376
3309
|
}
|
|
1377
3310
|
|
|
1378
|
-
if (ext === '.rs' && /\bprintln!\s*\(/.test(
|
|
1379
|
-
return
|
|
3311
|
+
if (ext === '.rs' && /\bprintln!\s*\(/.test(code)) {
|
|
3312
|
+
return staticFinding(
|
|
3313
|
+
'rust',
|
|
3314
|
+
'rust-println-stdout',
|
|
3315
|
+
'println! writes to stdout; use eprintln! for MCP stdio diagnostics'
|
|
3316
|
+
);
|
|
1380
3317
|
}
|
|
1381
3318
|
|
|
1382
|
-
if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(
|
|
1383
|
-
return
|
|
3319
|
+
if (['.java', '.kt'].includes(ext) && /System\.out\.print/.test(code)) {
|
|
3320
|
+
return staticFinding(
|
|
3321
|
+
'jvm',
|
|
3322
|
+
'jvm-system-out',
|
|
3323
|
+
'System.out writes to stdout; use stderr for MCP stdio diagnostics'
|
|
3324
|
+
);
|
|
1384
3325
|
}
|
|
1385
3326
|
|
|
1386
3327
|
return '';
|
|
1387
3328
|
}
|
|
1388
3329
|
|
|
3330
|
+
function staticFinding(language, reason, message) {
|
|
3331
|
+
return { language, reason, message };
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
function isJavaScriptSource(ext) {
|
|
3335
|
+
return ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
function normalizeSourceLine(line, ext) {
|
|
3339
|
+
return stripInlineComment(maskStringLiterals(line), ext);
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
function stripInlineComment(line, ext) {
|
|
3343
|
+
if (ext === '.py') {
|
|
3344
|
+
return line.split('#')[0];
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
if (isJavaScriptSource(ext) || ['.go', '.rs', '.java', '.kt'].includes(ext)) {
|
|
3348
|
+
return stripJavaScriptLikeInlineComment(line);
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
return line;
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
function stripJavaScriptLikeInlineComment(line) {
|
|
3355
|
+
let inRegex = false;
|
|
3356
|
+
let escaped = false;
|
|
3357
|
+
let inCharClass = false;
|
|
3358
|
+
|
|
3359
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
3360
|
+
const char = line[index];
|
|
3361
|
+
const next = line[index + 1];
|
|
3362
|
+
|
|
3363
|
+
if (inRegex) {
|
|
3364
|
+
if (escaped) {
|
|
3365
|
+
escaped = false;
|
|
3366
|
+
} else if (char === '\\') {
|
|
3367
|
+
escaped = true;
|
|
3368
|
+
} else if (char === '[') {
|
|
3369
|
+
inCharClass = true;
|
|
3370
|
+
} else if (char === ']') {
|
|
3371
|
+
inCharClass = false;
|
|
3372
|
+
} else if (char === '/' && !inCharClass) {
|
|
3373
|
+
inRegex = false;
|
|
3374
|
+
}
|
|
3375
|
+
continue;
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
if (char === '/' && next === '/') {
|
|
3379
|
+
return line.slice(0, index);
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
if (char === '/' && next === '*') {
|
|
3383
|
+
return line.slice(0, index);
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
if (char === '/' && canStartJavaScriptRegex(line, index)) {
|
|
3387
|
+
inRegex = true;
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
return line;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
function canStartJavaScriptRegex(line, index) {
|
|
3395
|
+
const before = line.slice(0, index).trimEnd();
|
|
3396
|
+
if (!before) return true;
|
|
3397
|
+
|
|
3398
|
+
const last = before[before.length - 1];
|
|
3399
|
+
if ('=(:,[!&|?;{}>'.includes(last)) return true;
|
|
3400
|
+
|
|
3401
|
+
return /\b(await|case|delete|of|return|throw|typeof|void|yield)$/.test(before);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
function maskStringLiterals(line) {
|
|
3405
|
+
let quote = '';
|
|
3406
|
+
let escaped = false;
|
|
3407
|
+
let masked = '';
|
|
3408
|
+
let templateExpressionDepth = 0;
|
|
3409
|
+
|
|
3410
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
3411
|
+
const char = line[index];
|
|
3412
|
+
const next = line[index + 1];
|
|
3413
|
+
|
|
3414
|
+
if (quote) {
|
|
3415
|
+
if (quote === '`' && char === '$' && next === '{') {
|
|
3416
|
+
quote = '';
|
|
3417
|
+
templateExpressionDepth += 1;
|
|
3418
|
+
masked += ' ';
|
|
3419
|
+
index += 1;
|
|
3420
|
+
continue;
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
if (escaped) {
|
|
3424
|
+
escaped = false;
|
|
3425
|
+
} else if (char === '\\') {
|
|
3426
|
+
escaped = true;
|
|
3427
|
+
} else if (char === quote) {
|
|
3428
|
+
quote = '';
|
|
3429
|
+
}
|
|
3430
|
+
masked += ' ';
|
|
3431
|
+
continue;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
if (templateExpressionDepth > 0) {
|
|
3435
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
3436
|
+
quote = char;
|
|
3437
|
+
masked += ' ';
|
|
3438
|
+
continue;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
if (char === '{') {
|
|
3442
|
+
templateExpressionDepth += 1;
|
|
3443
|
+
} else if (char === '}') {
|
|
3444
|
+
templateExpressionDepth -= 1;
|
|
3445
|
+
if (templateExpressionDepth === 0) {
|
|
3446
|
+
quote = '`';
|
|
3447
|
+
masked += ' ';
|
|
3448
|
+
continue;
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
masked += char;
|
|
3453
|
+
continue;
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
3457
|
+
quote = char;
|
|
3458
|
+
masked += ' ';
|
|
3459
|
+
continue;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
masked += char;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
return masked;
|
|
3466
|
+
}
|
|
3467
|
+
|
|
1389
3468
|
function listSourceFiles(root) {
|
|
1390
3469
|
const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', '.next', '.cache']);
|
|
1391
3470
|
const files = [];
|
|
@@ -1431,6 +3510,15 @@ function formatTextResult(result) {
|
|
|
1431
3510
|
lines.push(`request: ${result.operation.method} ${state}`);
|
|
1432
3511
|
}
|
|
1433
3512
|
|
|
3513
|
+
if (result.toolSchema?.checked) {
|
|
3514
|
+
lines.push(`tool schemas: ${result.toolSchema.validToolCount}/${result.toolSchema.toolCount} valid`);
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
if (result.adversarial?.enabled) {
|
|
3518
|
+
const passedProbes = result.adversarial.probes.filter((probe) => probe.status === 'pass').length;
|
|
3519
|
+
lines.push(`adversarial probes: ${passedProbes}/${result.adversarial.probes.length} passed`);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
1434
3522
|
if (result.staticFindings.length) {
|
|
1435
3523
|
lines.push(`static findings: ${result.staticFindings.length}`);
|
|
1436
3524
|
for (const finding of result.staticFindings.slice(0, 10)) {
|
|
@@ -1453,11 +3541,24 @@ function formatRepeatedTextResult(result) {
|
|
|
1453
3541
|
`runs: ${passedRuns}/${result.runs.length} passed`
|
|
1454
3542
|
];
|
|
1455
3543
|
|
|
3544
|
+
if (result.drift?.checked && result.drift.status !== 'skipped') {
|
|
3545
|
+
const issueCodes = result.drift.issueCodes.length ? ` (${result.drift.issueCodes.join(', ')})` : '';
|
|
3546
|
+
lines.push(`drift: ${result.drift.status}${issueCodes}`);
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
if (result.adversarial?.enabled) {
|
|
3550
|
+
const passedProbes = result.adversarial.probes.filter((probe) => probe.status === 'pass').length;
|
|
3551
|
+
lines.push(`adversarial probes: ${passedProbes}/${result.adversarial.probes.length} passed`);
|
|
3552
|
+
}
|
|
3553
|
+
|
|
1456
3554
|
for (const run of result.runs) {
|
|
1457
3555
|
const runStatus = run.ok ? 'PASS' : 'FAIL';
|
|
1458
3556
|
const invalidFrames = run.issues.filter((issue) => issue.code.startsWith('stdout-')).length;
|
|
1459
3557
|
const stderrLines = run.stderr ? run.stderr.trim().split(/\r?\n/).filter(Boolean).length : 0;
|
|
1460
|
-
|
|
3558
|
+
const toolSchemas = run.toolSchema?.checked
|
|
3559
|
+
? `, tool schemas ${run.toolSchema.validToolCount}/${run.toolSchema.toolCount} valid`
|
|
3560
|
+
: '';
|
|
3561
|
+
lines.push(`run ${run.run}: ${runStatus}, initialize ${run.initialized ? 'ok' : 'failed'}, frames ${run.frames.length} stdout / ${invalidFrames} invalid, stderr ${stderrLines} lines${toolSchemas}`);
|
|
1461
3562
|
}
|
|
1462
3563
|
|
|
1463
3564
|
if (result.staticFindings.length) {
|
|
@@ -1505,6 +3606,8 @@ Usage:
|
|
|
1505
3606
|
mcp-stdio-guard [options] -- <command> [args...]
|
|
1506
3607
|
|
|
1507
3608
|
Options:
|
|
3609
|
+
--config <path> read JSON config for registry runs and safe tool calls
|
|
3610
|
+
--profile <name> guard profile: custom, smoke, registry, ci, strict
|
|
1508
3611
|
--protocol <version> MCP protocol version, default ${DEFAULT_PROTOCOL}
|
|
1509
3612
|
--timeout <ms> initialize and request timeout, default ${DEFAULT_TIMEOUT}
|
|
1510
3613
|
--repeat <count> run the guard multiple times, default 1
|
|
@@ -1512,6 +3615,10 @@ Options:
|
|
|
1512
3615
|
--fail-on-static fail when --scan finds risky stdout writes
|
|
1513
3616
|
--request <method> send one MCP request after initialize, e.g. tools/list
|
|
1514
3617
|
--params <json> JSON params for --request
|
|
3618
|
+
--adversarial-probe <name>
|
|
3619
|
+
opt into strict probes: ${[...BUILTIN_ADVERSARIAL_PROBE_NAMES, 'all', 'none'].join(', ')}
|
|
3620
|
+
--adversarial-probes <list>
|
|
3621
|
+
comma-separated form of --adversarial-probe
|
|
1515
3622
|
--json print JSON output
|
|
1516
3623
|
--cwd <path> run command from this directory
|
|
1517
3624
|
--version, -v print version
|