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.
Files changed (38) hide show
  1. package/README.md +17 -2
  2. package/dist/core/bounded-http-client.d.ts +23 -0
  3. package/dist/core/bounded-http-client.js +241 -0
  4. package/dist/core/doctor-export-bundle.js +3 -1
  5. package/dist/core/output-contract.js +50 -1
  6. package/dist/core/release-check.d.ts +4 -0
  7. package/dist/core/release-check.js +4 -0
  8. package/dist/core/release-evidence.d.ts +4 -0
  9. package/dist/core/release-evidence.js +113 -7
  10. package/dist/core/remote-mcp-probe.d.ts +17 -0
  11. package/dist/core/remote-mcp-probe.js +284 -0
  12. package/dist/core/remote-network-policy.d.ts +24 -0
  13. package/dist/core/remote-network-policy.js +125 -0
  14. package/dist/core/remote-oauth-readiness.d.ts +12 -0
  15. package/dist/core/remote-oauth-readiness.js +295 -0
  16. package/dist/core/remote-transport-reliability.d.ts +18 -0
  17. package/dist/core/remote-transport-reliability.js +243 -0
  18. package/dist/core/remote-url-policy.d.ts +7 -0
  19. package/dist/core/remote-url-policy.js +49 -0
  20. package/dist/core/runtime-plan.d.ts +3 -0
  21. package/dist/core/runtime-plan.js +34 -2
  22. package/dist/core/runtime-policy.js +5 -1
  23. package/dist/core/runtime-probe.d.ts +10 -1
  24. package/dist/core/runtime-probe.js +89 -29
  25. package/dist/core/sse-observation.d.ts +7 -0
  26. package/dist/core/sse-observation.js +67 -0
  27. package/dist/core/validate-plugin.js +35 -3
  28. package/dist/domain/types.d.ts +26 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.js +4 -0
  31. package/dist/mcp/generic-mcp-doctor.d.ts +4 -0
  32. package/dist/mcp/generic-mcp-doctor.js +15 -5
  33. package/dist/reporting/render-markdown-report.js +38 -12
  34. package/dist/reporting/render-text-report.js +32 -10
  35. package/dist/rules/rule-catalog.js +289 -1
  36. package/dist/run-cli.js +172 -5
  37. package/dist/security/security-audit.js +34 -7
  38. 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.51.0
445
+ - uses: Esquetta/CodexPluginDoctor@v1.53.0
431
446
  with:
432
- version: "1.51.0"
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
- redactValue(nestedValue)
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, runtimePlan] = await Promise.all([
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>;