codex-plugin-doctor 1.51.0 → 1.53.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 +17 -2
- package/dist/core/bounded-http-client.d.ts +23 -0
- package/dist/core/bounded-http-client.js +241 -0
- package/dist/core/doctor-export-bundle.js +3 -1
- package/dist/core/output-contract.js +50 -1
- package/dist/core/release-check.d.ts +4 -0
- package/dist/core/release-check.js +4 -0
- package/dist/core/release-evidence.d.ts +4 -0
- package/dist/core/release-evidence.js +113 -7
- package/dist/core/remote-mcp-probe.d.ts +17 -0
- package/dist/core/remote-mcp-probe.js +284 -0
- package/dist/core/remote-network-policy.d.ts +24 -0
- package/dist/core/remote-network-policy.js +125 -0
- package/dist/core/remote-oauth-readiness.d.ts +12 -0
- package/dist/core/remote-oauth-readiness.js +295 -0
- package/dist/core/remote-transport-reliability.d.ts +18 -0
- package/dist/core/remote-transport-reliability.js +243 -0
- package/dist/core/remote-url-policy.d.ts +7 -0
- package/dist/core/remote-url-policy.js +49 -0
- package/dist/core/runtime-plan.d.ts +3 -0
- package/dist/core/runtime-plan.js +34 -2
- package/dist/core/runtime-policy.js +5 -1
- package/dist/core/runtime-probe.d.ts +10 -1
- package/dist/core/runtime-probe.js +89 -29
- package/dist/core/sse-observation.d.ts +7 -0
- package/dist/core/sse-observation.js +67 -0
- package/dist/core/validate-plugin.js +35 -3
- package/dist/domain/types.d.ts +26 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp/generic-mcp-doctor.d.ts +4 -0
- package/dist/mcp/generic-mcp-doctor.js +15 -5
- package/dist/reporting/render-markdown-report.js +38 -12
- package/dist/reporting/render-text-report.js +32 -10
- package/dist/rules/rule-catalog.js +289 -1
- package/dist/run-cli.js +172 -5
- package/dist/security/security-audit.js +34 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,21 @@ Runtime MCP validation with `--runtime`:
|
|
|
81
81
|
- optional runtime approval gating with a precomputed `doctor runtime-plan` digest
|
|
82
82
|
- optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
|
|
83
83
|
|
|
84
|
+
### Remote MCP Readiness
|
|
85
|
+
|
|
86
|
+
Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
codex-plugin-doctor check ./remote-mcp --runtime --allow-network
|
|
90
|
+
codex-plugin-doctor check ./remote-mcp --runtime --allow-network --allow-local-network
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The probe makes only bounded protocol and OAuth metadata-discovery requests, redacts report output, and applies SSRF controls. It does not authenticate or follow redirects.
|
|
94
|
+
|
|
95
|
+
Remote transport reliability adds one bounded SSE GET after initialization. HTTP `200` must be `text/event-stream`; HTTP `405` is compliant when the endpoint does not offer server-to-client SSE. At most one SSE resume and one session restart are attempted, and no remote response content is retained. This is a bounded readiness check, not a live interoperability, delivery-guarantee, or load-test claim.
|
|
96
|
+
|
|
97
|
+
`--allow-session-lifecycle` is disabled by default and is state-changing: only after a valid `MCP-Session-Id` it permits one bounded session `DELETE`. `--require-remote-reliability` is a strict result gate: it fails unless every attempted remote reliability scorecard passes. Local-only runs are unaffected. It grants no network consent, so `--runtime --allow-network` (and loopback consent when applicable) remain required. See [Remote MCP Readiness](./docs/architecture/remote-mcp-readiness.md) and [Remote MCP Transport Reliability](./docs/architecture/remote-mcp-transport-reliability.md).
|
|
98
|
+
|
|
84
99
|
Output formats:
|
|
85
100
|
|
|
86
101
|
- human text output
|
|
@@ -427,9 +442,9 @@ jobs:
|
|
|
427
442
|
runs-on: ubuntu-latest
|
|
428
443
|
steps:
|
|
429
444
|
- uses: actions/checkout@v5
|
|
430
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
445
|
+
- uses: Esquetta/CodexPluginDoctor@v1.53.0
|
|
431
446
|
with:
|
|
432
|
-
version: "1.
|
|
447
|
+
version: "1.53.0"
|
|
433
448
|
path: .
|
|
434
449
|
runtime: "true"
|
|
435
450
|
policy: codex-publish
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type RemoteLookup } from "./remote-network-policy.js";
|
|
2
|
+
export interface BoundedHttpRequestOptions {
|
|
3
|
+
allowLocalNetwork?: boolean;
|
|
4
|
+
lookup?: RemoteLookup;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
maxResponseBytes?: number;
|
|
7
|
+
method?: string;
|
|
8
|
+
body?: string | Buffer;
|
|
9
|
+
headers?: Record<string, string | undefined>;
|
|
10
|
+
stopAfter?: (body: Buffer) => boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface BoundedHttpResponse {
|
|
13
|
+
statusCode: number;
|
|
14
|
+
headers: Record<string, string | string[]>;
|
|
15
|
+
body: Buffer;
|
|
16
|
+
}
|
|
17
|
+
export declare class BoundedHttpError extends Error {
|
|
18
|
+
readonly code: "REMOTE_HTTP_ENCODING_UNSUPPORTED" | "REMOTE_HTTP_HEADER_FORBIDDEN" | "REMOTE_HTTP_OPTIONS_INVALID" | "REMOTE_HTTP_PEER_MISMATCH" | "REMOTE_HTTP_REDIRECT" | "REMOTE_HTTP_REQUEST_FAILED" | "REMOTE_HTTP_RESPONSE_TOO_LARGE" | "REMOTE_HTTP_STOP_CONDITION_FAILED" | "REMOTE_HTTP_TIMEOUT" | "REMOTE_HTTP_URL_CREDENTIALS" | "REMOTE_HTTP_URL_UNSUPPORTED";
|
|
19
|
+
readonly statusCode?: number | undefined;
|
|
20
|
+
readonly headers?: Record<string, string | string[]> | undefined;
|
|
21
|
+
constructor(code: "REMOTE_HTTP_ENCODING_UNSUPPORTED" | "REMOTE_HTTP_HEADER_FORBIDDEN" | "REMOTE_HTTP_OPTIONS_INVALID" | "REMOTE_HTTP_PEER_MISMATCH" | "REMOTE_HTTP_REDIRECT" | "REMOTE_HTTP_REQUEST_FAILED" | "REMOTE_HTTP_RESPONSE_TOO_LARGE" | "REMOTE_HTTP_STOP_CONDITION_FAILED" | "REMOTE_HTTP_TIMEOUT" | "REMOTE_HTTP_URL_CREDENTIALS" | "REMOTE_HTTP_URL_UNSUPPORTED", message: string, statusCode?: number | undefined, headers?: Record<string, string | string[]> | undefined);
|
|
22
|
+
}
|
|
23
|
+
export declare function requestBoundedHttp(rawUrl: string, options?: BoundedHttpRequestOptions): Promise<BoundedHttpResponse>;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import { BlockList, isIP } from "node:net";
|
|
4
|
+
import { resolveRemoteTarget } from "./remote-network-policy.js";
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 3_000;
|
|
6
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 1_024 * 1_024;
|
|
7
|
+
const allowedRequestHeaders = new Set([
|
|
8
|
+
"accept",
|
|
9
|
+
"content-type",
|
|
10
|
+
"last-event-id",
|
|
11
|
+
"mcp-protocol-version",
|
|
12
|
+
"mcp-session-id",
|
|
13
|
+
"user-agent"
|
|
14
|
+
]);
|
|
15
|
+
const safeResponseHeaders = new Set([
|
|
16
|
+
"content-type",
|
|
17
|
+
"www-authenticate",
|
|
18
|
+
"mcp-session-id",
|
|
19
|
+
"mcp-protocol-version",
|
|
20
|
+
"location"
|
|
21
|
+
]);
|
|
22
|
+
export class BoundedHttpError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
statusCode;
|
|
25
|
+
headers;
|
|
26
|
+
constructor(code, message, statusCode, headers) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.statusCode = statusCode;
|
|
30
|
+
this.headers = headers;
|
|
31
|
+
this.name = "BoundedHttpError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function validateHeaders(headers) {
|
|
35
|
+
const validated = {};
|
|
36
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const normalizedName = name.toLowerCase();
|
|
41
|
+
if (!allowedRequestHeaders.has(normalizedName)) {
|
|
42
|
+
throw new BoundedHttpError("REMOTE_HTTP_HEADER_FORBIDDEN", `Remote HTTP request header is not allowed: ${normalizedName}.`);
|
|
43
|
+
}
|
|
44
|
+
if (normalizedName in validated || typeof value !== "string") {
|
|
45
|
+
throw new BoundedHttpError("REMOTE_HTTP_HEADER_FORBIDDEN", `Remote HTTP request header must be a single valid string: ${normalizedName}.`);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
http.validateHeaderValue(normalizedName, value);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new BoundedHttpError("REMOTE_HTTP_HEADER_FORBIDDEN", `Remote HTTP request header must be a single valid string: ${normalizedName}.`);
|
|
52
|
+
}
|
|
53
|
+
validated[normalizedName] = value;
|
|
54
|
+
}
|
|
55
|
+
return validated;
|
|
56
|
+
}
|
|
57
|
+
function validateOptions(options) {
|
|
58
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
59
|
+
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
60
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS
|
|
61
|
+
|| !Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
|
|
62
|
+
throw new BoundedHttpError("REMOTE_HTTP_OPTIONS_INVALID", "Remote HTTP request options are invalid.");
|
|
63
|
+
}
|
|
64
|
+
return { timeoutMs, maxResponseBytes };
|
|
65
|
+
}
|
|
66
|
+
async function resolveWithinDeadline(url, options, timeoutMs) {
|
|
67
|
+
let timer;
|
|
68
|
+
try {
|
|
69
|
+
return await Promise.race([
|
|
70
|
+
resolveRemoteTarget(url, {
|
|
71
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
72
|
+
lookup: options.lookup
|
|
73
|
+
}),
|
|
74
|
+
new Promise((_resolve, reject) => {
|
|
75
|
+
timer = setTimeout(() => {
|
|
76
|
+
reject(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out."));
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
})
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
if (timer !== undefined) {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function selectSafeHeaders(headers) {
|
|
88
|
+
const selected = {};
|
|
89
|
+
for (const name of safeResponseHeaders) {
|
|
90
|
+
const value = headers[name];
|
|
91
|
+
if (value !== undefined) {
|
|
92
|
+
selected[name] = value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return selected;
|
|
96
|
+
}
|
|
97
|
+
function hasIdentityContentEncoding(headers) {
|
|
98
|
+
const value = headers["content-encoding"];
|
|
99
|
+
const values = Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
100
|
+
return values.every((entry) => entry.toLowerCase() === "identity");
|
|
101
|
+
}
|
|
102
|
+
function matchesTargetPeer(target, remoteAddress) {
|
|
103
|
+
if (remoteAddress === undefined || isIP(remoteAddress) === 0) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const peers = new BlockList();
|
|
107
|
+
peers.addAddress(target.address, target.family === 4 ? "ipv4" : "ipv6");
|
|
108
|
+
if (target.family === 4) {
|
|
109
|
+
peers.addAddress(`::ffff:${target.address}`, "ipv6");
|
|
110
|
+
}
|
|
111
|
+
return peers.check(remoteAddress, isIP(remoteAddress) === 4 ? "ipv4" : "ipv6");
|
|
112
|
+
}
|
|
113
|
+
export async function requestBoundedHttp(rawUrl, options = {}) {
|
|
114
|
+
let url;
|
|
115
|
+
try {
|
|
116
|
+
url = new URL(rawUrl);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw new BoundedHttpError("REMOTE_HTTP_URL_UNSUPPORTED", "Remote HTTP URL must be absolute HTTP or HTTPS.");
|
|
120
|
+
}
|
|
121
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
122
|
+
throw new BoundedHttpError("REMOTE_HTTP_URL_UNSUPPORTED", "Remote HTTP URL must be absolute HTTP or HTTPS.");
|
|
123
|
+
}
|
|
124
|
+
if (url.username || url.password) {
|
|
125
|
+
throw new BoundedHttpError("REMOTE_HTTP_URL_CREDENTIALS", "Remote HTTP URL must not include credentials.");
|
|
126
|
+
}
|
|
127
|
+
const { timeoutMs, maxResponseBytes } = validateOptions(options);
|
|
128
|
+
const headers = validateHeaders(options.headers);
|
|
129
|
+
const deadline = Date.now() + timeoutMs;
|
|
130
|
+
const target = await resolveWithinDeadline(url, options, timeoutMs);
|
|
131
|
+
const remainingMs = deadline - Date.now();
|
|
132
|
+
if (remainingMs <= 0) {
|
|
133
|
+
throw new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out.");
|
|
134
|
+
}
|
|
135
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
let request;
|
|
138
|
+
let response;
|
|
139
|
+
let settled = false;
|
|
140
|
+
const timeout = setTimeout(() => {
|
|
141
|
+
fail(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out.", response?.statusCode, response ? selectSafeHeaders(response.headers) : undefined));
|
|
142
|
+
}, remainingMs);
|
|
143
|
+
const fail = (error) => {
|
|
144
|
+
if (settled) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
settled = true;
|
|
148
|
+
clearTimeout(timeout);
|
|
149
|
+
response?.destroy();
|
|
150
|
+
request?.destroy();
|
|
151
|
+
reject(error);
|
|
152
|
+
};
|
|
153
|
+
const complete = (body) => {
|
|
154
|
+
if (settled) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
settled = true;
|
|
158
|
+
clearTimeout(timeout);
|
|
159
|
+
response?.destroy();
|
|
160
|
+
request?.destroy();
|
|
161
|
+
resolve({
|
|
162
|
+
statusCode: response?.statusCode ?? 0,
|
|
163
|
+
headers: response ? selectSafeHeaders(response.headers) : {},
|
|
164
|
+
body
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
try {
|
|
168
|
+
request = transport.request({
|
|
169
|
+
protocol: url.protocol,
|
|
170
|
+
hostname: url.hostname,
|
|
171
|
+
port: url.port || undefined,
|
|
172
|
+
path: `${url.pathname}${url.search}`,
|
|
173
|
+
method: options.method ?? "GET",
|
|
174
|
+
headers,
|
|
175
|
+
agent: false,
|
|
176
|
+
lookup: (_hostname, lookupOptions, callback) => {
|
|
177
|
+
if (lookupOptions.all) {
|
|
178
|
+
callback(null, [{ address: target.address, family: target.family }]);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
callback(null, target.address, target.family);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REQUEST_FAILED", "Remote HTTP request failed."));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
request.once("error", () => {
|
|
190
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REQUEST_FAILED", "Remote HTTP request failed."));
|
|
191
|
+
});
|
|
192
|
+
request.once("response", (incoming) => {
|
|
193
|
+
response = incoming;
|
|
194
|
+
const safeHeaders = selectSafeHeaders(incoming.headers);
|
|
195
|
+
if (!matchesTargetPeer(target, incoming.socket.remoteAddress)) {
|
|
196
|
+
fail(new BoundedHttpError("REMOTE_HTTP_PEER_MISMATCH", "Remote HTTP peer did not match the resolved target."));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if ((incoming.statusCode ?? 0) >= 300 && (incoming.statusCode ?? 0) < 400) {
|
|
200
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REDIRECT", "Remote HTTP redirects are not allowed.", incoming.statusCode, safeHeaders));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!hasIdentityContentEncoding(incoming.headers)) {
|
|
204
|
+
fail(new BoundedHttpError("REMOTE_HTTP_ENCODING_UNSUPPORTED", "Remote HTTP response content encoding must be identity."));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const chunks = [];
|
|
208
|
+
let receivedBytes = 0;
|
|
209
|
+
incoming.on("data", (chunk) => {
|
|
210
|
+
receivedBytes += chunk.length;
|
|
211
|
+
if (receivedBytes > maxResponseBytes) {
|
|
212
|
+
fail(new BoundedHttpError("REMOTE_HTTP_RESPONSE_TOO_LARGE", "Remote HTTP response exceeded the configured size limit."));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
chunks.push(chunk);
|
|
216
|
+
const body = Buffer.concat(chunks);
|
|
217
|
+
let shouldStop;
|
|
218
|
+
try {
|
|
219
|
+
shouldStop = options.stopAfter?.(body) ?? false;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
fail(new BoundedHttpError("REMOTE_HTTP_STOP_CONDITION_FAILED", "Remote HTTP stop condition failed."));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (shouldStop) {
|
|
226
|
+
complete(body);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
incoming.once("error", () => {
|
|
231
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REQUEST_FAILED", "Remote HTTP request failed."));
|
|
232
|
+
});
|
|
233
|
+
incoming.once("end", () => {
|
|
234
|
+
if (!settled) {
|
|
235
|
+
complete(Buffer.concat(chunks));
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
request.end(options.body);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -16,7 +16,9 @@ export function redactValue(value) {
|
|
|
16
16
|
if (typeof value === "object" && value !== null) {
|
|
17
17
|
return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [
|
|
18
18
|
key,
|
|
19
|
-
|
|
19
|
+
/^(?:mcp[-_]?session[-_]?id|session[-_]?id|last[-_]?event[-_]?id|event[-_]?id|retry|(?:sse[-_]?)?data)$/i.test(key)
|
|
20
|
+
? "[REDACTED_SECRET]"
|
|
21
|
+
: redactValue(nestedValue)
|
|
20
22
|
]));
|
|
21
23
|
}
|
|
22
24
|
return value;
|
|
@@ -5,6 +5,10 @@ const runtimeCapabilityStatusSchema = {
|
|
|
5
5
|
type: "string",
|
|
6
6
|
enum: ["pass", "fail", "warn", "skipped", "unsupported"]
|
|
7
7
|
};
|
|
8
|
+
const remoteTransportReliabilityStatusSchema = {
|
|
9
|
+
type: "string",
|
|
10
|
+
enum: ["pass", "warn", "fail", "skipped"]
|
|
11
|
+
};
|
|
8
12
|
const runtimeConformanceSchema = {
|
|
9
13
|
type: "object",
|
|
10
14
|
properties: {
|
|
@@ -35,6 +39,50 @@ const runtimeConformanceSchema = {
|
|
|
35
39
|
],
|
|
36
40
|
additionalProperties: false
|
|
37
41
|
};
|
|
42
|
+
const remoteTransportReliabilitySchema = {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
getSse: remoteTransportReliabilityStatusSchema,
|
|
46
|
+
sessionPropagation: remoteTransportReliabilityStatusSchema,
|
|
47
|
+
resumability: remoteTransportReliabilityStatusSchema,
|
|
48
|
+
disconnectSafety: remoteTransportReliabilityStatusSchema,
|
|
49
|
+
sessionRestart: remoteTransportReliabilityStatusSchema,
|
|
50
|
+
termination: remoteTransportReliabilityStatusSchema,
|
|
51
|
+
overall: remoteTransportReliabilityStatusSchema
|
|
52
|
+
},
|
|
53
|
+
required: [
|
|
54
|
+
"getSse",
|
|
55
|
+
"sessionPropagation",
|
|
56
|
+
"resumability",
|
|
57
|
+
"disconnectSafety",
|
|
58
|
+
"sessionRestart",
|
|
59
|
+
"termination",
|
|
60
|
+
"overall"
|
|
61
|
+
],
|
|
62
|
+
additionalProperties: false
|
|
63
|
+
};
|
|
64
|
+
const remoteRuntimeScorecardSchema = {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
transport: runtimeCapabilityStatusSchema,
|
|
68
|
+
networkSafety: runtimeCapabilityStatusSchema,
|
|
69
|
+
initialize: runtimeCapabilityStatusSchema,
|
|
70
|
+
contentType: runtimeCapabilityStatusSchema,
|
|
71
|
+
session: {
|
|
72
|
+
type: "string",
|
|
73
|
+
enum: ["absent", "present-valid", "present-invalid"]
|
|
74
|
+
},
|
|
75
|
+
protocolHeaders: runtimeCapabilityStatusSchema,
|
|
76
|
+
authorization: runtimeCapabilityStatusSchema,
|
|
77
|
+
reliability: remoteTransportReliabilitySchema,
|
|
78
|
+
overall: {
|
|
79
|
+
type: "string",
|
|
80
|
+
enum: ["pass", "warn", "fail", "skipped"]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
required: ["transport", "networkSafety", "initialize", "contentType", "session", "protocolHeaders", "authorization", "overall"],
|
|
84
|
+
additionalProperties: false
|
|
85
|
+
};
|
|
38
86
|
const runtimeScorecardSchema = {
|
|
39
87
|
type: "object",
|
|
40
88
|
properties: {
|
|
@@ -46,7 +94,8 @@ const runtimeScorecardSchema = {
|
|
|
46
94
|
resourceTemplatesList: runtimeCapabilityStatusSchema,
|
|
47
95
|
promptsList: runtimeCapabilityStatusSchema,
|
|
48
96
|
promptGet: runtimeCapabilityStatusSchema,
|
|
49
|
-
conformance: runtimeConformanceSchema
|
|
97
|
+
conformance: runtimeConformanceSchema,
|
|
98
|
+
remote: remoteRuntimeScorecardSchema
|
|
50
99
|
},
|
|
51
100
|
required: [
|
|
52
101
|
"initialize",
|
|
@@ -41,6 +41,10 @@ export interface BuildReleaseCheckOptions {
|
|
|
41
41
|
env?: Record<string, string | undefined>;
|
|
42
42
|
platform?: NodeJS.Platform;
|
|
43
43
|
runtime?: boolean;
|
|
44
|
+
allowNetwork?: boolean;
|
|
45
|
+
allowLocalNetwork?: boolean;
|
|
46
|
+
allowSessionLifecycle?: boolean;
|
|
47
|
+
requireRemoteReliability?: boolean;
|
|
44
48
|
runtimeSandbox?: RuntimeSandboxMode;
|
|
45
49
|
runCheck?: (targetPath: string, options: CheckOptions) => Promise<CheckResult>;
|
|
46
50
|
}
|
|
@@ -106,6 +106,10 @@ export async function buildReleaseCheck(targetPath, options = {}) {
|
|
|
106
106
|
const runtimeProbeEnabled = options.runtime ?? false;
|
|
107
107
|
const validationResult = await (options.runCheck ?? validatePlugin)(resolvedPath, {
|
|
108
108
|
runtime: runtimeProbeEnabled,
|
|
109
|
+
...(options.allowNetwork ? { allowNetwork: true } : {}),
|
|
110
|
+
...(options.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
|
|
111
|
+
...(options.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
|
|
112
|
+
...(options.requireRemoteReliability ? { requireRemoteReliability: true } : {}),
|
|
109
113
|
...(options.runtimeSandbox ? { runtimeSandbox: options.runtimeSandbox } : {})
|
|
110
114
|
});
|
|
111
115
|
const securityResult = await buildSecurityAudit(resolvedPath);
|
|
@@ -111,6 +111,10 @@ export interface BuildDoctorReleaseEvidenceOptions {
|
|
|
111
111
|
requireRuntimeApproval?: boolean;
|
|
112
112
|
runtimeApprovalDigest?: string | null;
|
|
113
113
|
runtime?: boolean;
|
|
114
|
+
allowNetwork?: boolean;
|
|
115
|
+
allowLocalNetwork?: boolean;
|
|
116
|
+
allowSessionLifecycle?: boolean;
|
|
117
|
+
requireRemoteReliability?: boolean;
|
|
114
118
|
sandbox?: RuntimeSandboxMode;
|
|
115
119
|
environment?: CompatibilityEnvironment;
|
|
116
120
|
runCheck?: (targetPath: string, options?: CheckOptions) => Promise<CheckResult>;
|
|
@@ -192,17 +192,128 @@ function signReleaseEvidence(report, signingKey, keyHint) {
|
|
|
192
192
|
keyHint
|
|
193
193
|
};
|
|
194
194
|
}
|
|
195
|
+
function buildRuntimeApprovalFailureReport(rootPath, runtimePlan, runtimeApproval, options) {
|
|
196
|
+
const generatedAt = new Date().toISOString();
|
|
197
|
+
const emptyDigest = sha256("");
|
|
198
|
+
const failureMessage = "Release evidence checks were not run because runtime approval did not pass.";
|
|
199
|
+
const report = {
|
|
200
|
+
schemaVersion: "1.0.0",
|
|
201
|
+
kind: "doctor.release.evidence",
|
|
202
|
+
generatedAt,
|
|
203
|
+
version: packageVersion,
|
|
204
|
+
targetPath: rootPath,
|
|
205
|
+
status: "fail",
|
|
206
|
+
exitCode: 1,
|
|
207
|
+
releaseReady: false,
|
|
208
|
+
summary: {
|
|
209
|
+
attestation: "fail",
|
|
210
|
+
attestationVerification: "fail",
|
|
211
|
+
corpus: "fail",
|
|
212
|
+
performance: "fail",
|
|
213
|
+
releaseGates: "fail",
|
|
214
|
+
runtimeApproval: "fail",
|
|
215
|
+
security: "fail",
|
|
216
|
+
trust: "fail"
|
|
217
|
+
},
|
|
218
|
+
releaseGates: {
|
|
219
|
+
status: "fail",
|
|
220
|
+
checks: [{ id: "runtime.approval", status: "fail", message: runtimeApproval.message }]
|
|
221
|
+
},
|
|
222
|
+
runtimeApproval,
|
|
223
|
+
...(options.runtime ? { execution: runtimePlan.execution } : {}),
|
|
224
|
+
package: { name: null, version: null, private: null },
|
|
225
|
+
git: { commit: null, tag: null, dirty: null },
|
|
226
|
+
attestation: {
|
|
227
|
+
schemaVersion: "1.0.0",
|
|
228
|
+
kind: "doctor.attestation",
|
|
229
|
+
generatedAt,
|
|
230
|
+
version: packageVersion,
|
|
231
|
+
targetPath: rootPath,
|
|
232
|
+
subject: { name: "unavailable", version: null, description: null },
|
|
233
|
+
packageFingerprint: { algorithm: "sha256", digest: emptyDigest, files: { total: 0, bytes: 0 } },
|
|
234
|
+
reportDigest: { algorithm: "sha256", digest: emptyDigest },
|
|
235
|
+
summary: {
|
|
236
|
+
status: "fail",
|
|
237
|
+
validation: { status: "fail", findingCount: 0 },
|
|
238
|
+
security: { status: "fail", score: 0, findingCount: 0 },
|
|
239
|
+
compatibility: { failedClients: [] },
|
|
240
|
+
trust: { status: "fail", score: 0, findingCount: 0 },
|
|
241
|
+
recommendations: { actionCount: 0 }
|
|
242
|
+
},
|
|
243
|
+
verification: { recomputeCommand: "", notes: [failureMessage] },
|
|
244
|
+
signature: { status: "unsigned", reason: failureMessage }
|
|
245
|
+
},
|
|
246
|
+
attestationVerification: {
|
|
247
|
+
schemaVersion: "1.0.0",
|
|
248
|
+
kind: "doctor.attestation.verification",
|
|
249
|
+
generatedAt,
|
|
250
|
+
artifactPath: "inline:doctor.release-evidence.attestation",
|
|
251
|
+
targetPath: rootPath,
|
|
252
|
+
status: "fail",
|
|
253
|
+
exitCode: 1,
|
|
254
|
+
summary: { packageFingerprint: "fail", reportDigest: "fail", signature: "fail" },
|
|
255
|
+
unsignedFields: [],
|
|
256
|
+
checks: [{ id: "runtime.approval", status: "fail", message: runtimeApproval.message }]
|
|
257
|
+
},
|
|
258
|
+
corpus: {
|
|
259
|
+
schemaVersion: "1.0.0",
|
|
260
|
+
kind: "doctor.validation.corpus",
|
|
261
|
+
generatedAt,
|
|
262
|
+
version: packageVersion,
|
|
263
|
+
summary: { status: "fail", caseCount: 0, passedExpectations: 0, failedExpectations: 0, runtimeCases: 0 },
|
|
264
|
+
cases: []
|
|
265
|
+
},
|
|
266
|
+
performance: {
|
|
267
|
+
schemaVersion: "1.0.0",
|
|
268
|
+
kind: "doctor.perf",
|
|
269
|
+
generatedAt,
|
|
270
|
+
targetPath: rootPath,
|
|
271
|
+
status: "fail",
|
|
272
|
+
exitCode: 1,
|
|
273
|
+
summary: {
|
|
274
|
+
stageCount: 0,
|
|
275
|
+
slowestStage: "total",
|
|
276
|
+
totalDurationMs: 0,
|
|
277
|
+
validationStatus: "fail",
|
|
278
|
+
securityStatus: "fail",
|
|
279
|
+
trustScore: 0,
|
|
280
|
+
compatibilityFailures: 0,
|
|
281
|
+
thresholdFailures: 0
|
|
282
|
+
},
|
|
283
|
+
stages: [],
|
|
284
|
+
thresholds: []
|
|
285
|
+
},
|
|
286
|
+
security: { status: "fail", score: 0, findingCounts: { fail: 0, warn: 0, total: 0 } },
|
|
287
|
+
trust: { status: "fail", score: 0, findingCounts: { fail: 0, warn: 0, total: 0 } }
|
|
288
|
+
};
|
|
289
|
+
return {
|
|
290
|
+
...report,
|
|
291
|
+
evidenceSignature: signReleaseEvidence(report, options.signingKey, `env:${options.signingKeyEnv}`)
|
|
292
|
+
};
|
|
293
|
+
}
|
|
195
294
|
export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
|
|
196
295
|
const rootPath = path.resolve(targetPath);
|
|
296
|
+
const runtimePlan = await buildDoctorRuntimePlan(rootPath, new Date().toISOString(), options.sandbox ? { sandbox: options.sandbox } : {});
|
|
297
|
+
const runtimeApproval = evaluateRuntimeApproval(runtimePlan, {
|
|
298
|
+
required: options.requireRuntimeApproval ?? false,
|
|
299
|
+
approvedDigest: options.runtimeApprovalDigest
|
|
300
|
+
});
|
|
301
|
+
if (!runtimeApprovalPassed(runtimeApproval)) {
|
|
302
|
+
return buildRuntimeApprovalFailureReport(rootPath, runtimePlan, runtimeApproval, options);
|
|
303
|
+
}
|
|
197
304
|
const security = await buildSecurityAudit(rootPath);
|
|
198
305
|
const runCheck = options.runCheck ?? validatePlugin;
|
|
199
306
|
const checkOptions = options.runtime
|
|
200
307
|
? {
|
|
201
308
|
runtime: true,
|
|
309
|
+
...(options.allowNetwork ? { allowNetwork: true } : {}),
|
|
310
|
+
...(options.allowLocalNetwork ? { allowLocalNetwork: true } : {}),
|
|
311
|
+
...(options.allowSessionLifecycle ? { allowSessionLifecycle: true } : {}),
|
|
312
|
+
...(options.requireRemoteReliability ? { requireRemoteReliability: true } : {}),
|
|
202
313
|
...(options.sandbox ? { runtimeSandbox: options.sandbox } : {})
|
|
203
314
|
}
|
|
204
315
|
: {};
|
|
205
|
-
const [attestation, corpus, performance, trust, packageMetadata, git
|
|
316
|
+
const [attestation, corpus, performance, trust, packageMetadata, git] = await Promise.all([
|
|
206
317
|
buildDoctorAttestation(rootPath, {
|
|
207
318
|
signingKey: options.signingKey,
|
|
208
319
|
signingKeyHint: `env:${options.signingKeyEnv}`,
|
|
@@ -216,8 +327,7 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
|
|
|
216
327
|
}),
|
|
217
328
|
buildTrustScore(rootPath, { securityAudit: security }),
|
|
218
329
|
readPackageMetadata(rootPath),
|
|
219
|
-
readGitMetadata(rootPath)
|
|
220
|
-
buildDoctorRuntimePlan(rootPath, new Date().toISOString(), options.sandbox ? { sandbox: options.sandbox } : {})
|
|
330
|
+
readGitMetadata(rootPath)
|
|
221
331
|
]);
|
|
222
332
|
const normalizedPackageMetadata = {
|
|
223
333
|
name: packageMetadata.name ?? attestation.subject.name,
|
|
@@ -229,10 +339,6 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
|
|
|
229
339
|
artifactPath: "inline:doctor.release-evidence.attestation"
|
|
230
340
|
});
|
|
231
341
|
const releaseGates = buildReleaseGateReport(git, options);
|
|
232
|
-
const runtimeApproval = evaluateRuntimeApproval(runtimePlan, {
|
|
233
|
-
required: options.requireRuntimeApproval ?? false,
|
|
234
|
-
approvedDigest: options.runtimeApprovalDigest
|
|
235
|
-
});
|
|
236
342
|
const partialReport = {
|
|
237
343
|
schemaVersion: "1.0.0",
|
|
238
344
|
kind: "doctor.release.evidence",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type BoundedHttpRequestOptions, type BoundedHttpResponse } from "./bounded-http-client.js";
|
|
2
|
+
import { type RemoteLookup } from "./remote-network-policy.js";
|
|
3
|
+
import type { Finding, RemoteRuntimeScorecard } from "../domain/types.js";
|
|
4
|
+
export type RemoteMcpRequest = (rawUrl: string, options?: BoundedHttpRequestOptions) => Promise<BoundedHttpResponse>;
|
|
5
|
+
export interface RemoteMcpProbeOptions {
|
|
6
|
+
allowNetwork?: boolean;
|
|
7
|
+
allowLocalNetwork?: boolean;
|
|
8
|
+
requestTimeoutMs?: number;
|
|
9
|
+
lookup?: RemoteLookup;
|
|
10
|
+
request?: RemoteMcpRequest;
|
|
11
|
+
allowSessionLifecycle?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface RemoteMcpProbeResult {
|
|
14
|
+
findings: Finding[];
|
|
15
|
+
scorecard: RemoteRuntimeScorecard;
|
|
16
|
+
}
|
|
17
|
+
export declare function probeRemoteMcpServer(serverName: string, rawUrl: string, options?: RemoteMcpProbeOptions): Promise<RemoteMcpProbeResult>;
|