mcp-stdio-guard 0.2.0 → 0.3.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 +105 -1
- package/package.json +1 -1
- package/src/index.js +717 -24
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<a href="https://github.com/1Utkarsh1/mcp-stdio-guard/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/1Utkarsh1/mcp-stdio-guard/actions/workflows/ci.yml/badge.svg" /></a>
|
|
13
13
|
<a href="https://www.npmjs.com/package/mcp-stdio-guard"><img alt="npm" src="https://img.shields.io/npm/v/mcp-stdio-guard?color=0b6bcb" /></a>
|
|
14
|
+
<a href="https://badge.socket.dev/npm/package/mcp-stdio-guard/0.3.0"><img alt="Socket" src="https://badge.socket.dev/npm/package/mcp-stdio-guard/0.3.0" /></a>
|
|
14
15
|
<img alt="runtime dependencies" src="https://img.shields.io/badge/runtime%20deps-0-1f8f4c" />
|
|
15
16
|
<img alt="node" src="https://img.shields.io/badge/node-%3E%3D18-2f855a" />
|
|
16
17
|
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-111827" /></a>
|
|
@@ -145,20 +146,123 @@ mcp-stdio-guard [options] -- <command> [args...]
|
|
|
145
146
|
| `negotiatedProtocol` | protocol version returned by the server, when available |
|
|
146
147
|
| `initialized` | whether the server completed the initialize handshake |
|
|
147
148
|
| `operation` | post-initialize request result, or `null` when `--request` was not used |
|
|
149
|
+
| `process` | startup, timeout, exit code, signal, and guard-termination metadata for a single run; repeat mode exposes this inside each `runs` entry |
|
|
148
150
|
| `checks` | badge-friendly per-class statuses |
|
|
149
|
-
| `
|
|
151
|
+
| `issueClasses` | registry-friendly summary grouped by `installRuntime`, `stdioTransport`, and `mcpProtocol` |
|
|
152
|
+
| `fingerprint` | redacted reproducibility metadata for debugging registry and CI runs |
|
|
153
|
+
| `issues` | machine-readable diagnostics with `class`, `severity`, `code`, and `message`; repeat mode also adds `run` |
|
|
150
154
|
| `staticScan` | whether source scanning was enabled and whether findings fail the command |
|
|
151
155
|
| `staticFindings` | source scan findings with file, line, and message |
|
|
152
156
|
| `runs` | per-run results when `--repeat` is used |
|
|
153
157
|
|
|
154
158
|
Check statuses are `pass`, `fail`, `warning`, or `skipped`. The `checks` object separates the signal into `initialize`, `stdout`, `jsonRpc`, `operation`, `process`, `pythonBuffering`, `staticScan`, and `repeat`, each with stable `status` and `issueCodes` fields. When `--repeat` is used, `checks.repeat` also includes `runs`, `passedRuns`, and `failedRuns`; each entry in `runs` is a normal schema-versioned result for that individual guard run.
|
|
155
159
|
|
|
160
|
+
`issueClasses` is additive to `checks`. It groups issue codes by the kind of problem a registry or client should display:
|
|
161
|
+
|
|
162
|
+
| Issue class | Meaning | Display guidance |
|
|
163
|
+
| --- | --- | --- |
|
|
164
|
+
| `installRuntime` | the command could not start, timed out, exited, crashed, or hit a runtime advisory | show as "needs inspection" or "runtime/install issue"; do not present it as an MCP protocol violation |
|
|
165
|
+
| `stdioTransport` | stdout was not a clean newline-delimited JSON-RPC channel, or source scan found risky stdout writes | show as stdio hygiene failure; ask maintainers to keep diagnostics on stderr |
|
|
166
|
+
| `mcpProtocol` | the server emitted invalid JSON-RPC/MCP responses, mismatched request ids, or returned initialize/operation errors | show as MCP/JSON-RPC conformance issue |
|
|
167
|
+
|
|
168
|
+
Current issue-code mapping:
|
|
169
|
+
|
|
170
|
+
| Issue class | Issue codes |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| `installRuntime` | `initialize-timeout`, `operation-missing-response`, `operation-timeout`, `python-buffered-stdio`, `server-crashed`, `server-exited`, `spawn-failed` |
|
|
173
|
+
| `stdioTransport` | `static-stdout-write`, `stdout-content-length-framing`, `stdout-empty-line`, `stdout-non-json`, `stdout-without-newline` |
|
|
174
|
+
| `mcpProtocol` | `initialize-error`, `initialize-invalid-capabilities`, `initialize-invalid-protocol-version`, `initialize-invalid-result`, `initialize-invalid-server-info`, `initialize-missing-capabilities`, `initialize-missing-protocol-version`, `initialize-missing-server-info`, `notification-response`, `operation-error`, `response-id-mismatch`, `response-id-type-mismatch`, `stdout-invalid-json-rpc`, `stdout-unexpected-request-id` |
|
|
175
|
+
|
|
176
|
+
Initialize lifecycle checks are part of the MCP protocol class. Missing or invalid `protocolVersion` and `capabilities` fail the run before the guard sends `notifications/initialized` or any normal request. Missing or invalid `serverInfo` is warning-level so registries can surface incomplete metadata without confusing it with a broken transport.
|
|
177
|
+
|
|
178
|
+
JSON-RPC invariant checks distinguish wrong response ids from id type round-trip problems and fail servers that respond to `notifications/initialized`. JSON-RPC error frames must be structured with numeric `code` and string `message` fields.
|
|
179
|
+
|
|
180
|
+
Runtime issue codes remain backward-compatible. For finer registry display, runtime issues may also include a stable `detailCode`:
|
|
181
|
+
|
|
182
|
+
| Existing issue code | Detail codes |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| `spawn-failed` | `spawn-failed-before-startup` |
|
|
185
|
+
| `server-exited` | `clean-exit-before-initialize`, `nonzero-exit-before-initialize`, `signal-exit-before-initialize` |
|
|
186
|
+
| `initialize-timeout` | `startup-timeout` |
|
|
187
|
+
| `operation-timeout` | `request-timeout` |
|
|
188
|
+
| `operation-missing-response` | `clean-exit-during-operation`, `nonzero-exit-during-operation`, `signal-exit-during-operation` |
|
|
189
|
+
| `server-crashed` | `nonzero-exit-after-initialize`, `signal-exit-after-initialize` |
|
|
190
|
+
|
|
191
|
+
`process` records the observed lifecycle even when the run passes. `outcome` is one of `starting`, `running`, `exited`, `timeout`, `spawn-failed`, or `guard-terminated`; `starting` is the transient initial value while the child is being created, not an expected terminal outcome. `phase` is `startup`, `initialize`, `operation`, or `post-initialize`. `exitCode` and `signal` are included when the process exits before the guard finishes; timeout runs include `timedOut`, `timeoutCode`, `timeoutMs`, and guard kill metadata. `spawnError` is either `null` or an object with `code` and `message`; the matching `spawn-failed` issue also exposes `spawnErrorCode`.
|
|
192
|
+
|
|
193
|
+
Spawn failure shape:
|
|
194
|
+
|
|
195
|
+
| Field | Shape |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| `process.spawnError` | `null` or `{ "code": "ENOENT", "message": "spawn missing-command ENOENT" }` |
|
|
198
|
+
| `issues[].spawnErrorCode` | short platform error code such as `ENOENT`, or `""` when unavailable |
|
|
199
|
+
|
|
200
|
+
`fingerprint` helps explain why a result reproduced in one runner but not another. It includes the guard version, redacted command argv, cwd details, protocol, timeout, repeat count, requested operation, platform/arch, relevant runtime versions, package metadata when detectable, static-scan context, and startup/total duration. Environment variable values are always emitted as `<redacted>` and only explicitly provided env names are listed.
|
|
201
|
+
|
|
202
|
+
Registry display flow:
|
|
203
|
+
|
|
204
|
+
| Step | Use |
|
|
205
|
+
| --- | --- |
|
|
206
|
+
| 1 | Show `issueClasses` first so install/runtime, stdio transport, and MCP protocol failures stay distinct |
|
|
207
|
+
| 2 | Use `fingerprint.command`, `fingerprint.cwd`, and `fingerprint.package` to show what was actually run |
|
|
208
|
+
| 3 | Compare `fingerprint.system`, `fingerprint.runtimes`, and `fingerprint.timings` before marking a package broken |
|
|
209
|
+
| 4 | Show `fingerprint.env.names` only when debugging; never ask users to paste secret values |
|
|
210
|
+
|
|
156
211
|
Example:
|
|
157
212
|
|
|
158
213
|
```json
|
|
159
214
|
{
|
|
160
215
|
"schemaVersion": 1,
|
|
161
216
|
"ok": true,
|
|
217
|
+
"fingerprint": {
|
|
218
|
+
"guard": { "name": "mcp-stdio-guard", "version": "0.3.0" },
|
|
219
|
+
"command": {
|
|
220
|
+
"executable": "node",
|
|
221
|
+
"args": ["./server.js"],
|
|
222
|
+
"argv": ["node", "./server.js"]
|
|
223
|
+
},
|
|
224
|
+
"cwd": {
|
|
225
|
+
"requested": "/repo/server",
|
|
226
|
+
"resolved": "/repo/server",
|
|
227
|
+
"exists": true
|
|
228
|
+
},
|
|
229
|
+
"protocol": "2025-11-25",
|
|
230
|
+
"timeoutMs": 5000,
|
|
231
|
+
"repeat": 1,
|
|
232
|
+
"operation": { "method": "tools/list", "hasParams": false },
|
|
233
|
+
"system": { "platform": "darwin", "arch": "arm64", "osRelease": "25.0.0" },
|
|
234
|
+
"runtimes": {
|
|
235
|
+
"node": { "version": "v24.0.0", "role": "guard-and-target" }
|
|
236
|
+
},
|
|
237
|
+
"package": null,
|
|
238
|
+
"env": {
|
|
239
|
+
"inherited": true,
|
|
240
|
+
"names": ["API_TOKEN"],
|
|
241
|
+
"values": { "API_TOKEN": "<redacted>" }
|
|
242
|
+
},
|
|
243
|
+
"staticScan": { "enabled": false, "path": "", "failOnFindings": false },
|
|
244
|
+
"timings": { "startupMs": 42, "totalMs": 96 }
|
|
245
|
+
},
|
|
246
|
+
"process": {
|
|
247
|
+
"started": true,
|
|
248
|
+
"pid": 12345,
|
|
249
|
+
"outcome": "guard-terminated",
|
|
250
|
+
"phase": "post-initialize",
|
|
251
|
+
"exitCode": null,
|
|
252
|
+
"signal": null,
|
|
253
|
+
"timedOut": false,
|
|
254
|
+
"timeoutCode": "",
|
|
255
|
+
"timeoutMs": 5000,
|
|
256
|
+
"killedByGuard": true,
|
|
257
|
+
"killSignal": "SIGTERM",
|
|
258
|
+
"killReason": "guard-finished",
|
|
259
|
+
"spawnError": null
|
|
260
|
+
},
|
|
261
|
+
"issueClasses": {
|
|
262
|
+
"installRuntime": { "status": "pass", "issueCodes": [] },
|
|
263
|
+
"stdioTransport": { "status": "pass", "issueCodes": [] },
|
|
264
|
+
"mcpProtocol": { "status": "pass", "issueCodes": [] }
|
|
265
|
+
},
|
|
162
266
|
"checks": {
|
|
163
267
|
"initialize": { "status": "pass", "issueCodes": [] },
|
|
164
268
|
"stdout": { "status": "pass", "issueCodes": [] },
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
5
|
|
|
5
6
|
function loadVersion() {
|
|
6
7
|
try {
|
|
7
8
|
const packageJson = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
8
|
-
return typeof packageJson.version === 'string' ? packageJson.version : '0.
|
|
9
|
+
return typeof packageJson.version === 'string' ? packageJson.version : '0.3.0';
|
|
9
10
|
} catch {
|
|
10
|
-
return '0.
|
|
11
|
+
return '0.3.0';
|
|
11
12
|
}
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -15,6 +16,44 @@ const DEFAULT_PROTOCOL = '2025-11-25';
|
|
|
15
16
|
const DEFAULT_TIMEOUT = 5000;
|
|
16
17
|
const VERSION = loadVersion();
|
|
17
18
|
const JSON_SCHEMA_VERSION = 1;
|
|
19
|
+
const REDACTED = '<redacted>';
|
|
20
|
+
const VERSION_PROBE_CACHE = new Map();
|
|
21
|
+
const NODE_OPTIONS_WITH_VALUES = new Set([
|
|
22
|
+
'--conditions',
|
|
23
|
+
'--cpu-prof-dir',
|
|
24
|
+
'--diagnostic-dir',
|
|
25
|
+
'--experimental-loader',
|
|
26
|
+
'--heapsnapshot-near-heap-limit',
|
|
27
|
+
'--import',
|
|
28
|
+
'--inspect-port',
|
|
29
|
+
'--loader',
|
|
30
|
+
'--max-old-space-size',
|
|
31
|
+
'--openssl-config',
|
|
32
|
+
'--perf-basic-prof-only-functions',
|
|
33
|
+
'--prof-process',
|
|
34
|
+
'--redirect-warnings',
|
|
35
|
+
'--require',
|
|
36
|
+
'--test-reporter',
|
|
37
|
+
'--test-reporter-destination',
|
|
38
|
+
'--title',
|
|
39
|
+
'--trace-event-categories',
|
|
40
|
+
'--trace-event-file-pattern',
|
|
41
|
+
'-C',
|
|
42
|
+
'-r'
|
|
43
|
+
]);
|
|
44
|
+
const NODE_EVAL_OPTIONS = new Set(['--eval', '--print', '-e', '-p']);
|
|
45
|
+
|
|
46
|
+
export const ISSUE_CLASSES = Object.freeze({
|
|
47
|
+
INSTALL_RUNTIME: 'installRuntime',
|
|
48
|
+
STDIO_TRANSPORT: 'stdioTransport',
|
|
49
|
+
MCP_PROTOCOL: 'mcpProtocol'
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const ISSUE_CLASS_NAMES = [
|
|
53
|
+
ISSUE_CLASSES.INSTALL_RUNTIME,
|
|
54
|
+
ISSUE_CLASSES.STDIO_TRANSPORT,
|
|
55
|
+
ISSUE_CLASSES.MCP_PROTOCOL
|
|
56
|
+
];
|
|
18
57
|
|
|
19
58
|
const STDOUT_ISSUE_CODES = new Set([
|
|
20
59
|
'stdout-empty-line',
|
|
@@ -25,12 +64,21 @@ const STDOUT_ISSUE_CODES = new Set([
|
|
|
25
64
|
'stdout-without-newline'
|
|
26
65
|
]);
|
|
27
66
|
const JSON_RPC_ISSUE_CODES = new Set([
|
|
67
|
+
'notification-response',
|
|
68
|
+
'response-id-mismatch',
|
|
28
69
|
'response-id-type-mismatch',
|
|
29
70
|
'stdout-invalid-json-rpc',
|
|
30
71
|
'stdout-unexpected-request-id'
|
|
31
72
|
]);
|
|
32
73
|
const INITIALIZE_ISSUE_CODES = new Set([
|
|
33
74
|
'initialize-error',
|
|
75
|
+
'initialize-invalid-capabilities',
|
|
76
|
+
'initialize-invalid-protocol-version',
|
|
77
|
+
'initialize-invalid-result',
|
|
78
|
+
'initialize-invalid-server-info',
|
|
79
|
+
'initialize-missing-capabilities',
|
|
80
|
+
'initialize-missing-protocol-version',
|
|
81
|
+
'initialize-missing-server-info',
|
|
34
82
|
'initialize-timeout',
|
|
35
83
|
'server-exited',
|
|
36
84
|
'spawn-failed'
|
|
@@ -45,6 +93,36 @@ const PROCESS_ISSUE_CODES = new Set([
|
|
|
45
93
|
'server-exited',
|
|
46
94
|
'spawn-failed'
|
|
47
95
|
]);
|
|
96
|
+
const ISSUE_CLASS_BY_CODE = new Map([
|
|
97
|
+
['initialize-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
98
|
+
['operation-missing-response', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
99
|
+
['operation-timeout', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
100
|
+
['python-buffered-stdio', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
101
|
+
['server-crashed', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
102
|
+
['server-exited', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
103
|
+
['spawn-failed', ISSUE_CLASSES.INSTALL_RUNTIME],
|
|
104
|
+
|
|
105
|
+
['static-stdout-write', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
106
|
+
['stdout-content-length-framing', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
107
|
+
['stdout-empty-line', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
108
|
+
['stdout-non-json', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
109
|
+
['stdout-without-newline', ISSUE_CLASSES.STDIO_TRANSPORT],
|
|
110
|
+
|
|
111
|
+
['initialize-error', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
112
|
+
['initialize-invalid-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
113
|
+
['initialize-invalid-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
114
|
+
['initialize-invalid-result', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
115
|
+
['initialize-invalid-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
116
|
+
['initialize-missing-capabilities', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
117
|
+
['initialize-missing-protocol-version', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
118
|
+
['initialize-missing-server-info', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
119
|
+
['operation-error', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
120
|
+
['notification-response', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
121
|
+
['response-id-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
122
|
+
['response-id-type-mismatch', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
123
|
+
['stdout-invalid-json-rpc', ISSUE_CLASSES.MCP_PROTOCOL],
|
|
124
|
+
['stdout-unexpected-request-id', ISSUE_CLASSES.MCP_PROTOCOL]
|
|
125
|
+
]);
|
|
48
126
|
|
|
49
127
|
export async function runCli(argv) {
|
|
50
128
|
const options = parseArgs(argv);
|
|
@@ -85,6 +163,9 @@ export async function runCli(argv) {
|
|
|
85
163
|
path: options.scanPath,
|
|
86
164
|
failOnFindings: options.failOnStatic
|
|
87
165
|
};
|
|
166
|
+
if (result.fingerprint) {
|
|
167
|
+
result.fingerprint.staticScan = result.staticScan;
|
|
168
|
+
}
|
|
88
169
|
result.staticFindings = scanSource(options.scanPath);
|
|
89
170
|
if (options.failOnStatic) {
|
|
90
171
|
for (const finding of result.staticFindings) {
|
|
@@ -193,8 +274,10 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
|
193
274
|
throw new Error('repeat must be an integer >= 1');
|
|
194
275
|
}
|
|
195
276
|
|
|
277
|
+
const singleRunOptions = { ...options, repeat: 1 };
|
|
278
|
+
|
|
196
279
|
for (let index = 1; index <= repeat; index += 1) {
|
|
197
|
-
const run = await guardStdioServer(commandWithArgs,
|
|
280
|
+
const run = await guardStdioServer(commandWithArgs, singleRunOptions);
|
|
198
281
|
run.run = index;
|
|
199
282
|
runs.push(run);
|
|
200
283
|
for (const issue of run.issues) {
|
|
@@ -202,7 +285,8 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
|
202
285
|
}
|
|
203
286
|
}
|
|
204
287
|
|
|
205
|
-
|
|
288
|
+
const durationMs = Date.now() - startedAt;
|
|
289
|
+
const result = {
|
|
206
290
|
schemaVersion: JSON_SCHEMA_VERSION,
|
|
207
291
|
ok: !issues.some((issue) => issue.severity === 'error'),
|
|
208
292
|
command: commandWithArgs,
|
|
@@ -213,8 +297,11 @@ export async function guardRepeatedStdioServer(commandWithArgs, options = {}) {
|
|
|
213
297
|
checks: {},
|
|
214
298
|
staticScan: defaultStaticScan(),
|
|
215
299
|
staticFindings: [],
|
|
216
|
-
durationMs
|
|
217
|
-
|
|
300
|
+
durationMs,
|
|
301
|
+
fingerprint: createFingerprint(commandWithArgs, options)
|
|
302
|
+
};
|
|
303
|
+
result.fingerprint.timings.totalMs = durationMs;
|
|
304
|
+
return finalizeResult(result);
|
|
218
305
|
}
|
|
219
306
|
|
|
220
307
|
export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
@@ -231,6 +318,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
231
318
|
let stdoutBuffer = '';
|
|
232
319
|
let initialized = false;
|
|
233
320
|
let endedByGuard = false;
|
|
321
|
+
let initializeResponseAt = 0;
|
|
234
322
|
let timer;
|
|
235
323
|
let child;
|
|
236
324
|
|
|
@@ -252,20 +340,33 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
252
340
|
issues,
|
|
253
341
|
checks: {},
|
|
254
342
|
stderr: '',
|
|
343
|
+
process: defaultProcessInfo(timeoutMs),
|
|
255
344
|
staticScan: defaultStaticScan(),
|
|
256
345
|
staticFindings: [],
|
|
257
|
-
durationMs: 0
|
|
346
|
+
durationMs: 0,
|
|
347
|
+
fingerprint: createFingerprint(commandWithArgs, {
|
|
348
|
+
protocol,
|
|
349
|
+
timeoutMs,
|
|
350
|
+
cwd: options.cwd,
|
|
351
|
+
operation,
|
|
352
|
+
env: options.env
|
|
353
|
+
})
|
|
258
354
|
};
|
|
259
355
|
|
|
260
356
|
return new Promise((resolve) => {
|
|
261
|
-
function addIssue(severity, code, message) {
|
|
262
|
-
issues.push({ severity, code, message });
|
|
357
|
+
function addIssue(severity, code, message, details = {}) {
|
|
358
|
+
issues.push({ ...details, severity, code, message });
|
|
263
359
|
}
|
|
264
360
|
|
|
265
|
-
function armTimeout(code, message) {
|
|
361
|
+
function armTimeout(code, message, timeoutPhase) {
|
|
266
362
|
clearTimeout(timer);
|
|
363
|
+
result.process.phase = timeoutPhase;
|
|
267
364
|
timer = setTimeout(() => {
|
|
268
|
-
|
|
365
|
+
result.process.timedOut = true;
|
|
366
|
+
result.process.timeoutCode = code;
|
|
367
|
+
result.process.timeoutMs = timeoutMs;
|
|
368
|
+
result.process.outcome = 'timeout';
|
|
369
|
+
addIssue('error', code, message, timeoutIssueDetails(code, timeoutMs, timeoutPhase));
|
|
269
370
|
finish();
|
|
270
371
|
}, timeoutMs);
|
|
271
372
|
}
|
|
@@ -281,8 +382,19 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
281
382
|
result.durationMs = Date.now() - startedAt;
|
|
282
383
|
result.stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
283
384
|
result.initialized = initialized;
|
|
385
|
+
result.fingerprint.timings.startupMs = initializeResponseAt ? initializeResponseAt - startedAt : null;
|
|
386
|
+
result.fingerprint.timings.totalMs = result.durationMs;
|
|
387
|
+
const willTerminate = child && result.process.started && !child.killed && child.exitCode === null;
|
|
388
|
+
if (willTerminate) {
|
|
389
|
+
result.process.killedByGuard = true;
|
|
390
|
+
result.process.killSignal = 'SIGTERM';
|
|
391
|
+
result.process.killReason = result.process.timedOut ? 'timeout' : 'guard-finished';
|
|
392
|
+
if (!result.process.outcome || result.process.outcome === 'running') {
|
|
393
|
+
result.process.outcome = 'guard-terminated';
|
|
394
|
+
}
|
|
395
|
+
}
|
|
284
396
|
finalizeResult(result);
|
|
285
|
-
if (
|
|
397
|
+
if (willTerminate) {
|
|
286
398
|
endedByGuard = true;
|
|
287
399
|
child.kill('SIGTERM');
|
|
288
400
|
}
|
|
@@ -290,6 +402,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
290
402
|
}
|
|
291
403
|
|
|
292
404
|
function send(message) {
|
|
405
|
+
if (!child?.stdin?.writable) return;
|
|
293
406
|
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
294
407
|
}
|
|
295
408
|
|
|
@@ -303,15 +416,34 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
303
416
|
env,
|
|
304
417
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
305
418
|
});
|
|
419
|
+
result.process.pid = child.pid ?? null;
|
|
306
420
|
|
|
307
|
-
armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms
|
|
421
|
+
armTimeout('initialize-timeout', `no initialize response within ${timeoutMs}ms`, 'initialize');
|
|
422
|
+
|
|
423
|
+
child.on('spawn', () => {
|
|
424
|
+
result.process.started = true;
|
|
425
|
+
result.process.pid = child.pid ?? null;
|
|
426
|
+
result.process.outcome = 'running';
|
|
427
|
+
});
|
|
308
428
|
|
|
309
429
|
child.on('error', (error) => {
|
|
310
430
|
clearTimeout(timer);
|
|
311
|
-
|
|
431
|
+
result.process.phase = 'startup';
|
|
432
|
+
result.process.outcome = 'spawn-failed';
|
|
433
|
+
result.process.spawnError = {
|
|
434
|
+
code: error.code || '',
|
|
435
|
+
message: error.message
|
|
436
|
+
};
|
|
437
|
+
addIssue('error', 'spawn-failed', error.message, {
|
|
438
|
+
detailCode: 'spawn-failed-before-startup',
|
|
439
|
+
phase: 'startup',
|
|
440
|
+
spawnErrorCode: error.code || ''
|
|
441
|
+
});
|
|
312
442
|
finish();
|
|
313
443
|
});
|
|
314
444
|
|
|
445
|
+
child.stdin.on('error', () => {});
|
|
446
|
+
|
|
315
447
|
child.stdout.on('data', (chunk) => {
|
|
316
448
|
stdoutBuffer += chunk.toString('utf8');
|
|
317
449
|
const lines = stdoutBuffer.split(/\r?\n/);
|
|
@@ -329,17 +461,26 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
329
461
|
child.on('exit', (code, signal) => {
|
|
330
462
|
if (result.durationMs) return;
|
|
331
463
|
clearTimeout(timer);
|
|
464
|
+
const exitPhase = initialized
|
|
465
|
+
? result.operation && !result.operation.responded
|
|
466
|
+
? 'operation'
|
|
467
|
+
: 'post-initialize'
|
|
468
|
+
: 'initialize';
|
|
469
|
+
result.process.phase = exitPhase;
|
|
470
|
+
result.process.outcome = 'exited';
|
|
471
|
+
result.process.exitCode = code;
|
|
472
|
+
result.process.signal = signal;
|
|
332
473
|
if (stdoutBuffer.trim()) {
|
|
333
474
|
addIssue('error', 'stdout-without-newline', `stdout ended with an incomplete JSON-RPC frame: ${quote(stdoutBuffer)}`);
|
|
334
475
|
}
|
|
335
476
|
if (!endedByGuard && initialized && result.operation && !result.operation.responded) {
|
|
336
|
-
addIssue('error', 'operation-missing-response', `${result.operation.method} did not receive a response before server exit
|
|
477
|
+
addIssue('error', 'operation-missing-response', `${result.operation.method} did not receive a response before server exit`, exitIssueDetails('during-operation', code, signal));
|
|
337
478
|
}
|
|
338
|
-
if (!endedByGuard && initialized && code
|
|
339
|
-
addIssue('error', 'server-crashed', `server exited after initialize (code ${code}, signal ${signal ?? 'null'})
|
|
479
|
+
if (!endedByGuard && initialized && isAbnormalExit(code, signal)) {
|
|
480
|
+
addIssue('error', 'server-crashed', `server exited after initialize (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('after-initialize', code, signal));
|
|
340
481
|
}
|
|
341
482
|
if (!initialized && !endedByGuard && !issues.some((issue) => issue.code === 'spawn-failed')) {
|
|
342
|
-
addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})
|
|
483
|
+
addIssue('error', 'server-exited', `server exited before initialize completed (code ${code ?? 'null'}, signal ${signal ?? 'null'})`, exitIssueDetails('before-initialize', code, signal));
|
|
343
484
|
}
|
|
344
485
|
finish();
|
|
345
486
|
});
|
|
@@ -386,15 +527,23 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
386
527
|
|
|
387
528
|
frames.push(message);
|
|
388
529
|
|
|
389
|
-
if (isResponseIdTypeMismatch(message, 1)) {
|
|
530
|
+
if (!initialized && isResponseIdTypeMismatch(message, 1)) {
|
|
390
531
|
clearTimeout(timer);
|
|
391
532
|
addIssue('error', 'response-id-type-mismatch', `initialize response id ${JSON.stringify(message.id)} does not exactly match request id 1`);
|
|
392
533
|
finish();
|
|
393
534
|
return;
|
|
394
535
|
}
|
|
395
536
|
|
|
396
|
-
if (message
|
|
537
|
+
if (!initialized && isResponseIdMismatch(message, 1)) {
|
|
397
538
|
clearTimeout(timer);
|
|
539
|
+
addIssue('error', 'response-id-mismatch', `initialize response id ${JSON.stringify(message.id)} does not match request id 1`);
|
|
540
|
+
finish();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!initialized && message.id === 1) {
|
|
545
|
+
clearTimeout(timer);
|
|
546
|
+
initializeResponseAt = Date.now();
|
|
398
547
|
if (!isJsonRpcResponse(message)) {
|
|
399
548
|
addIssue('error', 'stdout-unexpected-request-id', 'stdout frame with id 1 is not an initialize response');
|
|
400
549
|
finish();
|
|
@@ -407,7 +556,17 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
407
556
|
return;
|
|
408
557
|
}
|
|
409
558
|
|
|
559
|
+
const initializeIssues = validateInitializeResult(message.result);
|
|
560
|
+
for (const issue of initializeIssues) {
|
|
561
|
+
addIssue(issue.severity, issue.code, issue.message);
|
|
562
|
+
}
|
|
563
|
+
if (initializeIssues.some((issue) => issue.severity === 'error')) {
|
|
564
|
+
finish();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
410
568
|
initialized = true;
|
|
569
|
+
result.process.phase = operation ? 'operation' : 'post-initialize';
|
|
411
570
|
result.negotiatedProtocol = message.result?.protocolVersion || '';
|
|
412
571
|
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
413
572
|
if (operation) {
|
|
@@ -420,15 +579,23 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
420
579
|
request.params = operation.params;
|
|
421
580
|
}
|
|
422
581
|
send(request);
|
|
423
|
-
armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms
|
|
582
|
+
armTimeout('operation-timeout', `no ${operation.method} response within ${timeoutMs}ms`, 'operation');
|
|
424
583
|
} else {
|
|
425
584
|
finishSoon();
|
|
426
585
|
}
|
|
427
|
-
} else if (operation &&
|
|
586
|
+
} else if (initialized && !operation && isJsonRpcResponse(message)) {
|
|
587
|
+
clearTimeout(timer);
|
|
588
|
+
addIssue('error', 'notification-response', 'server sent a JSON-RPC response after notifications/initialized without an outstanding request');
|
|
589
|
+
finish();
|
|
590
|
+
} else if (initialized && operation && isResponseIdTypeMismatch(message, 2)) {
|
|
428
591
|
clearTimeout(timer);
|
|
429
592
|
addIssue('error', 'response-id-type-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not exactly match request id 2`);
|
|
430
593
|
finish();
|
|
431
|
-
} else if (operation && message
|
|
594
|
+
} else if (initialized && operation && isResponseIdMismatch(message, 2)) {
|
|
595
|
+
clearTimeout(timer);
|
|
596
|
+
addIssue('error', 'response-id-mismatch', `${operation.method} response id ${JSON.stringify(message.id)} does not match request id 2`);
|
|
597
|
+
finish();
|
|
598
|
+
} else if (initialized && operation && message.id === 2) {
|
|
432
599
|
clearTimeout(timer);
|
|
433
600
|
if (!isJsonRpcResponse(message)) {
|
|
434
601
|
addIssue('error', 'stdout-unexpected-request-id', `stdout frame with id 2 is not a ${operation.method} response`);
|
|
@@ -437,6 +604,7 @@ export async function guardStdioServer(commandWithArgs, options = {}) {
|
|
|
437
604
|
}
|
|
438
605
|
|
|
439
606
|
result.operation.responded = true;
|
|
607
|
+
result.process.phase = 'post-initialize';
|
|
440
608
|
if (message.error) {
|
|
441
609
|
result.operation.error = message.error;
|
|
442
610
|
addIssue('warning', 'operation-error', `${operation.method} returned error: ${message.error.message || JSON.stringify(message.error)}`);
|
|
@@ -467,10 +635,63 @@ export function detectPythonBufferingIssue(commandWithArgs, env = process.env) {
|
|
|
467
635
|
return 'Python stdout is buffered when piped; use python -u or PYTHONUNBUFFERED=1 for MCP stdio servers';
|
|
468
636
|
}
|
|
469
637
|
|
|
638
|
+
function defaultProcessInfo(timeoutMs) {
|
|
639
|
+
return {
|
|
640
|
+
started: false,
|
|
641
|
+
pid: null,
|
|
642
|
+
outcome: 'starting',
|
|
643
|
+
phase: 'initialize',
|
|
644
|
+
exitCode: null,
|
|
645
|
+
signal: null,
|
|
646
|
+
timedOut: false,
|
|
647
|
+
timeoutCode: '',
|
|
648
|
+
timeoutMs,
|
|
649
|
+
killedByGuard: false,
|
|
650
|
+
killSignal: '',
|
|
651
|
+
killReason: '',
|
|
652
|
+
spawnError: null
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function timeoutIssueDetails(code, timeoutMs, phase) {
|
|
657
|
+
return {
|
|
658
|
+
detailCode: code === 'operation-timeout' ? 'request-timeout' : 'startup-timeout',
|
|
659
|
+
phase,
|
|
660
|
+
timeoutMs
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function exitIssueDetails(position, code, signal) {
|
|
665
|
+
return {
|
|
666
|
+
detailCode: exitDetailCode(position, code, signal),
|
|
667
|
+
phase: position === 'during-operation'
|
|
668
|
+
? 'operation'
|
|
669
|
+
: position === 'before-initialize'
|
|
670
|
+
? 'initialize'
|
|
671
|
+
: 'post-initialize',
|
|
672
|
+
exitCode: code,
|
|
673
|
+
signal
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function exitDetailCode(position, code, signal) {
|
|
678
|
+
if (signal) return `signal-exit-${position}`;
|
|
679
|
+
if (code === 0) return `clean-exit-${position}`;
|
|
680
|
+
return `nonzero-exit-${position}`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function isAbnormalExit(code, signal) {
|
|
684
|
+
return signal !== null || (code !== null && code !== 0);
|
|
685
|
+
}
|
|
686
|
+
|
|
470
687
|
function isResponseIdTypeMismatch(message, expectedId) {
|
|
471
688
|
return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) === String(expectedId);
|
|
472
689
|
}
|
|
473
690
|
|
|
691
|
+
function isResponseIdMismatch(message, expectedId) {
|
|
692
|
+
return hasResponsePayload(message) && Object.hasOwn(message, 'id') && message.id !== expectedId && String(message.id) !== String(expectedId);
|
|
693
|
+
}
|
|
694
|
+
|
|
474
695
|
function isJsonRpcResponse(message) {
|
|
475
696
|
return hasResponsePayload(message) && !Object.hasOwn(message, 'method');
|
|
476
697
|
}
|
|
@@ -493,10 +714,38 @@ export function validateJsonRpc(message) {
|
|
|
493
714
|
const hasResult = Object.hasOwn(message, 'result');
|
|
494
715
|
const hasError = Object.hasOwn(message, 'error');
|
|
495
716
|
|
|
717
|
+
if (hasId && !isJsonRpcId(message.id)) {
|
|
718
|
+
return 'JSON-RPC id must be a string, integer number, or null';
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (hasMethod && hasId && message.id === null) {
|
|
722
|
+
return 'JSON-RPC request id must not be null';
|
|
723
|
+
}
|
|
724
|
+
|
|
496
725
|
if (hasMethod && (hasResult || hasError)) {
|
|
497
726
|
return 'request/notification frame must not include result or error';
|
|
498
727
|
}
|
|
499
728
|
|
|
729
|
+
if (!hasMethod && hasResult && hasError) {
|
|
730
|
+
return 'response frame must not include both result and error';
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (!hasMethod && !hasId && (hasResult || hasError)) {
|
|
734
|
+
return 'response frame must include id';
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (!hasMethod && hasError) {
|
|
738
|
+
if (!message.error || typeof message.error !== 'object' || Array.isArray(message.error)) {
|
|
739
|
+
return 'JSON-RPC error must be an object';
|
|
740
|
+
}
|
|
741
|
+
if (!Number.isInteger(message.error.code)) {
|
|
742
|
+
return 'JSON-RPC error code must be an integer';
|
|
743
|
+
}
|
|
744
|
+
if (typeof message.error.message !== 'string') {
|
|
745
|
+
return 'JSON-RPC error message must be a string';
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
500
749
|
if (hasId && !hasMethod && !hasResult && !hasError) {
|
|
501
750
|
return 'response frame must include result or error';
|
|
502
751
|
}
|
|
@@ -508,15 +757,452 @@ export function validateJsonRpc(message) {
|
|
|
508
757
|
return '';
|
|
509
758
|
}
|
|
510
759
|
|
|
760
|
+
function isJsonRpcId(id) {
|
|
761
|
+
return id === null || typeof id === 'string' || (typeof id === 'number' && Number.isInteger(id));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function validateInitializeResult(result) {
|
|
765
|
+
const issues = [];
|
|
766
|
+
|
|
767
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
768
|
+
return [{
|
|
769
|
+
severity: 'error',
|
|
770
|
+
code: 'initialize-invalid-result',
|
|
771
|
+
message: 'initialize result must be an object with protocolVersion and capabilities'
|
|
772
|
+
}];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (!Object.hasOwn(result, 'protocolVersion')) {
|
|
776
|
+
issues.push({
|
|
777
|
+
severity: 'error',
|
|
778
|
+
code: 'initialize-missing-protocol-version',
|
|
779
|
+
message: 'initialize result is missing protocolVersion'
|
|
780
|
+
});
|
|
781
|
+
} else if (typeof result.protocolVersion !== 'string' || !result.protocolVersion) {
|
|
782
|
+
issues.push({
|
|
783
|
+
severity: 'error',
|
|
784
|
+
code: 'initialize-invalid-protocol-version',
|
|
785
|
+
message: 'initialize result protocolVersion must be a non-empty string'
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
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
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (!Object.hasOwn(result, 'serverInfo')) {
|
|
804
|
+
issues.push({
|
|
805
|
+
severity: 'warning',
|
|
806
|
+
code: 'initialize-missing-server-info',
|
|
807
|
+
message: 'initialize result is missing serverInfo'
|
|
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
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
return issues;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function classifyIssueCode(code) {
|
|
821
|
+
return ISSUE_CLASS_BY_CODE.get(code) ?? ISSUE_CLASSES.MCP_PROTOCOL;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export function createFingerprint(commandWithArgs, options = {}) {
|
|
825
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
826
|
+
const operation = options.operation || null;
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
guard: {
|
|
830
|
+
name: 'mcp-stdio-guard',
|
|
831
|
+
version: VERSION
|
|
832
|
+
},
|
|
833
|
+
command: {
|
|
834
|
+
executable: commandWithArgs[0] || '',
|
|
835
|
+
args: redactArgv(commandWithArgs.slice(1)),
|
|
836
|
+
argv: redactArgv(commandWithArgs)
|
|
837
|
+
},
|
|
838
|
+
cwd: {
|
|
839
|
+
requested: String(options.cwd ?? process.cwd()),
|
|
840
|
+
resolved: cwd,
|
|
841
|
+
exists: fs.existsSync(cwd)
|
|
842
|
+
},
|
|
843
|
+
protocol: options.protocol ?? DEFAULT_PROTOCOL,
|
|
844
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT,
|
|
845
|
+
repeat: options.repeat ?? 1,
|
|
846
|
+
operation: operation
|
|
847
|
+
? {
|
|
848
|
+
method: operation.method,
|
|
849
|
+
hasParams: operation.params !== undefined
|
|
850
|
+
}
|
|
851
|
+
: null,
|
|
852
|
+
system: {
|
|
853
|
+
platform: process.platform,
|
|
854
|
+
arch: process.arch,
|
|
855
|
+
osRelease: os.release()
|
|
856
|
+
},
|
|
857
|
+
runtimes: detectRuntimeVersions(commandWithArgs),
|
|
858
|
+
package: detectPackageMetadata(commandWithArgs, cwd),
|
|
859
|
+
env: redactEnvMetadata(options.env ?? {}),
|
|
860
|
+
staticScan: defaultStaticScan(),
|
|
861
|
+
timings: {
|
|
862
|
+
startupMs: null,
|
|
863
|
+
totalMs: null
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function redactEnvMetadata(env) {
|
|
869
|
+
const names = Object.keys(env).sort();
|
|
870
|
+
return {
|
|
871
|
+
inherited: true,
|
|
872
|
+
names,
|
|
873
|
+
values: Object.fromEntries(names.map((name) => [name, REDACTED]))
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function redactArgv(argv) {
|
|
878
|
+
const redacted = [];
|
|
879
|
+
let redactNext = false;
|
|
880
|
+
|
|
881
|
+
for (const arg of argv) {
|
|
882
|
+
if (redactNext) {
|
|
883
|
+
redacted.push(REDACTED);
|
|
884
|
+
redactNext = false;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const secretAssignment = redactSecretAssignment(arg);
|
|
889
|
+
if (secretAssignment) {
|
|
890
|
+
redacted.push(secretAssignment);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
redacted.push(arg);
|
|
895
|
+
if (isSecretFlag(arg)) {
|
|
896
|
+
redactNext = true;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
return redacted;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function redactSecretAssignment(arg) {
|
|
904
|
+
const match = /^([^=\s]+)=(.*)$/.exec(arg);
|
|
905
|
+
if (!match) return '';
|
|
906
|
+
|
|
907
|
+
const [, name] = match;
|
|
908
|
+
return isSecretName(name) ? `${name}=${REDACTED}` : '';
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function isSecretFlag(arg) {
|
|
912
|
+
if (!arg.startsWith('-')) return false;
|
|
913
|
+
const name = arg.replace(/^-+/, '').split(/[=\s]/)[0];
|
|
914
|
+
return isSecretName(name);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function isSecretName(name) {
|
|
918
|
+
return /(^|[-_])(api[-_]?key|auth|bearer|cookie|credential|password|passwd|private[-_]?key|pwd|secret|session|token)([-_]|$)/i.test(name);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function detectRuntimeVersions(commandWithArgs) {
|
|
922
|
+
const command = commandWithArgs[0] || '';
|
|
923
|
+
const base = path.basename(command).toLowerCase();
|
|
924
|
+
const runtimes = {
|
|
925
|
+
node: {
|
|
926
|
+
version: process.version,
|
|
927
|
+
role: isNodeCommand(command) ? 'guard-and-target' : 'guard'
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
if (isPythonCommand(command)) {
|
|
932
|
+
runtimes.python = executableVersion(command, ['--version']);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (isNpmCommand(base) || isNpxCommand(base)) {
|
|
936
|
+
runtimes.npm = executableVersion('npm', ['--version']);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (base === 'uv' || base === 'uvx') {
|
|
940
|
+
runtimes.uv = executableVersion(base === 'uvx' ? 'uv' : command, ['--version']);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (base === 'docker') {
|
|
944
|
+
runtimes.docker = executableVersion(command, ['--version']);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return runtimes;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function executableVersion(command, args) {
|
|
951
|
+
const cacheKey = JSON.stringify([command, args]);
|
|
952
|
+
if (VERSION_PROBE_CACHE.has(cacheKey)) {
|
|
953
|
+
return { ...VERSION_PROBE_CACHE.get(cacheKey) };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const result = spawnSync(command, args, {
|
|
957
|
+
encoding: 'utf8',
|
|
958
|
+
timeout: 1000,
|
|
959
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
960
|
+
});
|
|
961
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
962
|
+
const version = {
|
|
963
|
+
command,
|
|
964
|
+
version: output.split(/\r?\n/)[0] || '',
|
|
965
|
+
available: !result.error && result.status === 0 && result.signal === null,
|
|
966
|
+
status: result.status,
|
|
967
|
+
signal: result.signal
|
|
968
|
+
};
|
|
969
|
+
VERSION_PROBE_CACHE.set(cacheKey, version);
|
|
970
|
+
return { ...version };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function detectPackageMetadata(commandWithArgs, cwd) {
|
|
974
|
+
const command = commandWithArgs[0] || '';
|
|
975
|
+
const args = commandWithArgs.slice(1);
|
|
976
|
+
const base = path.basename(command).toLowerCase();
|
|
977
|
+
|
|
978
|
+
if (isNpxCommand(base)) {
|
|
979
|
+
return packageFromSpec('npm', firstPackageSpec(args));
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (isNpmCommand(base)) {
|
|
983
|
+
const subcommand = args[0] || '';
|
|
984
|
+
if (subcommand === 'exec' || subcommand === 'x') {
|
|
985
|
+
return packageFromSpec('npm', firstPackageSpec(args.slice(1)));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (base === 'uvx') {
|
|
990
|
+
return packageFromSpec('uv', firstPackageSpec(args));
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (base === 'docker') {
|
|
994
|
+
const image = dockerImageSpec(args);
|
|
995
|
+
return image ? { manager: 'docker', name: image, versionSpec: '' } : null;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (isNodeCommand(command)) {
|
|
999
|
+
const entrypoint = nodeEntrypointArg(args);
|
|
1000
|
+
return entrypoint ? localPackageMetadata(path.resolve(cwd, entrypoint)) : null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function firstPackageSpec(args) {
|
|
1007
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1008
|
+
const arg = args[index];
|
|
1009
|
+
if (arg === '--') continue;
|
|
1010
|
+
|
|
1011
|
+
if (arg.startsWith('--package=')) {
|
|
1012
|
+
return arg.slice('--package='.length);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (arg === '--package' || arg === '-p') {
|
|
1016
|
+
return args[index + 1] || '';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (arg.startsWith('-')) {
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return arg;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return '';
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function nodeEntrypointArg(args) {
|
|
1030
|
+
let afterSeparator = false;
|
|
1031
|
+
|
|
1032
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1033
|
+
const arg = args[index];
|
|
1034
|
+
|
|
1035
|
+
if (!afterSeparator && arg === '--') {
|
|
1036
|
+
afterSeparator = true;
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (!afterSeparator && arg.startsWith('-')) {
|
|
1041
|
+
const optionName = arg.split('=')[0];
|
|
1042
|
+
if (NODE_EVAL_OPTIONS.has(optionName)) {
|
|
1043
|
+
return '';
|
|
1044
|
+
}
|
|
1045
|
+
if (NODE_OPTIONS_WITH_VALUES.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
|
|
1046
|
+
index += 1;
|
|
1047
|
+
}
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return arg;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return '';
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function packageFromSpec(manager, spec) {
|
|
1058
|
+
if (!spec) return null;
|
|
1059
|
+
const parsed = parsePackageSpec(spec);
|
|
1060
|
+
return {
|
|
1061
|
+
manager,
|
|
1062
|
+
name: parsed.name,
|
|
1063
|
+
versionSpec: parsed.versionSpec
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function parsePackageSpec(spec) {
|
|
1068
|
+
if (spec.startsWith('@')) {
|
|
1069
|
+
const versionAt = spec.indexOf('@', 1);
|
|
1070
|
+
if (versionAt > -1) {
|
|
1071
|
+
return {
|
|
1072
|
+
name: spec.slice(0, versionAt),
|
|
1073
|
+
versionSpec: spec.slice(versionAt + 1)
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
return { name: spec, versionSpec: '' };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const versionAt = spec.lastIndexOf('@');
|
|
1080
|
+
if (versionAt > 0) {
|
|
1081
|
+
return {
|
|
1082
|
+
name: spec.slice(0, versionAt),
|
|
1083
|
+
versionSpec: spec.slice(versionAt + 1)
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
return { name: spec, versionSpec: '' };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function dockerImageSpec(args) {
|
|
1091
|
+
const runIndex = args.indexOf('run');
|
|
1092
|
+
if (runIndex === -1) return '';
|
|
1093
|
+
const optionsWithValues = new Set([
|
|
1094
|
+
'-e',
|
|
1095
|
+
'--env',
|
|
1096
|
+
'-h',
|
|
1097
|
+
'--hostname',
|
|
1098
|
+
'-p',
|
|
1099
|
+
'--publish',
|
|
1100
|
+
'-u',
|
|
1101
|
+
'--user',
|
|
1102
|
+
'-v',
|
|
1103
|
+
'--volume',
|
|
1104
|
+
'-w',
|
|
1105
|
+
'--workdir',
|
|
1106
|
+
'--entrypoint',
|
|
1107
|
+
'--name',
|
|
1108
|
+
'--network',
|
|
1109
|
+
'--platform'
|
|
1110
|
+
]);
|
|
1111
|
+
|
|
1112
|
+
for (let index = runIndex + 1; index < args.length; index += 1) {
|
|
1113
|
+
const arg = args[index];
|
|
1114
|
+
if (arg === '--') continue;
|
|
1115
|
+
if (arg.startsWith('-')) {
|
|
1116
|
+
const optionName = arg.split('=')[0];
|
|
1117
|
+
if (optionsWithValues.has(optionName) && !arg.includes('=') && index + 1 < args.length) {
|
|
1118
|
+
index += 1;
|
|
1119
|
+
}
|
|
1120
|
+
continue;
|
|
1121
|
+
}
|
|
1122
|
+
return arg;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return '';
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function localPackageMetadata(entry) {
|
|
1129
|
+
const packageJson = findNearestPackageJson(fs.existsSync(entry) && fs.statSync(entry).isDirectory() ? entry : path.dirname(entry));
|
|
1130
|
+
if (!packageJson) return null;
|
|
1131
|
+
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
|
|
1134
|
+
return {
|
|
1135
|
+
manager: 'local',
|
|
1136
|
+
name: typeof parsed.name === 'string' ? parsed.name : '',
|
|
1137
|
+
versionSpec: typeof parsed.version === 'string' ? parsed.version : ''
|
|
1138
|
+
};
|
|
1139
|
+
} catch {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function findNearestPackageJson(start) {
|
|
1145
|
+
let dir = path.resolve(start);
|
|
1146
|
+
while (dir !== path.dirname(dir)) {
|
|
1147
|
+
const candidate = path.join(dir, 'package.json');
|
|
1148
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
1149
|
+
dir = path.dirname(dir);
|
|
1150
|
+
}
|
|
1151
|
+
return '';
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function isNodeCommand(command) {
|
|
1155
|
+
const base = path.basename(command).toLowerCase();
|
|
1156
|
+
return base === 'node' || base === 'node.exe' || command === process.execPath;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function isPythonCommand(command) {
|
|
1160
|
+
const base = path.basename(command).toLowerCase();
|
|
1161
|
+
return /^python(?:\d+(?:\.\d+)*)?(?:\.exe)?$/.test(base);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function isNpmCommand(base) {
|
|
1165
|
+
return base === 'npm' || base === 'npm.cmd' || base === 'npm-cli.js';
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function isNpxCommand(base) {
|
|
1169
|
+
return base === 'npx' || base === 'npx.cmd';
|
|
1170
|
+
}
|
|
1171
|
+
|
|
511
1172
|
function finalizeResult(result) {
|
|
512
1173
|
result.schemaVersion = JSON_SCHEMA_VERSION;
|
|
513
1174
|
result.staticScan ??= defaultStaticScan();
|
|
514
1175
|
result.staticFindings ??= [];
|
|
1176
|
+
result.issues = normalizeIssues(result.issues ?? []);
|
|
515
1177
|
result.ok = !result.issues.some((issue) => issue.severity === 'error');
|
|
516
1178
|
result.checks = buildChecks(result);
|
|
1179
|
+
result.issueClasses = buildIssueClasses(result.issues);
|
|
1180
|
+
finalizeFingerprint(result);
|
|
517
1181
|
return result;
|
|
518
1182
|
}
|
|
519
1183
|
|
|
1184
|
+
function finalizeFingerprint(result) {
|
|
1185
|
+
if (!result.fingerprint) return;
|
|
1186
|
+
result.fingerprint.timings ??= {};
|
|
1187
|
+
result.fingerprint.timings.totalMs = result.durationMs ?? result.fingerprint.timings.totalMs ?? null;
|
|
1188
|
+
|
|
1189
|
+
if (Array.isArray(result.runs)) {
|
|
1190
|
+
result.fingerprint.runs = result.runs.map((run) => ({
|
|
1191
|
+
run: run.run,
|
|
1192
|
+
ok: run.ok,
|
|
1193
|
+
startupMs: run.fingerprint?.timings?.startupMs ?? null,
|
|
1194
|
+
totalMs: run.durationMs ?? run.fingerprint?.timings?.totalMs ?? null
|
|
1195
|
+
}));
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function normalizeIssues(issues) {
|
|
1200
|
+
return issues.map((issue) => ({
|
|
1201
|
+
...issue,
|
|
1202
|
+
class: classifyIssueCode(issue.code)
|
|
1203
|
+
}));
|
|
1204
|
+
}
|
|
1205
|
+
|
|
520
1206
|
function buildChecks(result) {
|
|
521
1207
|
const issues = result.issues ?? [];
|
|
522
1208
|
const repeated = Array.isArray(result.runs);
|
|
@@ -537,6 +1223,13 @@ function buildChecks(result) {
|
|
|
537
1223
|
};
|
|
538
1224
|
}
|
|
539
1225
|
|
|
1226
|
+
function buildIssueClasses(issues) {
|
|
1227
|
+
return Object.fromEntries(ISSUE_CLASS_NAMES.map((className) => [
|
|
1228
|
+
className,
|
|
1229
|
+
buildIssueCheck(issues, (issue) => issue.class === className)
|
|
1230
|
+
]));
|
|
1231
|
+
}
|
|
1232
|
+
|
|
540
1233
|
function buildInitializeCheck(result, issues) {
|
|
541
1234
|
const matched = issues.filter((issue) => (
|
|
542
1235
|
INITIALIZE_ISSUE_CODES.has(issue.code)
|