codex-plugin-doctor 1.50.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.
Files changed (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +17 -6
  3. package/dist/core/bounded-http-client.d.ts +23 -0
  4. package/dist/core/bounded-http-client.js +240 -0
  5. package/dist/core/mcp-conformance.d.ts +2 -0
  6. package/dist/core/mcp-conformance.js +215 -0
  7. package/dist/core/output-contract.js +104 -5
  8. package/dist/core/release-check.d.ts +2 -0
  9. package/dist/core/release-check.js +2 -0
  10. package/dist/core/release-evidence.d.ts +2 -0
  11. package/dist/core/release-evidence.js +111 -7
  12. package/dist/core/remote-mcp-probe.d.ts +16 -0
  13. package/dist/core/remote-mcp-probe.js +226 -0
  14. package/dist/core/remote-network-policy.d.ts +24 -0
  15. package/dist/core/remote-network-policy.js +125 -0
  16. package/dist/core/remote-oauth-readiness.d.ts +12 -0
  17. package/dist/core/remote-oauth-readiness.js +295 -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 +36 -2
  22. package/dist/core/runtime-policy.js +5 -1
  23. package/dist/core/runtime-probe.d.ts +11 -2
  24. package/dist/core/runtime-probe.js +375 -70
  25. package/dist/core/runtime-transcript.js +13 -3
  26. package/dist/core/validate-plugin.js +32 -1
  27. package/dist/domain/types.d.ts +47 -0
  28. package/dist/index.d.ts +4 -0
  29. package/dist/index.js +4 -0
  30. package/dist/mcp/generic-mcp-doctor.d.ts +10 -2
  31. package/dist/mcp/generic-mcp-doctor.js +44 -13
  32. package/dist/reporting/render-markdown-report.js +44 -13
  33. package/dist/reporting/render-text-report.js +39 -22
  34. package/dist/rules/rule-catalog.js +253 -1
  35. package/dist/run-cli.js +137 -9
  36. package/dist/security/security-audit.js +34 -7
  37. package/examples/README.md +94 -94
  38. package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
  39. package/examples/codex-doctor-generic-mcp/server.js +1 -1
  40. package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
  41. package/examples/codex-doctor-risky/.mcp.json +12 -12
  42. package/examples/codex-doctor-risky/mock-server.js +1 -1
  43. package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
  44. package/examples/codex-doctor-runtime/.mcp.json +9 -9
  45. package/examples/codex-doctor-runtime/mock-server.js +237 -237
  46. package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
  47. package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
  48. package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
  49. package/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import { BlockList, isIP, SocketAddress } from "node:net";
3
+ export class RemoteNetworkPolicyError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = "RemoteNetworkPolicyError";
9
+ }
10
+ }
11
+ const blockedAddresses = new BlockList();
12
+ const loopbackAddresses = new BlockList();
13
+ const mappedIpv4Addresses = new BlockList();
14
+ const ipv4CompatibleAddresses = new BlockList();
15
+ function addIpv4Subnet(address, prefix) {
16
+ blockedAddresses.addSubnet(address, prefix, "ipv4");
17
+ blockedAddresses.addSubnet(`::ffff:${address}`, prefix + 96, "ipv6");
18
+ }
19
+ function addLoopbackSubnet(address, prefix) {
20
+ loopbackAddresses.addSubnet(address, prefix, "ipv4");
21
+ loopbackAddresses.addSubnet(`::ffff:${address}`, prefix + 96, "ipv6");
22
+ }
23
+ for (const [address, prefix] of [
24
+ ["0.0.0.0", 8],
25
+ ["10.0.0.0", 8],
26
+ ["100.64.0.0", 10],
27
+ ["169.254.0.0", 16],
28
+ ["172.16.0.0", 12],
29
+ ["192.0.0.0", 24],
30
+ ["192.0.2.0", 24],
31
+ ["192.88.99.0", 24],
32
+ ["192.168.0.0", 16],
33
+ ["198.18.0.0", 15],
34
+ ["198.51.100.0", 24],
35
+ ["203.0.113.0", 24],
36
+ ["224.0.0.0", 4],
37
+ ["240.0.0.0", 4]
38
+ ]) {
39
+ addIpv4Subnet(address, prefix);
40
+ }
41
+ addLoopbackSubnet("127.0.0.0", 8);
42
+ blockedAddresses.addAddress("100.100.100.200", "ipv4");
43
+ blockedAddresses.addAddress("100.100.100.100", "ipv4");
44
+ for (const [address, prefix] of [
45
+ ["::", 128],
46
+ ["100::", 64],
47
+ ["2001::", 23],
48
+ ["2001:db8::", 32],
49
+ ["2002::", 16],
50
+ ["3fff::", 20],
51
+ ["5f00::", 16],
52
+ ["fc00::", 7],
53
+ ["fe80::", 10],
54
+ ["ff00::", 8]
55
+ ]) {
56
+ blockedAddresses.addSubnet(address, prefix, "ipv6");
57
+ }
58
+ loopbackAddresses.addAddress("::1", "ipv6");
59
+ mappedIpv4Addresses.addSubnet("::ffff:0:0", 96, "ipv6");
60
+ ipv4CompatibleAddresses.addSubnet("::", 96, "ipv6");
61
+ for (const [address, prefix] of [
62
+ ["64:ff9b::", 96],
63
+ ["64:ff9b:1::", 48],
64
+ ["100:0:0:1::", 64]
65
+ ]) {
66
+ blockedAddresses.addSubnet(address, prefix, "ipv6");
67
+ }
68
+ function normalizeAddress(address, family) {
69
+ if ((family !== 4 && family !== 6) || isIP(address) !== family) {
70
+ throw new RemoteNetworkPolicyError("REMOTE_TARGET_INVALID_ADDRESS", "Remote target DNS resolution returned an invalid address.");
71
+ }
72
+ const normalized = new SocketAddress({
73
+ address,
74
+ port: 0,
75
+ family: family === 4 ? "ipv4" : "ipv6"
76
+ });
77
+ return { address: normalized.address, family };
78
+ }
79
+ function isListed(address, family, list) {
80
+ return list.check(address, family === 4 ? "ipv4" : "ipv6");
81
+ }
82
+ export async function resolveRemoteTarget(url, options = {}) {
83
+ const hostname = url.hostname.startsWith("[") && url.hostname.endsWith("]")
84
+ ? url.hostname.slice(1, -1)
85
+ : url.hostname;
86
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || !hostname || isIP(hostname) !== 0) {
87
+ throw new RemoteNetworkPolicyError("REMOTE_TARGET_URL_INVALID", "Remote target URL must use HTTP or HTTPS with a hostname, not an IP address literal.");
88
+ }
89
+ const lookup = options.lookup ?? (async (hostname, lookupOptions) => {
90
+ const answers = await dnsLookup(hostname, lookupOptions);
91
+ return answers.map((answer) => ({
92
+ address: answer.address,
93
+ family: answer.family
94
+ }));
95
+ });
96
+ let answers;
97
+ try {
98
+ answers = await lookup(hostname, { all: true, verbatim: true });
99
+ }
100
+ catch {
101
+ throw new RemoteNetworkPolicyError("REMOTE_TARGET_LOOKUP_FAILED", "Remote target DNS resolution failed.");
102
+ }
103
+ if (answers.length === 0) {
104
+ throw new RemoteNetworkPolicyError("REMOTE_TARGET_EMPTY", "Remote target DNS resolution returned no addresses.");
105
+ }
106
+ const normalizedAnswers = answers.map((answer) => normalizeAddress(answer.address, answer.family));
107
+ for (const answer of normalizedAnswers) {
108
+ const isLoopback = isListed(answer.address, answer.family, loopbackAddresses);
109
+ const isMappedIpv4 = answer.family === 6 && isListed(answer.address, answer.family, mappedIpv4Addresses);
110
+ const isIpv4Compatible = answer.family === 6 && isListed(answer.address, answer.family, ipv4CompatibleAddresses);
111
+ const isForbidden = isListed(answer.address, answer.family, blockedAddresses)
112
+ || isIpv4Compatible
113
+ || (isMappedIpv4 && !isLoopback);
114
+ if ((isForbidden && !isLoopback) || (isLoopback && !options.allowLocalNetwork)) {
115
+ throw new RemoteNetworkPolicyError("REMOTE_TARGET_FORBIDDEN", "Remote target resolved to a forbidden address.");
116
+ }
117
+ }
118
+ const selected = normalizedAnswers[0];
119
+ return {
120
+ hostname,
121
+ address: selected.address,
122
+ family: selected.family,
123
+ local: isListed(selected.address, selected.family, loopbackAddresses)
124
+ };
125
+ }
@@ -0,0 +1,12 @@
1
+ import { type BoundedHttpRequestOptions, type BoundedHttpResponse } from "./bounded-http-client.js";
2
+ import type { Finding } from "../domain/types.js";
3
+ export type RemoteOAuthReadinessRequest = (rawUrl: string, options?: BoundedHttpRequestOptions) => Promise<BoundedHttpResponse>;
4
+ export interface RemoteOAuthReadinessOptions {
5
+ request?: RemoteOAuthReadinessRequest;
6
+ requestOptions?: Pick<BoundedHttpRequestOptions, "allowLocalNetwork" | "lookup" | "timeoutMs">;
7
+ }
8
+ export interface RemoteOAuthReadinessResult {
9
+ status: "pass" | "fail";
10
+ findings: Finding[];
11
+ }
12
+ export declare function checkRemoteOAuthReadiness(resourceUrl: string, wwwAuthenticate: Array<string | string[]>, options?: RemoteOAuthReadinessOptions): Promise<RemoteOAuthReadinessResult>;
@@ -0,0 +1,295 @@
1
+ import { isIP } from "node:net";
2
+ import { requestBoundedHttp } from "./bounded-http-client.js";
3
+ const MAX_DISCOVERY_CANDIDATES = 4;
4
+ const MAX_DISCOVERY_TIMEOUT_MS = 3_000;
5
+ function failure(id) {
6
+ return {
7
+ status: "fail",
8
+ findings: [{
9
+ id,
10
+ severity: "fail",
11
+ message: "The remote MCP server authorization metadata could not be validated.",
12
+ impact: "Protected MCP endpoints cannot be safely assessed without valid OAuth discovery metadata.",
13
+ suggestedFix: "Publish valid HTTPS protected-resource and authorization-server metadata without credentials, queries, or fragments."
14
+ }]
15
+ };
16
+ }
17
+ function isPlainObject(value) {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+ function mediaType(headers) {
21
+ const value = headers["content-type"];
22
+ const source = Array.isArray(value) ? value[0] : value;
23
+ return typeof source === "string" ? source.split(";", 1)[0]?.trim().toLowerCase() ?? null : null;
24
+ }
25
+ function parseJsonObject(body) {
26
+ try {
27
+ const parsed = JSON.parse(body.toString("utf8"));
28
+ return isPlainObject(parsed) ? parsed : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function safeHttpsUrl(value) {
35
+ if (typeof value !== "string")
36
+ return null;
37
+ try {
38
+ const url = new URL(value);
39
+ if (url.protocol !== "https:" ||
40
+ !url.hostname ||
41
+ url.username ||
42
+ url.password ||
43
+ url.search ||
44
+ url.hash ||
45
+ url.hostname.startsWith("[") ||
46
+ isIP(url.hostname) !== 0) {
47
+ return null;
48
+ }
49
+ return value;
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function insertWellKnown(resourceUrl, suffix) {
56
+ const url = new URL(resourceUrl);
57
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
58
+ return `${url.origin}/.well-known/${suffix}${path}`;
59
+ }
60
+ function oidcDiscoveryUrl(issuer) {
61
+ const url = new URL(issuer);
62
+ return `${url.origin}${url.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`;
63
+ }
64
+ function splitTopLevelCommas(value) {
65
+ const parts = [];
66
+ let start = 0;
67
+ let quoted = false;
68
+ for (let index = 0; index < value.length; index += 1) {
69
+ const character = value[index];
70
+ if (quoted && character === "\\") {
71
+ index += 1;
72
+ if (index >= value.length)
73
+ return null;
74
+ continue;
75
+ }
76
+ if (character === "\"") {
77
+ quoted = !quoted;
78
+ continue;
79
+ }
80
+ if (!quoted && character === ",") {
81
+ parts.push(value.slice(start, index));
82
+ start = index + 1;
83
+ }
84
+ }
85
+ return quoted ? null : [...parts, value.slice(start)];
86
+ }
87
+ function parseAuthParam(value) {
88
+ const match = /^([!#$%&'*+.^`|~\w-]+)[ \t]*=[ \t]*(.*)$/.exec(value);
89
+ if (match === null)
90
+ return null;
91
+ const name = match[1];
92
+ const rawValue = match[2].trim();
93
+ if (rawValue.length === 0)
94
+ return null;
95
+ if (!rawValue.startsWith("\"")) {
96
+ return /[\s"]/u.test(rawValue) ? null : { name, value: rawValue };
97
+ }
98
+ if (!rawValue.endsWith("\"") || rawValue.length === 1)
99
+ return null;
100
+ let decoded = "";
101
+ for (let index = 1; index < rawValue.length - 1; index += 1) {
102
+ const character = rawValue[index];
103
+ if (character === "\\") {
104
+ index += 1;
105
+ if (index >= rawValue.length - 1)
106
+ return null;
107
+ decoded += rawValue[index];
108
+ continue;
109
+ }
110
+ if (character === "\"")
111
+ return null;
112
+ decoded += character;
113
+ }
114
+ return { name, value: decoded };
115
+ }
116
+ function parseChallenge(value) {
117
+ const match = /^([!#$%&'*+.^`|~\w-]+)(?:[ \t]+(.*))?$/.exec(value);
118
+ if (match === null)
119
+ return null;
120
+ const scheme = match[1];
121
+ const credentials = match[2];
122
+ if (credentials === undefined || credentials.trim().length === 0)
123
+ return { scheme, parameter: null };
124
+ const parameter = parseAuthParam(credentials);
125
+ if (parameter !== null)
126
+ return { scheme, parameter };
127
+ return /^[A-Za-z0-9\-._~+/]+={0,}$/.test(credentials) ? { scheme, parameter: null } : null;
128
+ }
129
+ function parseBearerResourceMetadata(headers) {
130
+ const values = [];
131
+ let specified = false;
132
+ for (const header of headers.flatMap((value) => Array.isArray(value) ? value : [value])) {
133
+ const parts = splitTopLevelCommas(header);
134
+ if (parts === null)
135
+ return null;
136
+ let scheme = null;
137
+ for (const part of parts) {
138
+ const token = part.trim();
139
+ if (token.length === 0)
140
+ return null;
141
+ const parameter = parseAuthParam(token);
142
+ if (parameter !== null) {
143
+ if (scheme === null)
144
+ return null;
145
+ if (scheme.toLowerCase() === "bearer" && parameter.name.toLowerCase() === "resource_metadata") {
146
+ specified = true;
147
+ values.push(parameter.value);
148
+ }
149
+ continue;
150
+ }
151
+ const challenge = parseChallenge(token);
152
+ if (challenge === null)
153
+ return null;
154
+ scheme = challenge.scheme;
155
+ if (scheme.toLowerCase() === "bearer" && challenge.parameter?.name.toLowerCase() === "resource_metadata") {
156
+ specified = true;
157
+ values.push(challenge.parameter.value);
158
+ }
159
+ }
160
+ }
161
+ return { specified, values };
162
+ }
163
+ async function fetchMetadata(url, request, requestOptions, deadline) {
164
+ const remainingMs = deadline - Date.now();
165
+ if (remainingMs <= 0)
166
+ return { kind: "unavailable" };
167
+ let timeout;
168
+ try {
169
+ const response = await Promise.race([
170
+ request(url, {
171
+ ...requestOptions,
172
+ timeoutMs: Math.max(1, Math.floor(remainingMs)),
173
+ method: "GET",
174
+ headers: { Accept: "application/json" }
175
+ }),
176
+ new Promise((_resolve, reject) => {
177
+ timeout = setTimeout(() => reject(new Error("OAuth discovery timed out.")), remainingMs);
178
+ })
179
+ ]);
180
+ if (response.statusCode === 404)
181
+ return { kind: "not-found" };
182
+ if (response.statusCode !== 200 || mediaType(response.headers) !== "application/json") {
183
+ return { kind: "unavailable" };
184
+ }
185
+ const metadata = parseJsonObject(response.body);
186
+ return metadata === null ? { kind: "invalid" } : { kind: "ok", metadata };
187
+ }
188
+ catch {
189
+ return { kind: "unavailable" };
190
+ }
191
+ finally {
192
+ if (timeout !== undefined)
193
+ clearTimeout(timeout);
194
+ }
195
+ }
196
+ function validProtectedResourceMetadata(metadata, resourceUrl) {
197
+ if (metadata.resource !== resourceUrl || !Array.isArray(metadata.authorization_servers) || metadata.authorization_servers.length === 0) {
198
+ return null;
199
+ }
200
+ const issuers = metadata.authorization_servers.map(safeHttpsUrl);
201
+ if (!issuers.every((issuer) => issuer !== null))
202
+ return null;
203
+ const uniqueIssuers = [...new Set(issuers)];
204
+ return uniqueIssuers.length <= MAX_DISCOVERY_CANDIDATES ? uniqueIssuers : null;
205
+ }
206
+ function validAuthorizationServerMetadata(metadata, issuer) {
207
+ return (metadata.issuer === issuer &&
208
+ safeHttpsUrl(metadata.authorization_endpoint) !== null &&
209
+ safeHttpsUrl(metadata.token_endpoint) !== null);
210
+ }
211
+ async function authorizationServerIsReady(issuer, request, requestOptions, deadline) {
212
+ const rfc8414 = await fetchMetadata(insertWellKnown(issuer, "oauth-authorization-server"), request, requestOptions, deadline);
213
+ if (rfc8414.kind === "ok")
214
+ return validAuthorizationServerMetadata(rfc8414.metadata, issuer) ? "pass" : "invalid";
215
+ if (rfc8414.kind === "unavailable")
216
+ return "unavailable";
217
+ const oidcUrls = [...new Set([
218
+ insertWellKnown(issuer, "openid-configuration"),
219
+ oidcDiscoveryUrl(issuer)
220
+ ])];
221
+ for (const oidcUrl of oidcUrls) {
222
+ const oidc = await fetchMetadata(oidcUrl, request, requestOptions, deadline);
223
+ if (oidc.kind === "ok")
224
+ return validAuthorizationServerMetadata(oidc.metadata, issuer) ? "pass" : "invalid";
225
+ if (oidc.kind === "unavailable")
226
+ return "unavailable";
227
+ }
228
+ return "invalid";
229
+ }
230
+ export async function checkRemoteOAuthReadiness(resourceUrl, wwwAuthenticate, options = {}) {
231
+ const request = options.request ?? requestBoundedHttp;
232
+ const explicit = parseBearerResourceMetadata(wwwAuthenticate);
233
+ if (explicit === null)
234
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
235
+ const metadataCandidates = explicit.specified
236
+ ? explicit.values.map(safeHttpsUrl)
237
+ : [
238
+ insertWellKnown(resourceUrl, "oauth-protected-resource"),
239
+ `${new URL(resourceUrl).origin}/.well-known/oauth-protected-resource`
240
+ ].map(safeHttpsUrl);
241
+ if (!metadataCandidates.every((url) => url !== null)) {
242
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
243
+ }
244
+ const metadataUrls = [...new Set(metadataCandidates)];
245
+ if (metadataUrls.length > MAX_DISCOVERY_CANDIDATES)
246
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
247
+ const suppliedTimeoutMs = options.requestOptions?.timeoutMs;
248
+ const timeoutMs = typeof suppliedTimeoutMs === "number" && Number.isFinite(suppliedTimeoutMs)
249
+ ? Math.max(0, Math.min(suppliedTimeoutMs, MAX_DISCOVERY_TIMEOUT_MS))
250
+ : MAX_DISCOVERY_TIMEOUT_MS;
251
+ const deadline = Date.now() + timeoutMs;
252
+ let issuers = null;
253
+ let protectedMetadataFetchedInvalid = false;
254
+ let protectedMetadataUnavailable = false;
255
+ for (const metadataUrl of metadataUrls) {
256
+ const reply = await fetchMetadata(metadataUrl, request, options.requestOptions, deadline);
257
+ if (reply.kind === "ok") {
258
+ const candidateIssuers = validProtectedResourceMetadata(reply.metadata, resourceUrl);
259
+ if (candidateIssuers !== null) {
260
+ issuers = candidateIssuers;
261
+ break;
262
+ }
263
+ protectedMetadataFetchedInvalid = true;
264
+ if (!explicit.specified)
265
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
266
+ continue;
267
+ }
268
+ if (reply.kind === "invalid") {
269
+ protectedMetadataFetchedInvalid = true;
270
+ if (!explicit.specified)
271
+ return failure("plugin.runtime.remote.authorization.metadata.invalid");
272
+ continue;
273
+ }
274
+ protectedMetadataUnavailable = true;
275
+ if (!explicit.specified && reply.kind === "unavailable") {
276
+ return failure("plugin.runtime.remote.authorization.metadata.unavailable");
277
+ }
278
+ }
279
+ if (issuers === null)
280
+ return failure(protectedMetadataFetchedInvalid
281
+ ? "plugin.runtime.remote.authorization.metadata.invalid"
282
+ : protectedMetadataUnavailable
283
+ ? "plugin.runtime.remote.authorization.metadata.unavailable"
284
+ : "plugin.runtime.remote.authorization.metadata.invalid");
285
+ let unavailable = false;
286
+ for (const issuer of issuers) {
287
+ const readiness = await authorizationServerIsReady(issuer, request, options.requestOptions, deadline);
288
+ if (readiness === "pass")
289
+ return { status: "pass", findings: [] };
290
+ unavailable ||= readiness === "unavailable";
291
+ }
292
+ return failure(unavailable
293
+ ? "plugin.runtime.remote.authorization.metadata.unavailable"
294
+ : "plugin.runtime.remote.authorization.metadata.invalid");
295
+ }
@@ -0,0 +1,7 @@
1
+ export interface RemoteUrlInspection {
2
+ parsedUrl: URL | null;
3
+ sanitizedUrl: string | null;
4
+ isLoopbackHost: boolean;
5
+ issues: Array<"invalid" | "unsupported_scheme" | "credentials" | "query" | "fragment" | "ip_literal" | "insecure_non_loopback">;
6
+ }
7
+ export declare function inspectRemoteMcpUrl(rawUrl: string): RemoteUrlInspection;
@@ -0,0 +1,49 @@
1
+ function isIpLiteral(hostname) {
2
+ return (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) ||
3
+ /^\[[0-9a-f:.]+\]$/i.test(hostname));
4
+ }
5
+ export function inspectRemoteMcpUrl(rawUrl) {
6
+ let parsedUrl;
7
+ try {
8
+ parsedUrl = new URL(rawUrl);
9
+ }
10
+ catch {
11
+ return {
12
+ parsedUrl: null,
13
+ sanitizedUrl: null,
14
+ isLoopbackHost: false,
15
+ issues: ["invalid"]
16
+ };
17
+ }
18
+ const issues = [];
19
+ const isSupportedScheme = parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
20
+ const isAbsoluteHttpUrl = /^https?:\/\//i.test(rawUrl);
21
+ const isLoopbackHost = parsedUrl.hostname.toLowerCase() === "localhost";
22
+ if (!isSupportedScheme) {
23
+ issues.push("unsupported_scheme");
24
+ }
25
+ else if (!isAbsoluteHttpUrl || !parsedUrl.hostname) {
26
+ issues.push("invalid");
27
+ }
28
+ if (parsedUrl.username || parsedUrl.password) {
29
+ issues.push("credentials");
30
+ }
31
+ if (parsedUrl.search || rawUrl.includes("?")) {
32
+ issues.push("query");
33
+ }
34
+ if (parsedUrl.hash || rawUrl.includes("#")) {
35
+ issues.push("fragment");
36
+ }
37
+ if (isIpLiteral(parsedUrl.hostname)) {
38
+ issues.push("ip_literal");
39
+ }
40
+ if (parsedUrl.protocol === "http:" && !isLoopbackHost) {
41
+ issues.push("insecure_non_loopback");
42
+ }
43
+ return {
44
+ parsedUrl,
45
+ sanitizedUrl: `${parsedUrl.origin}${parsedUrl.pathname}`,
46
+ isLoopbackHost,
47
+ issues
48
+ };
49
+ }
@@ -4,6 +4,7 @@ import type { RuntimeExecutionEvidence, RuntimeSandboxMode } from "../domain/typ
4
4
  type RuntimePlanStatus = "pass" | "warn" | "fail";
5
5
  type RuntimePlanRiskLevel = "low" | "medium" | "high";
6
6
  type RuntimePlanTransport = "stdio" | "http";
7
+ type RemoteNetworkClass = "public_https" | "loopback_http" | "loopback_https" | "invalid";
7
8
  export interface RuntimePlanServer {
8
9
  name: string;
9
10
  transport: RuntimePlanTransport;
@@ -11,7 +12,9 @@ export interface RuntimePlanServer {
11
12
  args: string[];
12
13
  cwd: string | null;
13
14
  url: string | null;
15
+ networkClass?: RemoteNetworkClass;
14
16
  probeMethods: string[];
17
+ approvalRequirements?: string[];
15
18
  riskLevel: RuntimePlanRiskLevel;
16
19
  riskReasons: string[];
17
20
  }
@@ -5,6 +5,7 @@ import { discoverPackage } from "./discover-package.js";
5
5
  import { readJsonFile } from "./read-json-file.js";
6
6
  import { buildSecurityAudit } from "../security/security-audit.js";
7
7
  import { DOCKER_RUNTIME_IMAGE } from "./runtime-sandbox.js";
8
+ import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
8
9
  function isPlainObject(value) {
9
10
  return typeof value === "object" && value !== null && !Array.isArray(value);
10
11
  }
@@ -47,6 +48,26 @@ function buildRisk(serverName, serverConfig, findings) {
47
48
  riskReasons: [...new Set(riskReasons)]
48
49
  };
49
50
  }
51
+ function remoteProbeMethods() {
52
+ return [
53
+ "POST initialize",
54
+ "POST notifications/initialized",
55
+ "GET OAuth protected-resource metadata (401 only)",
56
+ "GET OAuth authorization-server metadata (401 only)"
57
+ ];
58
+ }
59
+ function remoteNetworkClass(rawUrl) {
60
+ const inspection = inspectRemoteMcpUrl(rawUrl);
61
+ if (inspection.parsedUrl === null ||
62
+ (inspection.parsedUrl.protocol !== "http:" && inspection.parsedUrl.protocol !== "https:") ||
63
+ !inspection.parsedUrl.hostname) {
64
+ return "invalid";
65
+ }
66
+ if (inspection.isLoopbackHost) {
67
+ return inspection.parsedUrl.protocol === "http:" ? "loopback_http" : "loopback_https";
68
+ }
69
+ return "public_https";
70
+ }
50
71
  function planDigestPayload(plan) {
51
72
  return {
52
73
  schemaVersion: plan.schemaVersion,
@@ -119,6 +140,8 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
119
140
  const command = typeof serverConfig.command === "string" ? serverConfig.command : null;
120
141
  const url = typeof serverConfig.url === "string" ? serverConfig.url : null;
121
142
  const { riskLevel, riskReasons } = buildRisk(serverName, serverConfig, security.findings);
143
+ const networkClass = url ? remoteNetworkClass(url) : undefined;
144
+ const sanitizedUrl = url ? inspectRemoteMcpUrl(url).sanitizedUrl : null;
122
145
  return {
123
146
  name: serverName,
124
147
  transport: command ? "stdio" : "http",
@@ -127,19 +150,28 @@ export async function buildDoctorRuntimePlan(targetPath, generatedAt = new Date(
127
150
  ? serverConfig.args.filter((arg) => typeof arg === "string")
128
151
  : [],
129
152
  cwd: command ? normalizeCwd(discoveredPackage.rootPath, serverConfig.cwd) : null,
130
- url,
153
+ url: sanitizedUrl,
154
+ ...(networkClass ? { networkClass } : {}),
131
155
  probeMethods: command
132
156
  ? [
133
157
  "initialize",
134
158
  "tools/list",
135
159
  "tools/call:safe-only",
160
+ "tasks/list:declared-2025-11-only",
136
161
  "resources/list",
137
162
  "resources/read:first-resource-only",
138
163
  "resources/templates/list",
139
164
  "prompts/list",
140
165
  "prompts/get:first-prompt-only"
141
166
  ]
142
- : [],
167
+ : url ? remoteProbeMethods() : [],
168
+ ...(url
169
+ ? {
170
+ approvalRequirements: networkClass === "loopback_http" || networkClass === "loopback_https"
171
+ ? ["--runtime", "--allow-network", "--allow-local-network"]
172
+ : ["--runtime", "--allow-network"]
173
+ }
174
+ : {}),
143
175
  riskLevel,
144
176
  riskReasons
145
177
  };
@@ -244,7 +276,9 @@ export function renderDoctorRuntimePlanMarkdown(plan) {
244
276
  "## Execution Boundary",
245
277
  "",
246
278
  "- This plan is non-executing.",
279
+ "- Probe methods explicitly exclude task create, get, result, and cancel operations, plus sampling and elicitation requests.",
247
280
  "- Runtime probes require explicit operator approval before local MCP servers are started.",
281
+ "- Any remote target whose DNS resolves to loopback requires `--allow-local-network`, even if its URL looks public.",
248
282
  "- The approval digest changes when command, args, cwd, probe methods, risk reasons, or findings change.",
249
283
  "- Runtime approval is a review gate, not an OS, VM, or container sandbox.",
250
284
  "",
@@ -38,7 +38,8 @@ function strongestDecision(decisions) {
38
38
  return "allow";
39
39
  }
40
40
  function buildRecommendation(plan, decision, reasons) {
41
- if (plan.summary.executableServerCount === 0) {
41
+ const hasRemoteServer = plan.servers.some((server) => server.transport === "http");
42
+ if (plan.summary.executableServerCount === 0 && !hasRemoteServer) {
42
43
  return {
43
44
  decision: "allow",
44
45
  reason: "No executable local MCP runtime servers were found.",
@@ -78,6 +79,9 @@ function buildRecommendation(plan, decision, reasons) {
78
79
  actions: [
79
80
  "Review command, args, cwd, URL, probe methods, and risk reasons before execution.",
80
81
  "Approve the exact plan digest with `check --runtime --require-runtime-approval --runtime-approval-digest <digest>`.",
82
+ ...(hasRemoteServer
83
+ ? ["Remote MCP probing also requires `--runtime --allow-network`; any target that DNS resolves to loopback additionally requires `--allow-local-network`."]
84
+ : []),
81
85
  "Use `doctor runtime-plan --markdown` when the approval needs to be preserved with release evidence."
82
86
  ]
83
87
  };
@@ -1,6 +1,15 @@
1
1
  import type { DiscoveredPackage, RuntimeProbeResult, RuntimeSandboxMode } from "../domain/types.js";
2
- export declare function probeRuntime(discoveredPackage: DiscoveredPackage, options?: {
2
+ import { type RemoteMcpRequest } from "./remote-mcp-probe.js";
3
+ import type { RemoteLookup } from "./remote-network-policy.js";
4
+ export declare function probeRuntime(discoveredPackage: DiscoveredPackage, options?: RuntimeProbeOptions): Promise<RuntimeProbeResult>;
5
+ export interface RuntimeProbeOptions {
3
6
  startupTimeoutMs?: number;
4
7
  sandbox?: RuntimeSandboxMode;
5
8
  transcript?: (line: string) => void;
6
- }): Promise<RuntimeProbeResult>;
9
+ allowNetwork?: boolean;
10
+ allowLocalNetwork?: boolean;
11
+ remoteRequestTimeoutMs?: number;
12
+ remoteLookup?: RemoteLookup;
13
+ remoteRequest?: RemoteMcpRequest;
14
+ }
15
+ export declare function probeRuntimeConfig(rootPath: string, mcpConfigPath: string, options?: RuntimeProbeOptions): Promise<RuntimeProbeResult>;