codex-plugin-doctor 1.51.0 → 1.52.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 -6
- package/dist/core/bounded-http-client.d.ts +23 -0
- package/dist/core/bounded-http-client.js +240 -0
- package/dist/core/output-contract.js +23 -1
- package/dist/core/release-check.d.ts +2 -0
- package/dist/core/release-check.js +2 -0
- package/dist/core/release-evidence.d.ts +2 -0
- package/dist/core/release-evidence.js +111 -7
- package/dist/core/remote-mcp-probe.d.ts +16 -0
- package/dist/core/remote-mcp-probe.js +226 -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-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 +7 -0
- package/dist/core/runtime-probe.js +72 -29
- package/dist/core/validate-plugin.js +32 -1
- package/dist/domain/types.d.ts +13 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp/generic-mcp-doctor.d.ts +2 -0
- package/dist/mcp/generic-mcp-doctor.js +10 -3
- package/dist/reporting/render-markdown-report.js +25 -12
- package/dist/reporting/render-text-report.js +21 -10
- package/dist/rules/rule-catalog.js +181 -1
- package/dist/run-cli.js +98 -5
- package/dist/security/security-audit.js +34 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,10 +78,21 @@ Runtime MCP validation with `--runtime`:
|
|
|
78
78
|
- paginated list responses
|
|
79
79
|
- runtime capability scorecard
|
|
80
80
|
- redacted verbose transcript with `--verbose-runtime`
|
|
81
|
-
- optional runtime approval gating with a precomputed `doctor runtime-plan` digest
|
|
82
|
-
- optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
- optional runtime approval gating with a precomputed `doctor runtime-plan` digest
|
|
82
|
+
- optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
|
|
83
|
+
|
|
84
|
+
### Remote MCP Readiness
|
|
85
|
+
|
|
86
|
+
Remote MCP runtime probing is disabled until you explicitly pass `--allow-network`. Local 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, read-only protocol and OAuth metadata-discovery requests, redacts report output, and applies SSRF controls. It does not authenticate or follow redirects. See [Remote MCP Readiness](./docs/architecture/remote-mcp-readiness.md).
|
|
94
|
+
|
|
95
|
+
Output formats:
|
|
85
96
|
|
|
86
97
|
- human text output
|
|
87
98
|
- JSON reports
|
|
@@ -427,9 +438,9 @@ jobs:
|
|
|
427
438
|
runs-on: ubuntu-latest
|
|
428
439
|
steps:
|
|
429
440
|
- uses: actions/checkout@v5
|
|
430
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
441
|
+
- uses: Esquetta/CodexPluginDoctor@v1.52.0
|
|
431
442
|
with:
|
|
432
|
-
version: "1.
|
|
443
|
+
version: "1.52.0"
|
|
433
444
|
path: .
|
|
434
445
|
runtime: "true"
|
|
435
446
|
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,240 @@
|
|
|
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
|
+
"mcp-protocol-version",
|
|
11
|
+
"mcp-session-id",
|
|
12
|
+
"user-agent"
|
|
13
|
+
]);
|
|
14
|
+
const safeResponseHeaders = new Set([
|
|
15
|
+
"content-type",
|
|
16
|
+
"www-authenticate",
|
|
17
|
+
"mcp-session-id",
|
|
18
|
+
"mcp-protocol-version",
|
|
19
|
+
"location"
|
|
20
|
+
]);
|
|
21
|
+
export class BoundedHttpError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
statusCode;
|
|
24
|
+
headers;
|
|
25
|
+
constructor(code, message, statusCode, headers) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.statusCode = statusCode;
|
|
29
|
+
this.headers = headers;
|
|
30
|
+
this.name = "BoundedHttpError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function validateHeaders(headers) {
|
|
34
|
+
const validated = {};
|
|
35
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const normalizedName = name.toLowerCase();
|
|
40
|
+
if (!allowedRequestHeaders.has(normalizedName)) {
|
|
41
|
+
throw new BoundedHttpError("REMOTE_HTTP_HEADER_FORBIDDEN", `Remote HTTP request header is not allowed: ${normalizedName}.`);
|
|
42
|
+
}
|
|
43
|
+
if (normalizedName in validated || typeof value !== "string") {
|
|
44
|
+
throw new BoundedHttpError("REMOTE_HTTP_HEADER_FORBIDDEN", `Remote HTTP request header must be a single valid string: ${normalizedName}.`);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
http.validateHeaderValue(normalizedName, value);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new BoundedHttpError("REMOTE_HTTP_HEADER_FORBIDDEN", `Remote HTTP request header must be a single valid string: ${normalizedName}.`);
|
|
51
|
+
}
|
|
52
|
+
validated[normalizedName] = value;
|
|
53
|
+
}
|
|
54
|
+
return validated;
|
|
55
|
+
}
|
|
56
|
+
function validateOptions(options) {
|
|
57
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
58
|
+
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
59
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS
|
|
60
|
+
|| !Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
|
|
61
|
+
throw new BoundedHttpError("REMOTE_HTTP_OPTIONS_INVALID", "Remote HTTP request options are invalid.");
|
|
62
|
+
}
|
|
63
|
+
return { timeoutMs, maxResponseBytes };
|
|
64
|
+
}
|
|
65
|
+
async function resolveWithinDeadline(url, options, timeoutMs) {
|
|
66
|
+
let timer;
|
|
67
|
+
try {
|
|
68
|
+
return await Promise.race([
|
|
69
|
+
resolveRemoteTarget(url, {
|
|
70
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
71
|
+
lookup: options.lookup
|
|
72
|
+
}),
|
|
73
|
+
new Promise((_resolve, reject) => {
|
|
74
|
+
timer = setTimeout(() => {
|
|
75
|
+
reject(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out."));
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
})
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
if (timer !== undefined) {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function selectSafeHeaders(headers) {
|
|
87
|
+
const selected = {};
|
|
88
|
+
for (const name of safeResponseHeaders) {
|
|
89
|
+
const value = headers[name];
|
|
90
|
+
if (value !== undefined) {
|
|
91
|
+
selected[name] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return selected;
|
|
95
|
+
}
|
|
96
|
+
function hasIdentityContentEncoding(headers) {
|
|
97
|
+
const value = headers["content-encoding"];
|
|
98
|
+
const values = Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
99
|
+
return values.every((entry) => entry.toLowerCase() === "identity");
|
|
100
|
+
}
|
|
101
|
+
function matchesTargetPeer(target, remoteAddress) {
|
|
102
|
+
if (remoteAddress === undefined || isIP(remoteAddress) === 0) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const peers = new BlockList();
|
|
106
|
+
peers.addAddress(target.address, target.family === 4 ? "ipv4" : "ipv6");
|
|
107
|
+
if (target.family === 4) {
|
|
108
|
+
peers.addAddress(`::ffff:${target.address}`, "ipv6");
|
|
109
|
+
}
|
|
110
|
+
return peers.check(remoteAddress, isIP(remoteAddress) === 4 ? "ipv4" : "ipv6");
|
|
111
|
+
}
|
|
112
|
+
export async function requestBoundedHttp(rawUrl, options = {}) {
|
|
113
|
+
let url;
|
|
114
|
+
try {
|
|
115
|
+
url = new URL(rawUrl);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new BoundedHttpError("REMOTE_HTTP_URL_UNSUPPORTED", "Remote HTTP URL must be absolute HTTP or HTTPS.");
|
|
119
|
+
}
|
|
120
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
121
|
+
throw new BoundedHttpError("REMOTE_HTTP_URL_UNSUPPORTED", "Remote HTTP URL must be absolute HTTP or HTTPS.");
|
|
122
|
+
}
|
|
123
|
+
if (url.username || url.password) {
|
|
124
|
+
throw new BoundedHttpError("REMOTE_HTTP_URL_CREDENTIALS", "Remote HTTP URL must not include credentials.");
|
|
125
|
+
}
|
|
126
|
+
const { timeoutMs, maxResponseBytes } = validateOptions(options);
|
|
127
|
+
const headers = validateHeaders(options.headers);
|
|
128
|
+
const deadline = Date.now() + timeoutMs;
|
|
129
|
+
const target = await resolveWithinDeadline(url, options, timeoutMs);
|
|
130
|
+
const remainingMs = deadline - Date.now();
|
|
131
|
+
if (remainingMs <= 0) {
|
|
132
|
+
throw new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out.");
|
|
133
|
+
}
|
|
134
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
let request;
|
|
137
|
+
let response;
|
|
138
|
+
let settled = false;
|
|
139
|
+
const timeout = setTimeout(() => {
|
|
140
|
+
fail(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out."));
|
|
141
|
+
}, remainingMs);
|
|
142
|
+
const fail = (error) => {
|
|
143
|
+
if (settled) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
settled = true;
|
|
147
|
+
clearTimeout(timeout);
|
|
148
|
+
response?.destroy();
|
|
149
|
+
request?.destroy();
|
|
150
|
+
reject(error);
|
|
151
|
+
};
|
|
152
|
+
const complete = (body) => {
|
|
153
|
+
if (settled) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
settled = true;
|
|
157
|
+
clearTimeout(timeout);
|
|
158
|
+
response?.destroy();
|
|
159
|
+
request?.destroy();
|
|
160
|
+
resolve({
|
|
161
|
+
statusCode: response?.statusCode ?? 0,
|
|
162
|
+
headers: response ? selectSafeHeaders(response.headers) : {},
|
|
163
|
+
body
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
request = transport.request({
|
|
168
|
+
protocol: url.protocol,
|
|
169
|
+
hostname: url.hostname,
|
|
170
|
+
port: url.port || undefined,
|
|
171
|
+
path: `${url.pathname}${url.search}`,
|
|
172
|
+
method: options.method ?? "GET",
|
|
173
|
+
headers,
|
|
174
|
+
agent: false,
|
|
175
|
+
lookup: (_hostname, lookupOptions, callback) => {
|
|
176
|
+
if (lookupOptions.all) {
|
|
177
|
+
callback(null, [{ address: target.address, family: target.family }]);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
callback(null, target.address, target.family);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REQUEST_FAILED", "Remote HTTP request failed."));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
request.once("error", () => {
|
|
189
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REQUEST_FAILED", "Remote HTTP request failed."));
|
|
190
|
+
});
|
|
191
|
+
request.once("response", (incoming) => {
|
|
192
|
+
response = incoming;
|
|
193
|
+
const safeHeaders = selectSafeHeaders(incoming.headers);
|
|
194
|
+
if (!matchesTargetPeer(target, incoming.socket.remoteAddress)) {
|
|
195
|
+
fail(new BoundedHttpError("REMOTE_HTTP_PEER_MISMATCH", "Remote HTTP peer did not match the resolved target."));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if ((incoming.statusCode ?? 0) >= 300 && (incoming.statusCode ?? 0) < 400) {
|
|
199
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REDIRECT", "Remote HTTP redirects are not allowed.", incoming.statusCode, safeHeaders));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!hasIdentityContentEncoding(incoming.headers)) {
|
|
203
|
+
fail(new BoundedHttpError("REMOTE_HTTP_ENCODING_UNSUPPORTED", "Remote HTTP response content encoding must be identity."));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const chunks = [];
|
|
207
|
+
let receivedBytes = 0;
|
|
208
|
+
incoming.on("data", (chunk) => {
|
|
209
|
+
receivedBytes += chunk.length;
|
|
210
|
+
if (receivedBytes > maxResponseBytes) {
|
|
211
|
+
fail(new BoundedHttpError("REMOTE_HTTP_RESPONSE_TOO_LARGE", "Remote HTTP response exceeded the configured size limit."));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
chunks.push(chunk);
|
|
215
|
+
const body = Buffer.concat(chunks);
|
|
216
|
+
let shouldStop;
|
|
217
|
+
try {
|
|
218
|
+
shouldStop = options.stopAfter?.(body) ?? false;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
fail(new BoundedHttpError("REMOTE_HTTP_STOP_CONDITION_FAILED", "Remote HTTP stop condition failed."));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (shouldStop) {
|
|
225
|
+
complete(body);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
incoming.once("error", () => {
|
|
230
|
+
fail(new BoundedHttpError("REMOTE_HTTP_REQUEST_FAILED", "Remote HTTP request failed."));
|
|
231
|
+
});
|
|
232
|
+
incoming.once("end", () => {
|
|
233
|
+
if (!settled) {
|
|
234
|
+
complete(Buffer.concat(chunks));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
request.end(options.body);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -35,6 +35,27 @@ const runtimeConformanceSchema = {
|
|
|
35
35
|
],
|
|
36
36
|
additionalProperties: false
|
|
37
37
|
};
|
|
38
|
+
const remoteRuntimeScorecardSchema = {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
transport: runtimeCapabilityStatusSchema,
|
|
42
|
+
networkSafety: runtimeCapabilityStatusSchema,
|
|
43
|
+
initialize: runtimeCapabilityStatusSchema,
|
|
44
|
+
contentType: runtimeCapabilityStatusSchema,
|
|
45
|
+
session: {
|
|
46
|
+
type: "string",
|
|
47
|
+
enum: ["absent", "present-valid", "present-invalid"]
|
|
48
|
+
},
|
|
49
|
+
protocolHeaders: runtimeCapabilityStatusSchema,
|
|
50
|
+
authorization: runtimeCapabilityStatusSchema,
|
|
51
|
+
overall: {
|
|
52
|
+
type: "string",
|
|
53
|
+
enum: ["pass", "warn", "fail", "skipped"]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
required: ["transport", "networkSafety", "initialize", "contentType", "session", "protocolHeaders", "authorization", "overall"],
|
|
57
|
+
additionalProperties: false
|
|
58
|
+
};
|
|
38
59
|
const runtimeScorecardSchema = {
|
|
39
60
|
type: "object",
|
|
40
61
|
properties: {
|
|
@@ -46,7 +67,8 @@ const runtimeScorecardSchema = {
|
|
|
46
67
|
resourceTemplatesList: runtimeCapabilityStatusSchema,
|
|
47
68
|
promptsList: runtimeCapabilityStatusSchema,
|
|
48
69
|
promptGet: runtimeCapabilityStatusSchema,
|
|
49
|
-
conformance: runtimeConformanceSchema
|
|
70
|
+
conformance: runtimeConformanceSchema,
|
|
71
|
+
remote: remoteRuntimeScorecardSchema
|
|
50
72
|
},
|
|
51
73
|
required: [
|
|
52
74
|
"initialize",
|
|
@@ -41,6 +41,8 @@ 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;
|
|
44
46
|
runtimeSandbox?: RuntimeSandboxMode;
|
|
45
47
|
runCheck?: (targetPath: string, options: CheckOptions) => Promise<CheckResult>;
|
|
46
48
|
}
|
|
@@ -106,6 +106,8 @@ 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 } : {}),
|
|
109
111
|
...(options.runtimeSandbox ? { runtimeSandbox: options.runtimeSandbox } : {})
|
|
110
112
|
});
|
|
111
113
|
const securityResult = await buildSecurityAudit(resolvedPath);
|
|
@@ -111,6 +111,8 @@ export interface BuildDoctorReleaseEvidenceOptions {
|
|
|
111
111
|
requireRuntimeApproval?: boolean;
|
|
112
112
|
runtimeApprovalDigest?: string | null;
|
|
113
113
|
runtime?: boolean;
|
|
114
|
+
allowNetwork?: boolean;
|
|
115
|
+
allowLocalNetwork?: boolean;
|
|
114
116
|
sandbox?: RuntimeSandboxMode;
|
|
115
117
|
environment?: CompatibilityEnvironment;
|
|
116
118
|
runCheck?: (targetPath: string, options?: CheckOptions) => Promise<CheckResult>;
|
|
@@ -192,17 +192,126 @@ 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 } : {}),
|
|
202
311
|
...(options.sandbox ? { runtimeSandbox: options.sandbox } : {})
|
|
203
312
|
}
|
|
204
313
|
: {};
|
|
205
|
-
const [attestation, corpus, performance, trust, packageMetadata, git
|
|
314
|
+
const [attestation, corpus, performance, trust, packageMetadata, git] = await Promise.all([
|
|
206
315
|
buildDoctorAttestation(rootPath, {
|
|
207
316
|
signingKey: options.signingKey,
|
|
208
317
|
signingKeyHint: `env:${options.signingKeyEnv}`,
|
|
@@ -216,8 +325,7 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
|
|
|
216
325
|
}),
|
|
217
326
|
buildTrustScore(rootPath, { securityAudit: security }),
|
|
218
327
|
readPackageMetadata(rootPath),
|
|
219
|
-
readGitMetadata(rootPath)
|
|
220
|
-
buildDoctorRuntimePlan(rootPath, new Date().toISOString(), options.sandbox ? { sandbox: options.sandbox } : {})
|
|
328
|
+
readGitMetadata(rootPath)
|
|
221
329
|
]);
|
|
222
330
|
const normalizedPackageMetadata = {
|
|
223
331
|
name: packageMetadata.name ?? attestation.subject.name,
|
|
@@ -229,10 +337,6 @@ export async function buildDoctorReleaseEvidenceReport(targetPath, options) {
|
|
|
229
337
|
artifactPath: "inline:doctor.release-evidence.attestation"
|
|
230
338
|
});
|
|
231
339
|
const releaseGates = buildReleaseGateReport(git, options);
|
|
232
|
-
const runtimeApproval = evaluateRuntimeApproval(runtimePlan, {
|
|
233
|
-
required: options.requireRuntimeApproval ?? false,
|
|
234
|
-
approvedDigest: options.runtimeApprovalDigest
|
|
235
|
-
});
|
|
236
340
|
const partialReport = {
|
|
237
341
|
schemaVersion: "1.0.0",
|
|
238
342
|
kind: "doctor.release.evidence",
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
}
|
|
12
|
+
export interface RemoteMcpProbeResult {
|
|
13
|
+
findings: Finding[];
|
|
14
|
+
scorecard: RemoteRuntimeScorecard;
|
|
15
|
+
}
|
|
16
|
+
export declare function probeRemoteMcpServer(serverName: string, rawUrl: string, options?: RemoteMcpProbeOptions): Promise<RemoteMcpProbeResult>;
|