From 6cca39b4758ec0fee009d7f0e36e00ce557d26de Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 7 Aug 2026 11:04:11 -0400 Subject: [PATCH 1/4] fix(mcp): bind private endpoint policy authority Signed-off-by: Julie Yaunches --- agents/hermes/mcp-config-transaction.py | 62 ++---- .../managed-dcode-runtime.py | 40 +--- .../actions/sandbox/mcp-bridge-add-restart.ts | 8 +- .../sandbox/mcp-bridge-input-targets.test.ts | 68 +++++- .../sandbox/mcp-bridge-policy-render.ts | 24 +-- .../actions/sandbox/mcp-bridge-policy.test.ts | 144 +++++++++++-- src/lib/actions/sandbox/mcp-bridge-policy.ts | 77 ++++--- .../mcp-bridge-private-lifecycle.test.ts | 35 ++- .../sandbox/mcp-bridge-provider-inspection.ts | 4 +- .../sandbox/mcp-bridge-url-validation.ts | 112 ++++++++-- src/lib/adapters/http/curl-args.test.ts | 77 ++++++- src/lib/adapters/http/curl-args.ts | 54 ++--- src/lib/adapters/http/probe.ts | 2 +- .../compatible-endpoint-context.test.ts | 16 +- .../inference/compatible-endpoint-context.ts | 2 +- .../inference/endpoint-ssrf-preflight.test.ts | 21 +- src/lib/inference/endpoint-ssrf-preflight.ts | 28 ++- src/lib/inference/probe-anthropic.ts | 2 +- .../inference-selection-validation.test.ts | 5 +- .../onboard/inference-selection-validation.ts | 2 +- .../machine/handlers/provider-inference.ts | 2 +- src/lib/onboard/setup-nim-selection.ts | 2 +- .../policy/trusted-private-endpoints.test.ts | 23 +- src/lib/policy/trusted-private-endpoints.ts | 67 +++--- .../security/trusted-private-endpoint.test.ts | 92 +++++++- src/lib/security/trusted-private-endpoint.ts | 203 ++++++++++++------ src/lib/shields/mcp-policy-transition.test.ts | 19 +- src/lib/state/registry-mcp.ts | 28 ++- test/helpers/shields-flow-harness.ts | 2 +- test/hermes-mcp-config-transaction.test.ts | 9 - ...rmes-mcp-private-target-validation.test.ts | 80 +++++++ test/hermes-mcp-shields-order.test.ts | 2 +- ...pagents-code-managed-mcp-hardening.test.ts | 55 +++++ test/mcp-destroy-lifecycle.test.ts | 9 +- test/mcp-policy-key-ownership.test.ts | 4 +- test/mcp-policy-transition.test.ts | 6 +- test/mcp-restart-policy-order.test.ts | 8 +- 37 files changed, 1022 insertions(+), 372 deletions(-) create mode 100644 test/hermes-mcp-private-target-validation.test.ts diff --git a/agents/hermes/mcp-config-transaction.py b/agents/hermes/mcp-config-transaction.py index 9244a7dc4f3..9bc278172c8 100755 --- a/agents/hermes/mcp-config-transaction.py +++ b/agents/hermes/mcp-config-transaction.py @@ -55,6 +55,9 @@ SERVICE_MANAGER_PATH = b"/usr/local/bin/nemoclaw-start" RELOAD_TIMEOUT_SECONDS = 300 SERVER_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$") +MCP_DNS_LABEL_RE = re.compile( + r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$" +) ENV_PLACEHOLDER_RE = re.compile( r"^Bearer openshell:resolve:env:([A-Za-z_][A-Za-z0-9_]{0,127})$" ) @@ -83,29 +86,6 @@ MCP_RACE_RECOVERY_ATTEMPTS = 3 GATEWAY_INTERNAL_PORT = 18642 GATEWAY_PUBLIC_PORT = 8642 -BLOCKED_IPV4_NETWORKS = tuple( - ipaddress.ip_network(cidr) - for cidr in ( - "0.0.0.0/8", - "10.0.0.0/8", - "100.64.0.0/10", - "127.0.0.0/8", - "169.254.0.0/16", - "172.16.0.0/12", - "192.0.0.0/24", - "192.0.2.0/24", - "192.31.196.0/24", - "192.52.193.0/24", - "192.88.99.0/24", - "192.168.0.0/16", - "192.175.48.0/24", - "198.18.0.0/15", - "198.51.100.0/24", - "203.0.113.0/24", - "224.0.0.0/4", - "240.0.0.0/4", - ) -) TRUSTED_HERMES_GATEWAY_LAUNCHERS = { b"/usr/local/bin/hermes.real", b"/usr/local/lib/nemoclaw/hermes", @@ -310,7 +290,7 @@ def _validate_payload(action: str, payload: dict[str, object]) -> None: raise ValueError("MCP mutation payload URL contains forbidden components") hostname = parsed.hostname.lower().rstrip(".") # Fail closed on every IPv6 literal, including globally routable addresses, - # before the IPv4-only classification below. DNS names are resolved and + # before the numeric-host handling below. DNS names are resolved and # validated by the host boundary, then pinned into OpenShell allowed_ips; # this in-sandbox transaction never establishes the network connection. if ":" in hostname: @@ -332,28 +312,26 @@ def _validate_payload(action: str, payload: dict[str, object]) -> None: raise ValueError( "Authenticated MCP OpenShell host aliases are unavailable with OpenShell v0.0.85" ) - if not (action == "remove" and hostname in host_aliases) and ( - hostname in {"localhost", "local", "internal", "metadata"} - or any( - hostname.endswith(f".{suffix}") - for suffix in ("localhost", "local", "internal", "metadata") - ) - ): - raise ValueError("MCP mutation payload URL uses a reserved hostname") + # Host preflight owns destination trust and binds every accepted endpoint to + # exact OpenShell address pins. Parse IP literals here only to distinguish a + # canonical address from an ambiguous numeric hostname. try: address = ipaddress.ip_address(hostname) except ValueError: address = None - if address is None and re.fullmatch( - r"(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+))*", - hostname, - ): - raise ValueError("MCP mutation payload URL uses an ambiguous numeric host") - if address is not None and ( - not address.is_global - or any(address in network for network in BLOCKED_IPV4_NETWORKS) - ): - raise ValueError("MCP mutation payload URL uses a non-global address") + if address is None: + if re.fullmatch( + r"(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+))*", + hostname, + ): + raise ValueError("MCP mutation payload URL uses an ambiguous numeric host") + if len(hostname) > 253 or any( + MCP_DNS_LABEL_RE.fullmatch(label) is None + for label in hostname.split(".") + ): + raise ValueError( + "MCP mutation payload URL hostname must use canonical DNS labels" + ) path = parsed.path or "/" path_segments = path.split("/") if ( diff --git a/agents/langchain-deepagents-code/managed-dcode-runtime.py b/agents/langchain-deepagents-code/managed-dcode-runtime.py index 415b93d8ab2..1181da91a89 100644 --- a/agents/langchain-deepagents-code/managed-dcode-runtime.py +++ b/agents/langchain-deepagents-code/managed-dcode-runtime.py @@ -155,30 +155,6 @@ "host.docker.internal", "host.containers.internal", } -_MCP_RESERVED_NAMES = {"localhost", "local", "internal", "metadata"} -_MCP_BLOCKED_IPV4_NETWORKS = tuple( - ipaddress.ip_network(network) - for network in ( - "0.0.0.0/8", - "10.0.0.0/8", - "100.64.0.0/10", - "127.0.0.0/8", - "169.254.0.0/16", - "172.16.0.0/12", - "192.0.0.0/24", - "192.0.2.0/24", - "192.31.196.0/24", - "192.52.193.0/24", - "192.88.99.0/24", - "192.168.0.0/16", - "192.175.48.0/24", - "198.18.0.0/15", - "198.51.100.0/24", - "203.0.113.0/24", - "224.0.0.0/4", - "240.0.0.0/4", - ) -) _MANAGED_MCP_FD: int | None = None _MANAGED_MCP_BINDING: dict[str, int | str] | None = None _MANAGED_MCP_CHILD_BINDING: dict[str, int | str] | None = None @@ -366,13 +342,11 @@ def _validate_managed_mcp_hostname(hostname: str) -> None: hostname != hostname.lower() or hostname.endswith(".") or hostname in _MCP_BLOCKED_ALIASES - or hostname in _MCP_RESERVED_NAMES - or any( - hostname.endswith(f".{reserved}") - for reserved in _MCP_RESERVED_NAMES - ) ): raise RuntimeError("managed MCP server URL hostname is invalid") + # Host preflight owns destination trust and binds every accepted endpoint to + # exact OpenShell address pins. This runtime revalidates canonical syntax, + # but it does not classify an already-admitted IPv4 or DNS destination. try: address = ipaddress.ip_address(hostname) except ValueError: @@ -383,12 +357,8 @@ def _validate_managed_mcp_hostname(hostname: str) -> None: ): raise RuntimeError("managed MCP server URL hostname is invalid") return - if ( - address.version != 4 - or not address.is_global - or any(address in network for network in _MCP_BLOCKED_IPV4_NETWORKS) - ): - raise RuntimeError("managed MCP server URL address is not public IPv4") + if address.version != 4: + raise RuntimeError("managed MCP server URL does not support IPv6 literals") def _validate_managed_mcp_url(value: object) -> str: diff --git a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts index 77b4a0e5cdc..6f3a7711f48 100644 --- a/src/lib/actions/sandbox/mcp-bridge-add-restart.ts +++ b/src/lib/actions/sandbox/mcp-bridge-add-restart.ts @@ -123,12 +123,7 @@ function assertPreparedMcpAddResourcesAbsent( `MCP add preflight for '${entry.server}' found an existing policy ownership record '${entry.policyName}'. The durable add manifest was preserved without claiming it.`, ); } - const policyContent = buildMcpBridgePolicyYaml( - entry.server, - entry.url, - adapter, - target.addresses, - ); + const policyContent = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, target); const policyState = policies.getPresetContentGatewayState(sandboxName, policyContent); if (policyState !== "absent") { throw new McpBridgeError( @@ -209,6 +204,7 @@ async function addMcpBridgeUnlocked( const replay = replayTrustedPrivateEndpoint( existingEntry.trustedPrivateHost, existingEntry.allowedIps ?? [], + { requireAllPrivate: true }, ); target = { addresses: [...replay.addresses], diff --git a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts index 195da493110..cf78ccabede 100644 --- a/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts @@ -9,6 +9,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { isTrustedPrivateEndpointCapability } from "../../security/trusted-private-endpoint"; import { addMcpBridge, normalizeMcpServerUrl } from "./mcp-bridge"; import { inspectMcpRecordedTargetPins, @@ -90,8 +91,63 @@ describe("MCP URL target validation", () => { } }); + it("admits a direct private IPv4 target with exact host-bound authority (#8267)", async () => { + const lookup = vi.spyOn(dns, "lookup"); + try { + const url = normalizeMcpServerUrl("https://10.20.30.40/mcp", { + trustedPrivateHosts: ["10.20.30.40"], + }); + const target = await preflightMcpServerUrlResolvedTarget(new URL(url), { + trustedPrivateHosts: ["10.20.30.40"], + requireTrustedPrivateEndpoint: true, + }); + + expect(lookup).not.toHaveBeenCalled(); + expect(target).toMatchObject({ + addresses: ["10.20.30.40"], + trustedPrivateHost: "10.20.30.40", + }); + expect(isTrustedPrivateEndpointCapability(target.trustedPrivateCapability)).toBe(true); + expect(target.trustedPrivateCapability).toMatchObject({ + host: "10.20.30.40", + addresses: ["10.20.30.40"], + }); + } finally { + lookup.mockRestore(); + } + }); + + it("admits a trusted reserved-suffix DNS target with exact private pins (#8267)", async () => { + const lookup = vi + .spyOn(dns, "lookup") + .mockResolvedValue([{ address: "10.20.30.40", family: 4 }] as never); + try { + expect(() => normalizeMcpServerUrl("https://mcp.corp.internal/mcp")).toThrow( + /private, local, or special-use/, + ); + const url = normalizeMcpServerUrl("https://mcp.corp.internal/mcp", { + trustedPrivateHosts: ["mcp.corp.internal"], + }); + await expect( + preflightMcpServerUrlResolvedTarget(new URL(url), { + trustedPrivateHosts: ["mcp.corp.internal"], + requireTrustedPrivateEndpoint: true, + }), + ).resolves.toMatchObject({ + addresses: ["10.20.30.40"], + trustedPrivateHost: "mcp.corp.internal", + trustedPrivateCapability: { + host: "mcp.corp.internal", + addresses: ["10.20.30.40"], + }, + }); + } finally { + lookup.mockRestore(); + } + }); + it("persists exact normalized pins after successful trusted-private admission (#8267)", { - timeout: 15_000, + timeout: 40_000, }, () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-private-mcp-add-success-")); const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); @@ -154,8 +210,10 @@ require("./src/lib/actions/sandbox/mcp-bridge.js").addMcpBridge("alpha", { capabilityAddresses: admittedTarget.trustedPrivateCapability.addresses, trustedPrivateHost: admittedTarget.trustedPrivateHost, }, - })); -}, (error) => { process.stderr.write(error.stack || error.message); process.exitCode = 1; }); + }), () => process.exit(0)); +}, (error) => { + process.stderr.write(error.stack || error.message, () => process.exit(1)); +}); `; try { const result = spawnSync(process.execPath, ["-e", script], { @@ -168,7 +226,7 @@ require("./src/lib/actions/sandbox/mcp-bridge.js").addMcpBridge("alpha", { .filter(Boolean) .join(" "), }, - timeout: 12_000, + timeout: 30_000, }); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); const admission = JSON.parse(result.stdout) as { @@ -202,7 +260,7 @@ require("./src/lib/actions/sandbox/mcp-bridge.js").addMcpBridge("alpha", { trustedPrivateHosts: ["mcp.corp.example"], requireTrustedPrivateEndpoint: true, }), - ).rejects.toThrow(/mixed public and private addresses/); + ).rejects.toThrow(/must resolve only to supported routed private addresses/); lookup.mockResolvedValueOnce([{ address: "8.8.8.8", family: 4 }] as never); await expect( diff --git a/src/lib/actions/sandbox/mcp-bridge-policy-render.ts b/src/lib/actions/sandbox/mcp-bridge-policy-render.ts index 94f2463bf80..4a505bbc03a 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy-render.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy-render.ts @@ -4,7 +4,11 @@ import YAML from "yaml"; import type { AgentMcpAdapter } from "../../agent/defs"; -import { parseMcpUrl, validateMcpServerName } from "./mcp-bridge-validation"; +import { + type McpBridgeTargetValidation, + parseMcpUrlWithValidatedTarget, +} from "./mcp-bridge-url-validation"; +import { validateMcpServerName } from "./mcp-bridge-validation"; export const MCP_BRIDGE_POLICY_MAX_BODY_BYTES = 131_072; export const MCP_BRIDGE_ALLOWED_METHODS = [ @@ -78,23 +82,17 @@ function binariesForAdapter(adapter: AgentMcpAdapter): Array<{ path: string }> { } } -function allowedIpsForEndpoint( - resolvedAddresses: readonly string[] | undefined, -): string[] | undefined { - // OpenShell resolves this hostname for every new connection, validates every - // current answer against allowed_ips, and connects to that validated list. - return resolvedAddresses && resolvedAddresses.length > 0 ? [...resolvedAddresses] : undefined; -} - export function buildMcpBridgePolicyYaml( server: string, url: string, adapter: AgentMcpAdapter, - resolvedAddresses?: readonly string[], + target: McpBridgeTargetValidation, ): string { - const parsed = parseMcpUrl(url); + const parsed = parseMcpUrlWithValidatedTarget(url, target); const key = buildMcpBridgePolicyKey(server); - const allowedIps = allowedIpsForEndpoint(resolvedAddresses); + // OpenShell resolves this hostname for every new connection, validates every + // current answer against allowed_ips, and connects to that validated list. + const allowedIps = [...target.addresses]; return YAML.stringify({ preset: { name: buildMcpBridgePolicyName(server), @@ -110,7 +108,7 @@ export function buildMcpBridgePolicyYaml( path: endpointPath(parsed), protocol: "mcp", enforcement: "enforce", - ...(allowedIps ? { allowed_ips: allowedIps } : {}), + allowed_ips: allowedIps, mcp: { max_body_bytes: MCP_BRIDGE_POLICY_MAX_BODY_BYTES, strict_tool_names: true, diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts index a02e45ea005..c39975557fc 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import YAML from "yaml"; import * as policies from "../../policy"; +import { replayTrustedPrivateEndpoint } from "../../security/trusted-private-endpoint"; import type { McpBridgeEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { @@ -15,7 +16,12 @@ import { MCP_BRIDGE_POLICY_MAX_BODY_BYTES, MCP_BRIDGE_POLICY_SOURCE, } from "./mcp-bridge"; -import { applyGeneratedPolicy, assertGeneratedPolicyExactReadOnly } from "./mcp-bridge-policy"; +import { + applyGeneratedPolicy, + assertGeneratedPolicyExactReadOnly, + assertGeneratedPolicyMutationSafe, + removeGeneratedPolicy, +} from "./mcp-bridge-policy"; function githubBridgeEntry(overrides: Partial = {}): McpBridgeEntry { return { @@ -55,13 +61,32 @@ describe("MCP OpenShell policy", () => { ).toThrow(/without exact public address pins/); }); + it("inspects an unowned direct-private policy key without targetless rendering (#8267)", () => { + const entry = githubBridgeEntry({ + server: "local", + url: "https://10.20.30.40/mcp", + trustedPrivateHost: "10.20.30.40", + allowedIps: ["10.20.30.40"], + policyName: "mcp-bridge-local", + }); + vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); + const inspectKey = vi.spyOn(policies, "getLiveSandboxPolicyEntryDigest").mockReturnValue(null); + const inspectContent = vi.spyOn(policies, "getPresetContentGatewayState"); + const removePreset = vi.spyOn(policies, "removePreset"); + + expect(() => assertGeneratedPolicyMutationSafe("alpha", entry)).not.toThrow(); + expect(() => removeGeneratedPolicy("alpha", entry)).not.toThrow(); + expect(inspectKey).toHaveBeenCalledWith("alpha", "mcp_bridge_local"); + expect(inspectContent).not.toHaveBeenCalled(); + expect(removePreset).not.toHaveBeenCalled(); + }); + it("pins DNS answers while constraining the generic mcporter Node grant", () => { const policyName = buildMcpBridgePolicyName("GitHub_Server"); const policy = YAML.parse( - buildMcpBridgePolicyYaml("GitHub_Server", "https://api.githubcopilot.com/mcp", "mcporter", [ - "8.8.8.8", - "2606:4700:4700::1111", - ]), + buildMcpBridgePolicyYaml("GitHub_Server", "https://api.githubcopilot.com/mcp", "mcporter", { + addresses: ["2606:4700:4700::1111", "8.8.8.8"], + }), ) as { preset: { name: string }; network_policies: Record< @@ -105,7 +130,7 @@ describe("MCP OpenShell policy", () => { allow: { method }, })), ); - expect(entry.endpoints[0].allowed_ips).toEqual(["8.8.8.8", "2606:4700:4700::1111"]); + expect(entry.endpoints[0].allowed_ips).toEqual(["2606:4700:4700::1111", "8.8.8.8"]); expect(entry.binaries.map((binary) => binary.path)).toEqual([ "/usr/local/bin/mcporter", "/usr/bin/mcporter", @@ -120,6 +145,71 @@ describe("MCP OpenShell policy", () => { }); }); + it.each([ + "mcporter", + "hermes-config", + "deepagents-config", + ] as const)("renders an exactly authorized private IPv4 target for %s (#8267)", (adapter) => { + const replay = replayTrustedPrivateEndpoint("10.20.30.40", ["10.20.30.40"]); + const policy = YAML.parse( + buildMcpBridgePolicyYaml("local", "https://10.20.30.40/mcp", adapter, { + addresses: [...replay.addresses], + trustedPrivateCapability: replay.trustedPrivateCapability, + trustedPrivateHost: replay.host, + }), + ) as { + network_policies: Record< + string, + { endpoints: Array<{ allowed_ips: string[]; host: string }> } + >; + }; + + expect(policy.network_policies.mcp_bridge_local.endpoints[0]).toMatchObject({ + host: "10.20.30.40", + allowed_ips: ["10.20.30.40"], + }); + }); + + it("requires host-bound capability authority for a trusted private DNS policy (#8267)", () => { + const replay = replayTrustedPrivateEndpoint("mcp.corp.internal", ["10.20.30.40"]); + const target = { + addresses: [...replay.addresses], + trustedPrivateCapability: replay.trustedPrivateCapability, + trustedPrivateHost: replay.host, + }; + + expect(() => + buildMcpBridgePolicyYaml("local", "https://mcp.corp.internal/mcp", "mcporter", target), + ).not.toThrow(); + expect(() => + buildMcpBridgePolicyYaml("local", "https://other.corp.internal/mcp", "mcporter", target), + ).toThrow(/does not match URL host/); + expect(() => + buildMcpBridgePolicyYaml("local", "https://mcp.corp.internal/mcp", "mcporter", { + addresses: ["10.20.30.40"], + trustedPrivateHost: "mcp.corp.internal", + }), + ).toThrow(/no provenance-checked endpoint capability/); + }); + + it("rejects empty and structurally forged render targets (#8267)", () => { + expect(() => + buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "mcporter", { + addresses: [], + }), + ).toThrow(/non-empty canonical set/); + expect(() => + buildMcpBridgePolicyYaml("local", "https://mcp.corp.internal/mcp", "mcporter", { + addresses: ["10.20.30.40"], + trustedPrivateHost: "mcp.corp.internal", + trustedPrivateCapability: { + host: "mcp.corp.internal", + addresses: ["10.20.30.40"], + }, + } as never), + ).toThrow(/does not match its host-bound endpoint capability/); + }); + it("applies internally generated DNS pins outside the user-supplied preset path", () => { vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); vi.spyOn(registry, "addCustomPolicy").mockReturnValue(true); @@ -154,8 +244,10 @@ describe("MCP OpenShell policy", () => { it("accepts only the canonical generated policy for the exact bridge and DNS pins", () => { const entry = githubBridgeEntry(); - const pins = ["8.8.8.8", "2606:4700:4700::1111"]; - const content = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", pins); + const pins = ["2606:4700:4700::1111", "8.8.8.8"]; + const content = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { + addresses: pins, + }); const registration = { name: entry.policyName, content, @@ -178,7 +270,9 @@ describe("MCP OpenShell policy", () => { ])("rejects duplicate same-name ownership records regardless of order (%s)", (order) => { const entry = githubBridgeEntry(); const pins = ["8.8.8.8"]; - const content = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", pins); + const content = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { + addresses: pins, + }); const owned = { name: entry.policyName, content, @@ -203,7 +297,9 @@ describe("MCP OpenShell policy", () => { it("rejects individually valid policy records that disagree with their bridge definition", () => { const entry = githubBridgeEntry(); const pins = ["8.8.8.8"]; - const canonical = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", pins); + const canonical = buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { + addresses: pins, + }); const wrongKeyDocument = YAML.parse(canonical) as { network_policies: Record; }; @@ -225,7 +321,7 @@ describe("MCP OpenShell policy", () => { entry.server, "https://mcp.example.test/mcp", "mcporter", - pins, + { addresses: pins }, ), }, { @@ -234,17 +330,21 @@ describe("MCP OpenShell policy", () => { entry.server, "https://api.githubcopilot.com/other", "mcporter", - pins, + { addresses: pins }, ), }, { label: "adapter", - content: buildMcpBridgePolicyYaml(entry.server, entry.url, "hermes-config", pins), + content: buildMcpBridgePolicyYaml(entry.server, entry.url, "hermes-config", { + addresses: pins, + }), }, { label: "network policy key", content: YAML.stringify(wrongKeyDocument) }, { label: "resolved address pins", - content: buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", ["1.1.1.1"]), + content: buildMcpBridgePolicyYaml(entry.server, entry.url, "mcporter", { + addresses: ["1.1.1.1"], + }), }, { label: "policy name", @@ -326,7 +426,9 @@ describe("MCP OpenShell policy", () => { it("emits only fields supported by OpenShell current main", () => { const policy = YAML.parse( - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "mcporter"), + buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "mcporter", { + addresses: ["8.8.8.8"], + }), ) as { network_policies: Record> }> }; const endpoint = policy.network_policies.mcp_bridge_srv.endpoints[0]; expect(endpoint).not.toHaveProperty("credential_keys"); @@ -341,19 +443,25 @@ describe("MCP OpenShell policy", () => { "host.containers.internal", ]) { expect(() => - buildMcpBridgePolicyYaml("local", `https://${host}:31337/mcp`, "mcporter"), + buildMcpBridgePolicyYaml("local", `https://${host}:31337/mcp`, "mcporter", { + addresses: ["8.8.8.8"], + }), ).toThrow(/does not expose an attested driver gateway address/); } }); it("scopes binaries to the selected agent adapter", () => { const hermes = YAML.parse( - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "hermes-config"), + buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "hermes-config", { + addresses: ["8.8.8.8"], + }), ) as { network_policies: Record }>; }; const deepAgents = YAML.parse( - buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "deepagents-config"), + buildMcpBridgePolicyYaml("srv", "https://mcp.example.test/mcp", "deepagents-config", { + addresses: ["8.8.8.8"], + }), ) as { network_policies: Record }>; }; diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.ts b/src/lib/actions/sandbox/mcp-bridge-policy.ts index a17aaab5844..1c4e639dd14 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.ts @@ -10,7 +10,7 @@ import { diagnosticPreview } from "../../name-validation"; import * as policies from "../../policy"; import { isBlockedMcpUrlTargetHost } from "../../security/mcp-url-target"; import { - isTrustedPrivateEndpointCapability, + assertTrustedPrivateEndpointCapability, replayTrustedPrivateEndpoint, } from "../../security/trusted-private-endpoint"; import type { McpBridgeEntry } from "../../state/registry"; @@ -92,7 +92,7 @@ function requireCanonicalAllowedIps( networkPolicy: unknown, policyName: string, bridge: McpBridgeEntry, -): readonly string[] { +): McpBridgeTargetValidation { const addressKind = bridge.trustedPrivateHost ? "trusted-private" : "public"; if (!networkPolicy || typeof networkPolicy !== "object" || Array.isArray(networkPolicy)) { throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); @@ -129,7 +129,9 @@ function requireCanonicalAllowedIps( if (bridge.trustedPrivateHost) { let replay; try { - replay = replayTrustedPrivateEndpoint(bridge.trustedPrivateHost, bridge.allowedIps ?? []); + replay = replayTrustedPrivateEndpoint(bridge.trustedPrivateHost, bridge.allowedIps ?? [], { + requireAllPrivate: true, + }); } catch { throw new Error( `Managed MCP policy '${policyName}' has invalid trusted-private address pins`, @@ -144,10 +146,15 @@ function requireCanonicalAllowedIps( `Managed MCP policy '${policyName}' does not match its recorded trusted-private address pins`, ); } + return { + addresses: [...pins], + trustedPrivateCapability: replay.trustedPrivateCapability, + trustedPrivateHost: replay.host, + }; } else if (pins.some((address) => isBlockedMcpUrlTargetHost(address))) { throw new Error(`Managed MCP policy '${policyName}' has invalid public address pins`); } - return pins; + return { addresses: [...pins] }; } function resolveCanonicalManagedMcpAdapter( @@ -224,7 +231,7 @@ function requireCanonicalManagedPolicy( } const registeredNetworkPolicy = registeredPolicies[policyKey]; - const allowedIps = requireCanonicalAllowedIps(registeredNetworkPolicy, policyName, bridge); + const target = requireCanonicalAllowedIps(registeredNetworkPolicy, policyName, bridge); let expectedDocument: Record; try { expectedDocument = parseManagedPolicyDocument( @@ -232,7 +239,7 @@ function requireCanonicalManagedPolicy( bridge.server, bridge.url, resolveCanonicalManagedMcpAdapter(sandbox, bridge), - allowedIps, + target, ), `Canonical managed MCP policy '${policyName}'`, ); @@ -603,7 +610,7 @@ export function applyGeneratedPolicy( ); } const adapter = isAgentMcpAdapter(entry.adapter) ? entry.adapter : "mcporter"; - const content = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, resolvedAddresses); + const content = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, target); const policyKey = buildMcpBridgePolicyKey(entry.server); const sameNamePolicy = registry .getCustomPolicies(sandboxName) @@ -715,22 +722,23 @@ export function assertMcpBridgePolicyTarget( } return target.addresses; } - if ( - target.trustedPrivateHost !== entry.trustedPrivateHost || - !isTrustedPrivateEndpointCapability(target.trustedPrivateCapability) - ) { + let authority; + try { + authority = assertTrustedPrivateEndpointCapability( + entry.trustedPrivateHost, + target.addresses, + target.trustedPrivateCapability, + { requireAllPrivate: true }, + ); + } catch { throw new McpBridgeError( `MCP server '${entry.server}' has no provenance-checked capability for trusted private host '${entry.trustedPrivateHost}'.`, ); } const recordedPins = entry.allowedIps ?? []; - const capabilityPins = [...target.trustedPrivateCapability.addresses].sort(); if ( - recordedPins.length === 0 || - target.addresses.length !== recordedPins.length || - target.addresses.some((address, index) => address !== recordedPins[index]) || - capabilityPins.length !== recordedPins.length || - capabilityPins.some((address, index) => address !== recordedPins[index]) + target.trustedPrivateHost !== authority.host || + !isDeepStrictEqual(authority.addresses, recordedPins) ) { throw new McpBridgeError( `MCP server '${entry.server}' no longer resolves to its recorded trusted-private address pins. Remove and re-add the server to approve changed pins.`, @@ -740,9 +748,20 @@ export function assertMcpBridgePolicyTarget( return recordedPins; } -function generatedPolicyContent(entry: McpBridgeEntry): string { - const adapter = isAgentMcpAdapter(entry.adapter) ? entry.adapter : "mcporter"; - return buildMcpBridgePolicyYaml(entry.server, entry.url, adapter); +function getUnownedGeneratedPolicyState( + sandboxName: string, + entry: McpBridgeEntry, +): "absent" | "present" | null { + try { + return policies.getLiveSandboxPolicyEntryDigest( + sandboxName, + buildMcpBridgePolicyKey(entry.server), + ) === null + ? "absent" + : "present"; + } catch { + return null; + } } export function assertGeneratedPolicyMutationSafe( @@ -754,8 +773,7 @@ export function assertGeneratedPolicyMutationSafe( const reconciled = registeredPolicy ? reconcileGeneratedPolicyRegistration(sandboxName, registeredPolicy) : undefined; - const content = reconciled?.policy.content ?? generatedPolicyContent(entry); - const state = reconciled?.state ?? policies.getPresetContentGatewayState(sandboxName, content); + const state = reconciled?.state ?? getUnownedGeneratedPolicyState(sandboxName, entry); if (state === "absent") return; if (!owned || state !== "match") { throw new McpBridgeError( @@ -815,7 +833,7 @@ export function assertGeneratedPolicyExactReadOnly( } let expectedContent: string; try { - expectedContent = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, resolvedAddresses); + expectedContent = buildMcpBridgePolicyYaml(entry.server, entry.url, adapter, target); } catch { // Registry entries are untrusted local state. Keep malformed URLs and any // credential-shaped material out of the recovery diagnostic. @@ -867,9 +885,12 @@ export function removeGeneratedPolicy( ? reconcileGeneratedPolicyRegistration(sandboxName, registeredPolicy) : undefined; const effectiveRegistration = reconciled?.policy ?? registeredPolicy; - const content = effectiveRegistration?.content ?? generatedPolicyContent(entry); + const content = effectiveRegistration?.content; const gatewayState = - reconciled?.state ?? policies.getPresetContentGatewayState(sandboxName, content); + reconciled?.state ?? + (content + ? policies.getPresetContentGatewayState(sandboxName, content) + : getUnownedGeneratedPolicyState(sandboxName, entry)); if (gatewayState === "absent") { if (ownsRegistration) { registry.removeCustomPolicyByName(sandboxName, policyName); @@ -890,6 +911,12 @@ export function removeGeneratedPolicy( }); // OpenShell can acknowledge a superseded policy revision as success. Confirm // the exact generated key is absent before discarding its ownership record. + if (!content) { + if (options.bestEffort) return; + throw new McpBridgeError( + `Generated MCP policy '${policyName}' has no exact ownership content. Refusing to delete same-key policy state.`, + ); + } const activeState = policies.getPresetContentGatewayState(sandboxName, content); if (activeState === "absent") { registry.removeCustomPolicyByName(sandboxName, policyName); diff --git a/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts b/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts index f977f206e15..5feea6740e6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts +++ b/src/lib/actions/sandbox/mcp-bridge-private-lifecycle.test.ts @@ -26,9 +26,9 @@ function privateEntry(adapter: AgentMcpAdapter, agent: string): McpBridgeEntry { server: "local", agent, adapter, - url: "https://mcp.corp.example/mcp", + url: "https://mcp.corp.internal/mcp", env: ["LOCAL_MCP_TOKEN"], - trustedPrivateHost: "mcp.corp.example", + trustedPrivateHost: "mcp.corp.internal", allowedIps: ["10.20.30.40", "fd00::40"], providerName: "alpha-mcp-local", providerId: "11111111-2222-4333-8444-555555555555", @@ -55,6 +55,26 @@ describe("trusted-private MCP lifecycle replay", () => { expect(target && assertMcpBridgePolicyTarget(entry, target)).toEqual(entry.allowedIps); }); + it.each(adapters)("replays a direct private IPv4 target for $agent (#8267)", async ({ + adapter, + agent, + }) => { + const lookup = vi.spyOn(dns, "lookup").mockRejectedValue(new Error("ambient DNS used")); + const entry = privateEntry(adapter, agent); + entry.url = "https://10.20.30.40/mcp"; + entry.trustedPrivateHost = "10.20.30.40"; + entry.allowedIps = ["10.20.30.40"]; + + const target = (await preflightMcpEntryTargets([entry])).get(entry.server); + + expect(lookup).not.toHaveBeenCalled(); + expect(target).toMatchObject({ + addresses: ["10.20.30.40"], + trustedPrivateHost: "10.20.30.40", + }); + expect(target && assertMcpBridgePolicyTarget(entry, target)).toEqual(["10.20.30.40"]); + }); + it("rejects invalid durable private pins without consulting DNS (#8267)", async () => { const lookup = vi.spyOn(dns, "lookup").mockRejectedValue(new Error("ambient DNS used")); const entry = privateEntry("mcporter", "openclaw"); @@ -77,7 +97,9 @@ describe("trusted-private MCP lifecycle replay", () => { expect(lookup).not.toHaveBeenCalled(); }); - it("resumes an incomplete private add from recorded pins without ambient DNS (#8267)", () => { + it("resumes an incomplete private add from recorded pins without ambient DNS (#8267)", { + timeout: 40_000, + }, () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-private-mcp-add-replay-")); const sourceRequireHook = path.resolve("test/helpers/onboard-script-mocks.cjs"); const script = ` @@ -112,7 +134,10 @@ bridge.addMcpBridge("alpha", { trustedPrivateHosts: ["mcp.corp.example"], }).then( () => process.exit(9), - (error) => process.stdout.write(JSON.stringify({ message: error.message, dnsCalls })), + (error) => process.stdout.write( + JSON.stringify({ message: error.message, dnsCalls }), + () => process.exit(0), + ), ); `; const result = spawnSync(process.execPath, ["-e", script], { @@ -125,7 +150,7 @@ bridge.addMcpBridge("alpha", { .filter(Boolean) .join(" "), }, - timeout: 15_000, + timeout: 30_000, }); fs.rmSync(home, { recursive: true, force: true }); diff --git a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts index b9465602927..a6568d07b8c 100644 --- a/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts +++ b/src/lib/actions/sandbox/mcp-bridge-provider-inspection.ts @@ -284,7 +284,9 @@ export async function preflightMcpEntryTargets( const recordedPins = entry.allowedIps ?? []; let replay; try { - replay = replayTrustedPrivateEndpoint(entry.trustedPrivateHost, recordedPins); + replay = replayTrustedPrivateEndpoint(entry.trustedPrivateHost, recordedPins, { + requireAllPrivate: true, + }); } catch (error) { throw new McpBridgeError( `MCP server '${entry.server}' has invalid durable trusted-private intent: ${error instanceof Error ? error.message : String(error)}. Remove it with --force and add it again.`, diff --git a/src/lib/actions/sandbox/mcp-bridge-url-validation.ts b/src/lib/actions/sandbox/mcp-bridge-url-validation.ts index 783c02b9723..7e3e26b6e58 100644 --- a/src/lib/actions/sandbox/mcp-bridge-url-validation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-url-validation.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { isIP } from "node:net"; + import { resolveHostAddresses } from "../../adapters/dns/resolve"; import { isLoopbackHostname } from "../../private-networks"; import { @@ -10,8 +12,8 @@ import { } from "../../security/mcp-url-target"; import { TOKEN_PREFIX_PATTERNS } from "../../security/secret-patterns"; import { + assertTrustedPrivateEndpointCapability, assertEndpointResolvesPublic, - isTrustedPrivateEndpointCapability, normalizeTrustedPrivateHost, type TrustedPrivateEndpointCapability, } from "../../security/trusted-private-endpoint"; @@ -277,29 +279,24 @@ export async function preflightMcpServerUrlResolvedTarget( const normalizedHostname = normalizeTrustedPrivateHost(parsed.hostname); const explicitTrust = normalizedTrustedHosts.includes(normalizedHostname); if (result.trustedPrivateEndpoint) { - if (!isTrustedPrivateEndpointCapability(result.trustedPrivateCapability)) { - throw new McpBridgeError( - `MCP server URL host '${normalizedHostname}' did not return a provenance-checked trusted-private capability.`, - 2, + try { + const authority = assertTrustedPrivateEndpointCapability( + normalizedHostname, + addresses, + result.trustedPrivateCapability, + { requireAllPrivate: true }, ); - } - const capabilityAddresses = [...result.trustedPrivateCapability.addresses] - .map((address) => address.toLowerCase()) - .sort(); - if ( - capabilityAddresses.length !== addresses.length || - capabilityAddresses.some((address, index) => address !== addresses[index]) - ) { + return { + addresses: [...authority.addresses], + trustedPrivateCapability: authority.trustedPrivateCapability, + trustedPrivateHost: authority.host, + }; + } catch { throw new McpBridgeError( - `MCP server URL host '${normalizedHostname}' returned mixed public and private addresses. Trusted-private MCP endpoints must resolve only to supported routed private addresses.`, + `MCP server URL host '${normalizedHostname}' did not return exact routed-private authority. Trusted-private MCP endpoints must resolve only to supported routed private addresses, and every pin must match the host-bound capability.`, 2, ); } - return { - addresses, - trustedPrivateCapability: result.trustedPrivateCapability, - trustedPrivateHost: normalizedHostname, - }; } if (explicitTrust && options.requireTrustedPrivateEndpoint) { throw new McpBridgeError( @@ -340,3 +337,80 @@ export async function inspectMcpRecordedTargetPins( export function parseMcpUrl(rawUrl: string): URL { return new URL(normalizeMcpServerUrl(rawUrl)); } + +/** + * Revalidate URL syntax while preserving the exact destination authority + * issued by MCP target preflight or durable trusted-private replay. + */ +export function parseMcpUrlWithValidatedTarget( + rawUrl: string, + target: McpBridgeTargetValidation, +): URL { + const addresses = [...target.addresses]; + const sortedAddresses = [...addresses].sort(); + if ( + addresses.length === 0 || + addresses.some( + (address) => + typeof address !== "string" || + isIP(address) === 0 || + address !== address.toLowerCase() || + address.includes("%"), + ) || + new Set(addresses).size !== addresses.length || + addresses.some((address, index) => address !== sortedAddresses[index]) + ) { + throw new McpBridgeError( + "Validated MCP target must contain a non-empty canonical set of exact address pins.", + 2, + ); + } + + const hasPrivateAuthority = + target.trustedPrivateCapability !== undefined || target.trustedPrivateHost !== undefined; + if (!hasPrivateAuthority) { + if (addresses.some((address) => isBlockedMcpUrlTargetHost(address))) { + throw new McpBridgeError( + "Validated public MCP target contains a private, local, or special-use address pin.", + 2, + ); + } + return new URL(normalizeMcpServerUrl(rawUrl)); + } + + if (!target.trustedPrivateHost || !target.trustedPrivateCapability) { + throw new McpBridgeError( + "Validated private MCP target has no provenance-checked endpoint capability.", + 2, + ); + } + let authority; + try { + authority = assertTrustedPrivateEndpointCapability( + target.trustedPrivateHost, + addresses, + target.trustedPrivateCapability, + { requireAllPrivate: true }, + ); + } catch { + throw new McpBridgeError( + "Validated private MCP target does not match its host-bound endpoint capability.", + 2, + ); + } + const trustedPrivateHost = authority.host; + + let rawParsed: URL; + try { + rawParsed = new URL(rawUrl); + } catch { + return new URL(normalizeMcpServerUrl(rawUrl, { trustedPrivateHosts: [trustedPrivateHost] })); + } + if (normalizeTrustedPrivateHost(rawParsed.hostname) !== trustedPrivateHost) { + throw new McpBridgeError( + `Validated private MCP target host '${trustedPrivateHost}' does not match URL host '${rawParsed.hostname}'.`, + 2, + ); + } + return new URL(normalizeMcpServerUrl(rawUrl, { trustedPrivateHosts: [trustedPrivateHost] })); +} diff --git a/src/lib/adapters/http/curl-args.test.ts b/src/lib/adapters/http/curl-args.test.ts index 8a0ece5ed1a..f5c864b606c 100644 --- a/src/lib/adapters/http/curl-args.test.ts +++ b/src/lib/adapters/http/curl-args.test.ts @@ -179,6 +179,81 @@ describe("validateCurlProbeArgs — credential-leak defence", () => { ).not.toThrow(); }); + it("rejects capability reuse for a different host on the same private address (#8176)", async () => { + const preflight = await assertEndpointResolvesPublic( + "https://a.corp.example/v1/models", + async () => [{ address: "10.0.0.8", family: 4 }], + { trustedPrivateHosts: ["a.corp.example"] }, + ); + + expect(() => + validateCurlProbeArgs( + ["-sS", "--resolve", "b.corp.example:443:10.0.0.8", "https://b.corp.example/v1/models"], + { + pinnedAddresses: ["10.0.0.8"], + trustedPrivateCapability: preflight.trustedPrivateCapability, + }, + ), + ).toThrow(/capability host 'a\.corp\.example' does not match 'b\.corp\.example'/); + }); + + it("canonicalizes an expanded ULA answer before curl pin validation (#8176)", async () => { + const endpointUrl = "https://llm.corp.example/v1/models"; + const preflight = await assertEndpointResolvesPublic( + endpointUrl, + async () => [{ address: "fd00:0:0:0:0:0:0:10", family: 6 }], + { trustedPrivateHosts: ["llm.corp.example"] }, + ); + + expect(preflight.addresses).toEqual(["fd00::10"]); + expect(() => + validateCurlProbeArgs(["-sS", "--resolve", "llm.corp.example:443:[fd00::10]", endpointUrl], { + pinnedAddresses: preflight.addresses, + trustedPrivateCapability: preflight.trustedPrivateCapability, + }), + ).not.toThrow(); + }); + + it("requires the exact mixed public and private pin set at the curl boundary (#8176)", async () => { + const endpointUrl = "https://llm.corp.example/v1/models"; + const preflight = await assertEndpointResolvesPublic( + endpointUrl, + async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "10.0.0.8", family: 4 }, + ], + { trustedPrivateHosts: ["llm.corp.example"] }, + ); + const options = { + pinnedAddresses: preflight.addresses, + trustedPrivateCapability: preflight.trustedPrivateCapability, + }; + + expect(() => + validateCurlProbeArgs( + ["-sS", "--resolve", "llm.corp.example:443:10.0.0.8,93.184.216.34", endpointUrl], + options, + ), + ).not.toThrow(); + + for (const mapping of [ + "llm.corp.example:443:10.0.0.8", + "llm.corp.example:443:93.184.216.34", + "llm.corp.example:443:10.0.0.8,93.184.216.34,8.8.8.8", + ]) { + expect(() => + validateCurlProbeArgs(["-sS", "--resolve", mapping, endpointUrl], options), + ).toThrow(/exactly match pinnedAddresses/); + } + + expect(() => + validateCurlProbeArgs( + ["-sS", "--resolve", "llm.corp.example:443:10.0.0.8,93.184.216.34", endpointUrl], + { pinnedAddresses: preflight.addresses }, + ), + ).toThrow(/unauthorized private address/); + }); + it("rejects a forged private authorization even when the address is otherwise trustable (#6861)", () => { expect(() => validateCurlProbeArgs( @@ -232,7 +307,7 @@ describe("validateCurlProbeArgs — credential-leak defence", () => { validateCurlProbeArgs(["-sS", "http://10.0.0.8/v1/models"], options), ).not.toThrow(); expect(() => validateCurlProbeArgs(["-sS", "http://10.0.0.9/v1/models"], options)).toThrow( - /match the exact private IP URL/, + /capability host '10\.0\.0\.8' does not match '10\.0\.0\.9'/, ); }); diff --git a/src/lib/adapters/http/curl-args.ts b/src/lib/adapters/http/curl-args.ts index 1afd23f1a18..dd9318078d6 100644 --- a/src/lib/adapters/http/curl-args.ts +++ b/src/lib/adapters/http/curl-args.ts @@ -4,10 +4,9 @@ import { isIP } from "node:net"; import path from "node:path"; import { - isOperatorTrustablePrivateIp, - isTrustedPrivateEndpointCapability, + assertTrustedPrivateEndpointCapability, type TrustedPrivateEndpointCapability, -} from "../../inference/endpoint-ssrf-preflight"; +} from "../../security/trusted-private-endpoint"; import { isCredentialShapedName } from "../../security/credential-env"; import { ROOT } from "../../state/paths"; @@ -23,7 +22,7 @@ export interface CurlProbeArgOptions { allowRedirects?: boolean; /** Addresses approved by the endpoint SSRF preflight. */ pinnedAddresses?: readonly string[]; - /** Non-forgeable proof of the exact private subset admitted by the SSRF preflight. */ + /** Non-forgeable proof of the exact pins admitted for a trusted private host. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; } @@ -202,18 +201,26 @@ function isPrivateResolveAddress(address: string): boolean { return isPrivateIp(address); } -function isOperatorTrustablePrivateResolveAddress(address: string): boolean { - return isOperatorTrustablePrivateIp(address); -} - function getTrustedPrivateResolveAddresses( - capability: TrustedPrivateEndpointCapability | undefined, + target: URL, + opts: CurlProbeArgOptions, ): readonly string[] { - if (!capability) return []; - if (!isTrustedPrivateEndpointCapability(capability)) { - throw new Error("curl probe trusted private capability was not issued by the SSRF preflight"); + if (!opts.trustedPrivateCapability) return []; + if (opts.pinnedAddresses === undefined) { + throw new Error("curl probe trusted private capability requires pinnedAddresses"); } - return capability.addresses; + const targetHost = normalizeHostname(target.hostname); + const authorityAddresses = + opts.pinnedAddresses.length > 0 + ? opts.pinnedAddresses + : isIP(targetHost) !== 0 + ? [targetHost] + : opts.pinnedAddresses; + return assertTrustedPrivateEndpointCapability( + target.hostname, + authorityAddresses, + opts.trustedPrivateCapability, + ).addresses; } function assertResolveMatchesApprovedEndpoint( @@ -230,9 +237,7 @@ function assertResolveMatchesApprovedEndpoint( const port = value.slice(firstSeparator + 1, secondSeparator); const addresses = parseResolveAddresses(value.slice(secondSeparator + 1)); const approved = [...new Set(opts.pinnedAddresses ?? [])]; - const trustedPrivate = [ - ...new Set(getTrustedPrivateResolveAddresses(opts.trustedPrivateCapability)), - ]; + const trustedPrivate = [...getTrustedPrivateResolveAddresses(target, opts)]; if (approved.length === 0) { throw new Error("curl probe --resolve requires SSRF-preflight-approved pinnedAddresses"); } @@ -242,18 +247,6 @@ function assertResolveMatchesApprovedEndpoint( if (addresses.length === 0 || addresses.some((address) => isIP(address) === 0)) { throw new Error("curl probe --resolve addresses must be numeric IP addresses"); } - if ( - trustedPrivate.some( - (address) => - isIP(address) === 0 || - !isPrivateResolveAddress(address) || - !isOperatorTrustablePrivateResolveAddress(address), - ) - ) { - throw new Error( - "curl probe trusted private addresses must be numeric RFC1918, CGNAT, or IPv6 ULA addresses", - ); - } const trustedPrivateSet = new Set(trustedPrivate); if ( addresses.some((address) => isPrivateResolveAddress(address) && !trustedPrivateSet.has(address)) @@ -280,10 +273,7 @@ export function validateCurlProbeArgs( const args = [...argv]; const url = normalizeHttpProbeUrl(args.pop()); const parsedUrl = new URL(url); - const trustedPrivate = getTrustedPrivateResolveAddresses(opts.trustedPrivateCapability); - if (trustedPrivate.length > 0 && opts.pinnedAddresses === undefined) { - throw new Error("curl probe trusted private capability requires pinnedAddresses"); - } + const trustedPrivate = getTrustedPrivateResolveAddresses(parsedUrl, opts); let sawResolve = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; diff --git a/src/lib/adapters/http/probe.ts b/src/lib/adapters/http/probe.ts index f217625d4b8..6267ce59389 100644 --- a/src/lib/adapters/http/probe.ts +++ b/src/lib/adapters/http/probe.ts @@ -40,7 +40,7 @@ export interface CurlProbeOptions { * connection with ambient proxies disabled. */ pinnedAddresses?: readonly string[]; - /** Non-forgeable proof of the exact private subset admitted by the SSRF preflight. */ + /** Non-forgeable proof of the exact host and complete pins admitted by SSRF preflight. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; spawnSyncImpl?: ( command: string, diff --git a/src/lib/inference/compatible-endpoint-context.test.ts b/src/lib/inference/compatible-endpoint-context.test.ts index fffd3e59c6c..7c0b223a4ff 100644 --- a/src/lib/inference/compatible-endpoint-context.test.ts +++ b/src/lib/inference/compatible-endpoint-context.test.ts @@ -223,7 +223,7 @@ describe("compatible-endpoint context window", () => { expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); }); - it("does not probe an allowlisted endpoint with mixed private and public DNS answers (#8176)", async () => { + it("probes an allowlisted endpoint with every mixed private and public DNS pin (#8176)", async () => { const fetchModels = vi.fn(() => ({ data: [{ id: "model-a", max_model_len: 65_536 }] })); const messages: string[] = []; const env: NodeJS.ProcessEnv = { @@ -239,9 +239,17 @@ describe("compatible-endpoint context window", () => { logger: { log: (m) => messages.push(m), warn: (m) => messages.push(m) }, }); - expect(fetchModels).not.toHaveBeenCalled(); - expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined(); - expect(messages.some((message) => message.includes("93.184.216.34"))).toBe(true); + expect(fetchModels).toHaveBeenCalledWith( + "https://llm.corp.example/v1", + "", + ["10.0.0.8", "93.184.216.34"], + expect.objectContaining({ + host: "llm.corp.example", + addresses: ["10.0.0.8", "93.184.216.34"], + }), + ); + expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("65536"); + expect(messages).toEqual([" ✓ Using endpoint max_model_len: 65536 tokens"]); }); it.each([ diff --git a/src/lib/inference/compatible-endpoint-context.ts b/src/lib/inference/compatible-endpoint-context.ts index 7760a6a5038..08f6dec9a38 100644 --- a/src/lib/inference/compatible-endpoint-context.ts +++ b/src/lib/inference/compatible-endpoint-context.ts @@ -47,7 +47,7 @@ export type CompatibleEndpointModelsFetcher = ( * fakes can ignore it. */ pinnedAddresses?: string[], - /** Non-forgeable proof of the exact private subset admitted by the SSRF preflight. */ + /** Non-forgeable proof of the exact host and complete pins admitted by SSRF preflight. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability, ) => unknown | null; diff --git a/src/lib/inference/endpoint-ssrf-preflight.test.ts b/src/lib/inference/endpoint-ssrf-preflight.test.ts index 64b7dbf48c4..906a31018c0 100644 --- a/src/lib/inference/endpoint-ssrf-preflight.test.ts +++ b/src/lib/inference/endpoint-ssrf-preflight.test.ts @@ -70,7 +70,7 @@ describe("assertEndpointResolvesPublic (#6293)", () => { expect(lookup).toHaveBeenCalledWith("llm.corp.example", { all: true }); }); - it("rejects mixed public and trusted-private answers for an exact trusted hostname (#8176)", async () => { + it("pins mixed public and trusted-private answers for an exact trusted hostname (#8176)", async () => { const result = await assertEndpointResolvesPublic( "https://llm.corp.example/v1", async () => [ @@ -81,11 +81,14 @@ describe("assertEndpointResolvesPublic (#6293)", () => { ); expect(result).toMatchObject({ - ok: false, - reasonCode: "mixed-answer", - offendingAddress: "93.184.216.34", + ok: true, + addresses: ["10.0.0.8", "93.184.216.34"], + trustedPrivateEndpoint: true, + }); + expect(result.trustedPrivateCapability).toMatchObject({ + host: "llm.corp.example", + addresses: ["10.0.0.8", "93.184.216.34"], }); - expect(result.trustedPrivateCapability).toBeUndefined(); }); it("does not treat a trusted hostname as a suffix or wildcard allowlist (#6861)", async () => { @@ -267,13 +270,13 @@ describe("assertEndpointResolvesPublic (#6293)", () => { expect(result.ok).toBe(false); }); - it("returns a rejected result when URL parsing accepts a non-canonical hostname (#8176)", async () => { - const lookup = vi.fn(); + it("preserves a URL-valid public hostname outside declaration grammar (#8176)", async () => { + const lookup = resolverTo("93.184.216.34"); await expect( assertEndpointResolvesPublic("https://my_host.corp.example/v1", lookup), - ).resolves.toMatchObject({ ok: false, reasonCode: "rejected" }); - expect(lookup).not.toHaveBeenCalled(); + ).resolves.toEqual({ ok: true, addresses: ["93.184.216.34"] }); + expect(lookup).toHaveBeenCalledWith("my_host.corp.example", { all: true }); }); it.each([ diff --git a/src/lib/inference/endpoint-ssrf-preflight.ts b/src/lib/inference/endpoint-ssrf-preflight.ts index 7e95c411d5c..040deedaf48 100644 --- a/src/lib/inference/endpoint-ssrf-preflight.ts +++ b/src/lib/inference/endpoint-ssrf-preflight.ts @@ -1,11 +1,37 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { parseTrustedPrivateHosts } from "../security/trusted-private-endpoint"; +import { + assertEndpointResolvesPublic as assertSharedEndpointResolvesPublic, + type EndpointDnsLookupFn, + type EndpointSsrfPreflightOptions, + type EndpointSsrfPreflightResult, + isOpenShellManagedHost, + parseTrustedPrivateHosts, +} from "../security/trusted-private-endpoint"; export * from "../security/trusted-private-endpoint"; export { parseTrustedPrivateHosts as parseTrustedPrivateInferenceHosts } from "../security/trusted-private-endpoint"; +/** Preserve the established local inference routes outside the generic endpoint boundary. */ +export async function assertEndpointResolvesPublic( + endpointUrl: string, + lookup?: EndpointDnsLookupFn, + options: EndpointSsrfPreflightOptions = {}, +): Promise { + try { + const hostname = new URL(String(endpointUrl)).hostname; + const { isLoopbackHostname } = + require("../private-networks") as typeof import("../private-networks"); + if (isLoopbackHostname(hostname) || isOpenShellManagedHost(hostname)) { + return { ok: true, addresses: [] }; + } + } catch { + // The shared validator owns the stable malformed-URL result. + } + return assertSharedEndpointResolvesPublic(endpointUrl, lookup, options); +} + /** Read the generic trust source and the legacy inference-only source. */ export function parseTrustedPrivateInferenceHostsFromEnv(env: NodeJS.ProcessEnv): string[] { return [ diff --git a/src/lib/inference/probe-anthropic.ts b/src/lib/inference/probe-anthropic.ts index 77955b3c61f..6ec400b2448 100644 --- a/src/lib/inference/probe-anthropic.ts +++ b/src/lib/inference/probe-anthropic.ts @@ -60,7 +60,7 @@ export interface AnthropicProbeOptions { * a private/internal address after the public preflight (TOCTOU — #6293). */ pinnedAddresses?: readonly string[]; - /** Non-forgeable proof of the exact private subset admitted by the SSRF preflight. */ + /** Non-forgeable proof of the exact host and complete pins admitted by SSRF preflight. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; } diff --git a/src/lib/onboard/inference-selection-validation.test.ts b/src/lib/onboard/inference-selection-validation.test.ts index 80f728093cb..b8d14c77bf1 100644 --- a/src/lib/onboard/inference-selection-validation.test.ts +++ b/src/lib/onboard/inference-selection-validation.test.ts @@ -304,7 +304,10 @@ describe("inference selection validation", () => { ok: true, api: intendedApi, pinnedAddresses: ["10.0.0.8"], - trustedPrivateCapability: { addresses: ["10.0.0.8"] }, + trustedPrivateCapability: { + host: "anthropic.corp.example", + addresses: ["10.0.0.8"], + }, }); expect(probeEndpoint).toHaveBeenCalledOnce(); expect(probeEndpoint).toHaveBeenCalledWith( diff --git a/src/lib/onboard/inference-selection-validation.ts b/src/lib/onboard/inference-selection-validation.ts index bad8dc2d81e..6debd7b0aa3 100644 --- a/src/lib/onboard/inference-selection-validation.ts +++ b/src/lib/onboard/inference-selection-validation.ts @@ -50,7 +50,7 @@ export type EndpointValidationResult = retry?: undefined; /** Public addresses approved for this custom endpoint's host probes. */ pinnedAddresses?: string[]; - /** Non-forgeable proof of the exact private subset admitted by the operator allowlist. */ + /** Non-forgeable proof of the exact host and complete pins admitted by the operator allowlist. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; } | { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined }; diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts index 6e0bae480f7..048f05c8eb7 100644 --- a/src/lib/onboard/machine/handlers/provider-inference.ts +++ b/src/lib/onboard/machine/handlers/provider-inference.ts @@ -59,7 +59,7 @@ export interface ProviderInferenceSetupOptions { endpointPinnedAddresses?: readonly string[]; /** Durable route provenance to preserve when reserving a refreshed route. */ endpointSource?: InferenceEndpointSource | null; - /** Non-forgeable proof of the exact private subset admitted by the custom preflight. */ + /** Non-forgeable proof of the exact host and complete pins admitted by the custom preflight. */ endpointTrustedPrivateCapability?: TrustedPrivateEndpointCapability; /** One-shot host capability cache carried only through this onboarding run. */ inferenceCapabilityCache?: OnboardInferenceCapabilityCache; diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts index 62391d8581d..62cb4a5ac61 100644 --- a/src/lib/onboard/setup-nim-selection.ts +++ b/src/lib/onboard/setup-nim-selection.ts @@ -41,7 +41,7 @@ export type SetupNimSelectionState = { skipHostInferenceSmoke?: boolean; /** Public addresses approved for the selected custom endpoint. */ endpointPinnedAddresses?: string[]; - /** Non-forgeable proof of the exact private subset admitted by the selected preflight. */ + /** Non-forgeable proof of the exact host and complete pins admitted by the selected preflight. */ endpointTrustedPrivateCapability?: TrustedPrivateEndpointCapability; reuseGatewayCredentialWithoutLocalKey?: boolean; /** Ephemeral selection-to-smoke validation cache; never written to session state. */ diff --git a/src/lib/policy/trusted-private-endpoints.test.ts b/src/lib/policy/trusted-private-endpoints.test.ts index a6275544467..e4e1371d978 100644 --- a/src/lib/policy/trusted-private-endpoints.test.ts +++ b/src/lib/policy/trusted-private-endpoints.test.ts @@ -84,7 +84,7 @@ network_policies: "websocket", "jsonrpc", "mcp", - ])("rejects mixed public and private DNS answers for %s endpoints (#8176)", async (protocol) => { + ])("pins mixed public and trusted-private DNS answers for %s endpoints (#8176)", async (protocol) => { const input = preset(`preset: name: private network_policies: @@ -93,11 +93,20 @@ network_policies: - { host: api.corp.example, port: 443, protocol: ${protocol} } `); - await expect( - prepareTrustedPrivatePolicyPresets([input], ["api.corp.example"], { - lookup: lookup({ "api.corp.example": ["10.20.30.40", "8.8.8.8"] }), - }), - ).rejects.toThrow(/mixed public and private addresses/); + const [prepared] = await prepareTrustedPrivatePolicyPresets([input], ["api.corp.example"], { + lookup: lookup({ "api.corp.example": ["10.20.30.40", "8.8.8.8"] }), + }); + const document = YAML.parse(prepared.content) as { + network_policies: { services: { endpoints: Array<{ allowed_ips?: string[] }> } }; + }; + + expect(document.network_policies.services.endpoints[0]?.allowed_ips).toEqual([ + "10.20.30.40", + "8.8.8.8", + ]); + expect(hasTrustedPrivatePolicyPinReceipt(prepared.content, prepared.trustedPrivatePins)).toBe( + true, + ); expect(input.content).not.toContain("allowed_ips"); }); @@ -145,7 +154,7 @@ network_policies: }; expect(() => replayTrustedPrivatePolicyPinCapability(content, receipt)).toThrow( - /non-canonical private pins/, + /disallowed address pin/, ); }); diff --git a/src/lib/policy/trusted-private-endpoints.ts b/src/lib/policy/trusted-private-endpoints.ts index 45db11f452f..e3e6b86606b 100644 --- a/src/lib/policy/trusted-private-endpoints.ts +++ b/src/lib/policy/trusted-private-endpoints.ts @@ -6,12 +6,12 @@ import { isIP } from "node:net"; import YAML from "yaml"; -import { isPrivateIp, OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; +import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; import { + assertTrustedPrivateEndpointCapability, assertEndpointResolvesPublic, + canonicalizeTrustedPrivateEndpointPins, type EndpointDnsLookupFn, - isOperatorTrustablePrivateIp, - isTrustedPrivateEndpointCapability, normalizeTrustedPrivateHost, } from "../security/trusted-private-endpoint"; @@ -107,24 +107,19 @@ function validateTrustedPrivatePinnedContent(content: string): void { if (!Array.isArray(endpoint.allowed_ips) || endpoint.allowed_ips.length === 0) { throw new Error(`trusted private policy endpoint '${host}' has no exact address pins`); } - const addresses = endpoint.allowed_ips.map((address) => { - if ( - typeof address !== "string" || - isIP(address) === 0 || - address !== address.toLowerCase() - ) { - throw new Error(`trusted private policy endpoint '${host}' has a malformed address pin`); - } - if (isPrivateIp(address) && !isOperatorTrustablePrivateIp(address)) { - throw new Error(`trusted private policy endpoint '${host}' has a disallowed address pin`); - } - return address; - }); - const canonical = [...new Set(addresses)].sort(); + const addresses = endpoint.allowed_ips; + let canonical: readonly string[]; + try { + canonical = canonicalizeTrustedPrivateEndpointPins( + host, + addresses as readonly string[], + ).addresses; + } catch { + throw new Error(`trusted private policy endpoint '${host}' has a disallowed address pin`); + } if ( canonical.length !== addresses.length || - canonical.some((address, index) => address !== addresses[index]) || - !addresses.some((address) => isOperatorTrustablePrivateIp(address)) + canonical.some((address, index) => address !== addresses[index]) ) { throw new Error(`trusted private policy endpoint '${host}' has non-canonical private pins`); } @@ -306,7 +301,7 @@ export async function prepareTrustedPrivatePolicyPresets( `Trusted private host '${host}' failed destination preflight: ${result.reason ?? "validation failed"}.`, ); } - if (!isTrustedPrivateEndpointCapability(result.trustedPrivateCapability)) { + if (!result.trustedPrivateCapability) { if (requiredHostSet.has(host)) { throw new Error( `Trusted private host '${host}' did not resolve to an operator-trustable private address.`, @@ -314,29 +309,21 @@ export async function prepareTrustedPrivatePolicyPresets( } continue; } - const resolvedPins = [ - ...new Set( - (result.addresses?.length - ? result.addresses - : result.trustedPrivateCapability.addresses - ).map((address) => address.toLowerCase()), - ), - ].sort(); - const capabilityPins = [...result.trustedPrivateCapability.addresses] - .map((address) => address.toLowerCase()) - .sort(); - if ( - resolvedPins.length !== capabilityPins.length || - resolvedPins.some((address, index) => address !== capabilityPins[index]) - ) { + const resolvedPins = result.addresses?.length + ? result.addresses + : result.trustedPrivateCapability.addresses; + let pins: readonly string[]; + try { + pins = assertTrustedPrivateEndpointCapability( + host, + resolvedPins, + result.trustedPrivateCapability, + ).addresses; + } catch { throw new Error( - `Trusted private host '${host}' returned mixed public and private addresses. Trusted-private policy endpoints must resolve only to supported routed private addresses.`, + `Trusted private host '${host}' returned address pins that do not match its capability.`, ); } - const pins = capabilityPins; - if (pins.length === 0) { - throw new Error(`Trusted private host '${host}' produced no validated address pins.`); - } for (const { endpoint } of references) { endpoint.allowed_ips = [...pins]; for (const parsedPreset of parsedPresets) { diff --git a/src/lib/security/trusted-private-endpoint.test.ts b/src/lib/security/trusted-private-endpoint.test.ts index 92c9bfce12d..0fb1b120008 100644 --- a/src/lib/security/trusted-private-endpoint.test.ts +++ b/src/lib/security/trusted-private-endpoint.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it, vi } from "vitest"; import { assertEndpointResolvesPublic, + assertTrustedPrivateEndpointCapability, + canonicalizeTrustedPrivateEndpointPins, type EndpointDnsLookupFn, isOperatorTrustablePrivateIp, isTrustedPrivateEndpointCapability, @@ -95,9 +97,45 @@ describe("trusted private endpoint preflight", () => { addresses: ["10.0.0.8"], trustedPrivateEndpoint: true, }); + expect(result.trustedPrivateCapability?.host).toBe("mcp.corp.example"); expect(result.trustedPrivateCapability?.addresses).toEqual(["10.0.0.8"]); expect(isTrustedPrivateEndpointCapability(result.trustedPrivateCapability)).toBe(true); - expect(isTrustedPrivateEndpointCapability({ addresses: ["10.0.0.8"] })).toBe(false); + expect( + isTrustedPrivateEndpointCapability({ host: "mcp.corp.example", addresses: ["10.0.0.8"] }), + ).toBe(false); + }); + + it("pins every accepted mixed public and trusted-private answer (#8176)", async () => { + const result = await assertEndpointResolvesPublic( + "https://mcp.corp.example/mcp", + vi.fn(async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "10.0.0.8", family: 4 }, + ]), + { trustedPrivateHosts: ["mcp.corp.example"] }, + ); + + expect(result).toMatchObject({ + ok: true, + addresses: ["10.0.0.8", "93.184.216.34"], + trustedPrivateEndpoint: true, + }); + expect(result.trustedPrivateCapability).toMatchObject({ + host: "mcp.corp.example", + addresses: ["10.0.0.8", "93.184.216.34"], + }); + }); + + it.each([ + "http://127.0.0.1:8000/v1", + "https://inference.local/v1", + "http://host.openshell.internal:8000/v1", + ])("rejects the generic local endpoint %s before resolution (#8176)", async (endpointUrl) => { + const lookup = vi.fn(); + const result = await assertEndpointResolvesPublic(endpointUrl, lookup); + + expect(result).toMatchObject({ ok: false, reasonCode: "rejected" }); + expect(lookup).not.toHaveBeenCalled(); }); it("rejects a private result for a different exact host (#8176)", async () => { @@ -133,6 +171,40 @@ describe("trusted private endpoint preflight", () => { }); describe("trusted private endpoint replay", () => { + it("uses one canonical pin contract for durable and capability authority (#8176)", async () => { + const result = await assertEndpointResolvesPublic( + "https://mcp.corp.example/mcp", + async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "10.0.0.8", family: 4 }, + ], + { trustedPrivateHosts: ["mcp.corp.example"] }, + ); + const canonical = canonicalizeTrustedPrivateEndpointPins("MCP.CORP.EXAMPLE.", [ + "93.184.216.34", + "10.0.0.8", + ]); + + expect(canonical).toEqual({ + host: "mcp.corp.example", + addresses: ["10.0.0.8", "93.184.216.34"], + }); + expect( + assertTrustedPrivateEndpointCapability( + canonical.host, + canonical.addresses, + result.trustedPrivateCapability, + ).addresses, + ).toEqual(canonical.addresses); + expect(() => + assertTrustedPrivateEndpointCapability( + canonical.host, + ["10.0.0.8"], + result.trustedPrivateCapability, + ), + ).toThrow(/exact pins/); + }); + it("reissues capability authority from exact durable pins without DNS (#8267)", () => { const replay = replayTrustedPrivateEndpoint("MCP.CORP.EXAMPLE.", [ "fd00:0:0:0:0:0:0:10", @@ -141,10 +213,28 @@ describe("trusted private endpoint replay", () => { expect(replay.host).toBe("mcp.corp.example"); expect(replay.addresses).toEqual(["10.0.0.8", "fd00::10"]); + expect(replay.trustedPrivateCapability.host).toBe("mcp.corp.example"); expect(replay.trustedPrivateCapability.addresses).toEqual(replay.addresses); expect(isTrustedPrivateEndpointCapability(replay.trustedPrivateCapability)).toBe(true); }); + it("replays complete mixed pins for consumers that allow them (#8176)", () => { + const replay = replayTrustedPrivateEndpoint("mcp.corp.example", ["93.184.216.34", "10.0.0.8"]); + + expect(replay.trustedPrivateCapability).toMatchObject({ + host: "mcp.corp.example", + addresses: ["10.0.0.8", "93.184.216.34"], + }); + }); + + it("rejects mixed durable pins for consumers that require routed-private pins (#8267)", () => { + expect(() => + replayTrustedPrivateEndpoint("mcp.corp.example", ["10.0.0.8", "93.184.216.34"], { + requireAllPrivate: true, + }), + ).toThrow(/outside the supported private ranges/); + }); + it.each([ ["no pins", []], ["public pin", ["93.184.216.34"]], diff --git a/src/lib/security/trusted-private-endpoint.ts b/src/lib/security/trusted-private-endpoint.ts index 2db7b63c970..b49b83752cb 100644 --- a/src/lib/security/trusted-private-endpoint.ts +++ b/src/lib/security/trusted-private-endpoint.ts @@ -42,11 +42,14 @@ OPERATOR_TRUSTABLE_PRIVATE_NETWORKS.addSubnet("fc00::", 7, "ipv6"); declare const trustedPrivateEndpointCapabilityBrand: unique symbol; /** - * Ephemeral proof that the shared SSRF preflight admitted an exact set of - * operator-trusted private addresses. Callers can carry this value, but only - * this module can issue one and the curl boundary validates its provenance. + * Ephemeral proof that the shared SSRF preflight admitted the exact pins for + * an operator-trusted private host. The set includes every accepted public and + * private DNS answer so consumers can pin without re-resolving. Callers can + * carry this value, but only this module can issue one and enforcement + * boundaries validate its provenance. */ export interface TrustedPrivateEndpointCapability { + readonly host: string; readonly addresses: readonly string[]; readonly [trustedPrivateEndpointCapabilityBrand]: true; } @@ -54,9 +57,11 @@ export interface TrustedPrivateEndpointCapability { const TRUSTED_PRIVATE_ENDPOINT_CAPABILITIES = new WeakSet(); function issueTrustedPrivateEndpointCapability( + host: string, addresses: readonly string[], ): TrustedPrivateEndpointCapability { const capability = Object.freeze({ + host, addresses: Object.freeze([...new Set(addresses.map(normalizeIpLiteral))].sort()), }) as unknown as TrustedPrivateEndpointCapability; TRUSTED_PRIVATE_ENDPOINT_CAPABILITIES.add(capability); @@ -98,17 +103,16 @@ export interface EndpointSsrfPreflightResult { /** Exact rejected DNS answer when `reasonCode` identifies an address failure. */ offendingAddress?: string; /** - * Validated public addresses the endpoint host resolved to, for connection + * Every validated address the endpoint host resolved to, for connection * pinning (curl `--resolve`) so a subsequent probe cannot re-resolve the name - * to a rebound private/internal address (TOCTOU). Present only when - * `ok === true` and pinning applies — resolved public names and public IP - * literals. An empty array is the explicit trusted-no-pin capability for - * loopback, OpenShell-managed aliases, and public IP literals. Callers must - * preserve it so credentialed probes bypass ambient proxies even when no - * curl `--resolve` argument is needed. + * to a rebound private/internal address (TOCTOU). Present when `ok === true` + * and pinning applies. An empty array is the explicit trusted-no-pin result + * for inference-local endpoints and IP literals. Callers must preserve it so + * credentialed probes bypass ambient proxies even when no curl `--resolve` + * argument is needed. */ addresses?: string[]; - /** Non-forgeable proof of the exact private addresses admitted by the operator allowlist. */ + /** Non-forgeable proof of the exact accepted pins for an operator-trusted private host. */ trustedPrivateCapability?: TrustedPrivateEndpointCapability; /** True only when an exact operator allowlist entry admitted a private address. */ trustedPrivateEndpoint?: true; @@ -125,6 +129,13 @@ function normalizeIpLiteral(address: string): string { return hostname.slice(1, -1).toLowerCase(); } +function normalizeEndpointHostname(hostname: string): string { + const value = + hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + const normalized = value.replace(/\.$/, "").toLowerCase(); + return isIP(normalized) === 0 ? normalized : normalizeIpLiteral(normalized); +} + /** Normalize one exact hostname or IP literal for an operator trust decision. */ export function normalizeTrustedPrivateHost(raw: string): string { const value = String(raw).trim(); @@ -190,31 +201,106 @@ export interface TrustedPrivateEndpointReplay { readonly trustedPrivateCapability: TrustedPrivateEndpointCapability; } -/** Reissue in-process capability authority from exact durable private pins. */ -export function replayTrustedPrivateEndpoint( +export interface TrustedPrivateEndpointPinOptions { + /** Require every pin to be in a supported routed-private range. */ + requireAllPrivate?: boolean; +} + +export interface TrustedPrivateEndpointPins { + readonly host: string; + readonly addresses: readonly string[]; +} + +/** + * Validate and canonicalize exact pins without granting network authority. + * Callers must use a provenance-checked capability before enforcement. + */ +export function canonicalizeTrustedPrivateEndpointPins( host: string, addresses: readonly string[], -): TrustedPrivateEndpointReplay { + options: TrustedPrivateEndpointPinOptions = {}, +): TrustedPrivateEndpointPins { const normalizedHost = normalizeTrustedPrivateHost(host); if (addresses.length === 0) { throw new Error(`trusted private host "${normalizedHost}" has no recorded address pins`); } + const { isPrivateIp } = require("../private-networks") as typeof import("../private-networks"); const normalizedAddresses = addresses.map((address) => { - if (typeof address !== "string" || !isOperatorTrustablePrivateIp(address)) { + if (typeof address !== "string" || isIP(address) === 0 || address.includes("%")) { throw new Error( `trusted private host "${normalizedHost}" has a disallowed recorded address pin`, ); } - return normalizeIpLiteral(address); + const normalizedAddress = normalizeIpLiteral(address); + if (isPrivateIp(normalizedAddress) && !isOperatorTrustablePrivateIp(normalizedAddress)) { + throw new Error( + `trusted private host "${normalizedHost}" has a disallowed recorded address pin`, + ); + } + return normalizedAddress; }); if (new Set(normalizedAddresses).size !== normalizedAddresses.length) { throw new Error(`trusted private host "${normalizedHost}" has duplicate recorded address pins`); } - const pinnedAddresses = Object.freeze([...normalizedAddresses].sort()); + if (!normalizedAddresses.some((address) => isOperatorTrustablePrivateIp(address))) { + throw new Error( + `trusted private host "${normalizedHost}" has no recorded address pin in a supported private range`, + ); + } + if ( + options.requireAllPrivate && + normalizedAddresses.some((address) => !isOperatorTrustablePrivateIp(address)) + ) { + throw new Error( + `trusted private host "${normalizedHost}" has an address pin outside the supported private ranges`, + ); + } return Object.freeze({ host: normalizedHost, - addresses: pinnedAddresses, - trustedPrivateCapability: issueTrustedPrivateEndpointCapability(pinnedAddresses), + addresses: Object.freeze([...normalizedAddresses].sort()), + }); +} + +/** Prove that an issued capability grants the exact canonical host and pins. */ +export function assertTrustedPrivateEndpointCapability( + host: string, + addresses: readonly string[], + capability: unknown, + options: TrustedPrivateEndpointPinOptions = {}, +): TrustedPrivateEndpointReplay { + if (!isTrustedPrivateEndpointCapability(capability)) { + throw new Error("trusted private endpoint capability was not issued by the SSRF preflight"); + } + const pins = canonicalizeTrustedPrivateEndpointPins(host, addresses, options); + if (capability.host !== pins.host) { + throw new Error( + `trusted private endpoint capability host '${capability.host}' does not match '${pins.host}'`, + ); + } + if ( + pins.addresses.length !== addresses.length || + pins.addresses.some((address, index) => address !== addresses[index]) || + capability.addresses.length !== pins.addresses.length || + capability.addresses.some((address, index) => address !== pins.addresses[index]) + ) { + throw new Error("trusted private endpoint capability addresses do not match the exact pins"); + } + return Object.freeze({ + ...pins, + trustedPrivateCapability: capability, + }); +} + +/** Reissue in-process capability authority from exact durable private pins. */ +export function replayTrustedPrivateEndpoint( + host: string, + addresses: readonly string[], + options: TrustedPrivateEndpointPinOptions = {}, +): TrustedPrivateEndpointReplay { + const pins = canonicalizeTrustedPrivateEndpointPins(host, addresses, options); + return Object.freeze({ + ...pins, + trustedPrivateCapability: issueTrustedPrivateEndpointCapability(pins.host, pins.addresses), }); } @@ -230,11 +316,10 @@ export function replayTrustedPrivateEndpoint( * authoritative config-write DNS-pinning boundary (`validateUrlValueWithDnsResult`) * which runs later, before the URL is persisted. * - * Loopback (`127.0.0.0/8`, `::1`, and `localhost`) remains exempt only when the - * endpoint hostname is itself loopback. This preserves local inference - * behavior. A public name that resolves to loopback is treated as a rebinding - * attempt and rejected. The check fails closed on a resolver error or empty - * result. + * Generic endpoint admission rejects loopback and managed host aliases. The + * inference compatibility wrapper owns its narrower local exceptions. A + * public name that resolves to loopback is treated as a rebinding attempt and + * rejected. The check fails closed on a resolver error or empty result. * * See PR #6293 PRA-4 (GPT-5.5 advisor). */ @@ -260,10 +345,9 @@ export async function assertEndpointResolvesPublic( const { isLoopbackHostname, isPrivateHostname, isPrivateIp } = require("../private-networks") as typeof import("../private-networks"); - let normalizedHostname: string; + const normalizedHostname = normalizeEndpointHostname(hostname); let trustedPrivateHosts: string[]; try { - normalizedHostname = normalizeTrustedPrivateHost(hostname); trustedPrivateHosts = (options.trustedPrivateHosts ?? []).map(normalizeTrustedPrivateHost); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -271,15 +355,13 @@ export async function assertEndpointResolvesPublic( } const trustedPrivateHost = trustedPrivateHosts.includes(normalizedHostname); - // An explicit loopback host is a legitimate local inference server. - if (isLoopbackHostname(hostname)) return { ok: true, addresses: [] }; - - // NemoClaw's own OpenShell-managed aliases (inference.local, host.*.internal) - // resolve to the managed proxy/loopback by design and are trusted, not - // rebinding surfaces. Exempt like loopback — connect normally (no pinning) — - // and exempt BEFORE isPrivateHostname, which would otherwise reject their - // reserved .local/.internal suffixes (#6293). - if (isOpenShellManagedHost(hostname)) return { ok: true, addresses: [] }; + if (isLoopbackHostname(hostname) || isOpenShellManagedHost(hostname)) { + return { + ok: false, + reason: `endpoint host "${hostname}" is a private/internal address`, + reasonCode: "rejected", + }; + } // A literal private IP or reserved private name is refused without resolving. if (isPrivateHostname(hostname) && !trustedPrivateHost) { @@ -299,7 +381,9 @@ export async function assertEndpointResolvesPublic( ? { ok: true, addresses: [], - trustedPrivateCapability: issueTrustedPrivateEndpointCapability([bare]), + trustedPrivateCapability: issueTrustedPrivateEndpointCapability(normalizedHostname, [ + bare, + ]), trustedPrivateEndpoint: true, } : { @@ -327,45 +411,44 @@ export async function assertEndpointResolvesPublic( reasonCode: "unresolved", }; } + const resolvedAddresses: string[] = []; for (const { address } of addresses) { + if (typeof address !== "string" || isIP(address) === 0) { + return { + ok: false, + reason: `endpoint host "${hostname}" returned a malformed DNS address`, + reasonCode: "rejected", + }; + } + const normalizedAddress = normalizeIpLiteral(address); // A resolved private address — including loopback reached via a public name // (DNS rebinding) — is refused; the explicit-loopback case returned above. - if (isPrivateIp(address) && (!trustedPrivateHost || !isOperatorTrustablePrivateIp(address))) { + if ( + isPrivateIp(normalizedAddress) && + (!trustedPrivateHost || !isOperatorTrustablePrivateIp(normalizedAddress)) + ) { return { ok: false, - reason: `endpoint host "${hostname}" resolves to private/internal address "${address}"`, + reason: `endpoint host "${hostname}" resolves to private/internal address "${normalizedAddress}"`, reasonCode: "private-answer", - offendingAddress: address, + offendingAddress: normalizedAddress, }; } + resolvedAddresses.push(normalizedAddress); } - const resolvedAddresses = addresses.map(({ address }) => address); + const canonicalAddresses = [...new Set(resolvedAddresses)].sort(); const trustedPrivateAddresses = resolvedAddresses.filter((address) => isPrivateIp(address)); - if ( - trustedPrivateHost && - trustedPrivateAddresses.length > 0 && - trustedPrivateAddresses.length !== resolvedAddresses.length - ) { - const offendingAddress = resolvedAddresses.find( - (address) => !isOperatorTrustablePrivateIp(address), - ); - return { - ok: false, - reason: - `endpoint host "${hostname}" resolves to mixed public and private addresses` + - (offendingAddress ? `, including untrusted answer "${offendingAddress}"` : ""), - reasonCode: "mixed-answer", - offendingAddress, - }; - } return trustedPrivateHost && trustedPrivateAddresses.length > 0 ? { ok: true, - addresses: resolvedAddresses, - trustedPrivateCapability: issueTrustedPrivateEndpointCapability(trustedPrivateAddresses), + addresses: canonicalAddresses, + trustedPrivateCapability: issueTrustedPrivateEndpointCapability( + normalizedHostname, + canonicalAddresses, + ), trustedPrivateEndpoint: true, } - : { ok: true, addresses: resolvedAddresses }; + : { ok: true, addresses: canonicalAddresses }; } /** diff --git a/src/lib/shields/mcp-policy-transition.test.ts b/src/lib/shields/mcp-policy-transition.test.ts index be00142d5aa..b93b82c21ed 100644 --- a/src/lib/shields/mcp-policy-transition.test.ts +++ b/src/lib/shields/mcp-policy-transition.test.ts @@ -15,6 +15,10 @@ import { buildMcpBridgePolicyName, buildMcpBridgePolicyYaml, } from "../actions/sandbox/mcp-bridge-policy-render"; +import { + isOperatorTrustablePrivateIp, + replayTrustedPrivateEndpoint, +} from "../security/trusted-private-endpoint"; import type { SandboxEntry } from "../state/registry"; import { assertLegacyMcpPolicyRestoreSafe, @@ -28,11 +32,20 @@ function registeredPolicy( server: string, address: string, ): NonNullable[number] { + const host = `${server}.example.com`; + const target = isOperatorTrustablePrivateIp(address) + ? (() => { + const replay = replayTrustedPrivateEndpoint(host, [address]); + return { + addresses: [...replay.addresses], + trustedPrivateCapability: replay.trustedPrivateCapability, + trustedPrivateHost: replay.host, + }; + })() + : { addresses: [address] }; return { name: buildMcpBridgePolicyName(server), - content: buildMcpBridgePolicyYaml(server, `https://${server}.example.com/mcp`, ADAPTER, [ - address, - ]), + content: buildMcpBridgePolicyYaml(server, `https://${host}/mcp`, ADAPTER, target), sourcePath: MCP_BRIDGE_POLICY_SOURCE, }; } diff --git a/src/lib/state/registry-mcp.ts b/src/lib/state/registry-mcp.ts index 335eb69590b..d98d76ce976 100644 --- a/src/lib/state/registry-mcp.ts +++ b/src/lib/state/registry-mcp.ts @@ -4,7 +4,7 @@ import { isObjectRecord } from "../core/json-types"; import { isBlockedMcpUrlTargetHost, MCP_SERVER_URL_MAX_LENGTH } from "../security/mcp-url-target"; import { - isOperatorTrustablePrivateIp, + canonicalizeTrustedPrivateEndpointPins, normalizeTrustedPrivateHost, } from "../security/trusted-private-endpoint"; @@ -157,26 +157,24 @@ function normalizeMcpBridgeEntry(server: string, value: unknown): McpBridgeEntry let allowedIps: string[] | undefined; const rawAllowedIps = value.allowedIps; if (trustedPrivateHost) { - if ( - !Array.isArray(rawAllowedIps) || - rawAllowedIps.length === 0 || - !rawAllowedIps.every( - (address): address is string => - typeof address === "string" && - address === address.toLowerCase() && - isOperatorTrustablePrivateIp(address), - ) - ) { + if (!Array.isArray(rawAllowedIps)) return null; + let canonicalPins: readonly string[]; + try { + canonicalPins = canonicalizeTrustedPrivateEndpointPins( + trustedPrivateHost, + rawAllowedIps as readonly string[], + { requireAllPrivate: true }, + ).addresses; + } catch { return null; } - const validatedAllowedIps = rawAllowedIps as string[]; - allowedIps = [...new Set(validatedAllowedIps)].sort(); if ( - allowedIps.length !== validatedAllowedIps.length || - allowedIps.some((address, index) => address !== validatedAllowedIps[index]) + canonicalPins.length !== rawAllowedIps.length || + canonicalPins.some((address, index) => address !== rawAllowedIps[index]) ) { return null; } + allowedIps = [...canonicalPins]; } else if (rawAllowedIps !== undefined) { return null; } diff --git a/test/helpers/shields-flow-harness.ts b/test/helpers/shields-flow-harness.ts index 0278f76635c..9e6c4927c97 100644 --- a/test/helpers/shields-flow-harness.ts +++ b/test/helpers/shields-flow-harness.ts @@ -70,7 +70,7 @@ export function managedMcpPolicy(server: string, address = "8.8.8.8") { server, `https://${server}.example.com/mcp`, "hermes-config", - [address], + { addresses: [address] }, ); const entries = Object.entries(YAML.parse(content).network_policies as Record); expect(entries, `rendered MCP policies for ${server}`).toHaveLength(1); diff --git a/test/hermes-mcp-config-transaction.test.ts b/test/hermes-mcp-config-transaction.test.ts index 3e5b0e615e8..c9063d24720 100644 --- a/test/hermes-mcp-config-transaction.test.ts +++ b/test/hermes-mcp-config-transaction.test.ts @@ -64,15 +64,6 @@ if len(errors) != len(bad): { url: "https://host.containers.internal:31337/mcp", accepted: false }, { url: "https://8.8.8.8/mcp", accepted: true }, { url: "http://mcp.example.com/mcp", accepted: false }, - { url: "https://localhost/mcp", accepted: false }, - { url: "https://service.internal/mcp", accepted: false }, - { url: "https://127.0.0.1/mcp", accepted: false }, - { url: "https://10.0.0.1/mcp", accepted: false }, - { url: "https://100.64.0.1/mcp", accepted: false }, - { url: "https://169.254.169.254/mcp", accepted: false }, - { url: "https://192.0.2.1/mcp", accepted: false }, - { url: "https://198.18.0.1/mcp", accepted: false }, - { url: "https://224.0.0.1/mcp", accepted: false }, { url: "https://[::1]/mcp", accepted: false }, { url: "https://[fc00::1]/mcp", accepted: false }, { url: "https://[fe80::1]/mcp", accepted: false }, diff --git a/test/hermes-mcp-private-target-validation.test.ts b/test/hermes-mcp-private-target-validation.test.ts new file mode 100644 index 00000000000..d9dd34eb4cb --- /dev/null +++ b/test/hermes-mcp-private-target-validation.test.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const TRANSACTION = path.resolve( + import.meta.dirname, + "..", + "agents/hermes/mcp-config-transaction.py", +); + +function runPython(source: string) { + return spawnSync("python3", ["-c", source, TRANSACTION], { + encoding: "utf8", + }); +} + +describe("Hermes managed MCP private target validation", () => { + it("accepts host-validated private targets without accepting malformed or unsupported hosts (#8267)", () => { + const result = runPython(` +import importlib.util, json, sys, types +yaml_stub = types.ModuleType("yaml") +yaml_stub.YAMLError = Exception +sys.modules["yaml"] = yaml_stub +spec = importlib.util.spec_from_file_location("mcp_tx", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) + +accepted_urls = ( + "https://10.20.30.40/mcp", + "https://mcp.corp.internal/mcp", +) +rejected_urls = ( + "https://host.openshell.internal/mcp", + "https://host.docker.internal/mcp", + "https://host.containers.internal/mcp", + "https://mcp..corp.internal/mcp", + "https://0177.0.0.1/mcp", + "https://[fc00::1]/mcp", +) + +def payload(url): + return { + "server": "fake", + "url": url, + "headers": {"Authorization": "Bearer openshell:resolve:env:FAKE_TOKEN"}, + "replace_existing": False, + } + +accepted = [] +for url in accepted_urls: + module._validate_payload("add", payload(url)) + accepted.append(url) +rejected = [] +for url in rejected_urls: + try: + module._validate_payload("add", payload(url)) + except ValueError: + rejected.append(url) +print(json.dumps({"accepted": accepted, "rejected": rejected})) +`); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + accepted: ["https://10.20.30.40/mcp", "https://mcp.corp.internal/mcp"], + rejected: [ + "https://host.openshell.internal/mcp", + "https://host.docker.internal/mcp", + "https://host.containers.internal/mcp", + "https://mcp..corp.internal/mcp", + "https://0177.0.0.1/mcp", + "https://[fc00::1]/mcp", + ], + }); + }); +}); diff --git a/test/hermes-mcp-shields-order.test.ts b/test/hermes-mcp-shields-order.test.ts index c8641b4768e..f3d88ec804f 100644 --- a/test/hermes-mcp-shields-order.test.ts +++ b/test/hermes-mcp-shields-order.test.ts @@ -82,7 +82,7 @@ const register = (name, entry) => { entry.server, entry.url, "hermes-config", - ["8.8.8.8"], + { addresses: ["8.8.8.8"] }, ), sourcePath: "generated:nemoclaw-mcp-bridge", }); diff --git a/test/langchain-deepagents-code-managed-mcp-hardening.test.ts b/test/langchain-deepagents-code-managed-mcp-hardening.test.ts index b6b5bc54a10..26fa9f1bfe1 100644 --- a/test/langchain-deepagents-code-managed-mcp-hardening.test.ts +++ b/test/langchain-deepagents-code-managed-mcp-hardening.test.ts @@ -120,6 +120,61 @@ print("strict-tombstone-ok") }, ); + it("accepts host-validated private targets without accepting malformed or unsupported hosts (#8267)", () => { + const result = runManagedHelper(String.raw` +import fcntl +import importlib.util +import json +import sys + +for name, value in { + "F_SEAL_WRITE": 1, + "F_SEAL_GROW": 2, + "F_SEAL_SHRINK": 4, + "F_SEAL_SEAL": 8, +}.items(): + setattr(fcntl, name, getattr(fcntl, name, value)) +spec = importlib.util.spec_from_file_location("_nemoclaw_managed", sys.argv[1]) +managed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(managed) + +accepted_urls = ( + "https://10.20.30.40/mcp", + "https://mcp.corp.internal/mcp", +) +rejected_urls = ( + "https://host.openshell.internal/mcp", + "https://host.docker.internal/mcp", + "https://host.containers.internal/mcp", + "https://mcp..corp.internal/mcp", + "https://0177.0.0.1/mcp", + "https://[fc00::1]/mcp", +) + +accepted = [managed._validate_managed_mcp_url(url) for url in accepted_urls] +rejected = [] +for url in rejected_urls: + try: + managed._validate_managed_mcp_url(url) + except RuntimeError: + rejected.append(url) +print(json.dumps({"accepted": accepted, "rejected": rejected})) +`); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + accepted: ["https://10.20.30.40/mcp", "https://mcp.corp.internal/mcp"], + rejected: [ + "https://host.openshell.internal/mcp", + "https://host.docker.internal/mcp", + "https://host.containers.internal/mcp", + "https://mcp..corp.internal/mcp", + "https://0177.0.0.1/mcp", + "https://[fc00::1]/mcp", + ], + }); + }); + it("rejects stacked private tmpfs mounts even when the lower mount is compliant (#8018)", () => { const result = runManagedHelper(String.raw` import importlib.util diff --git a/test/mcp-destroy-lifecycle.test.ts b/test/mcp-destroy-lifecycle.test.ts index c9c7c8b9982..eab1ea2f2c8 100644 --- a/test/mcp-destroy-lifecycle.test.ts +++ b/test/mcp-destroy-lifecycle.test.ts @@ -149,12 +149,9 @@ function ownedPolicy( const resolvedAddresses = options.resolvedAddresses ?? [new URL(entry.url).hostname]; return { name: entry.policyName, - content: bridge.buildMcpBridgePolicyYaml( - entry.server, - entry.url, - adapter as AgentMcpAdapter, - resolvedAddresses, - ), + content: bridge.buildMcpBridgePolicyYaml(entry.server, entry.url, adapter as AgentMcpAdapter, { + addresses: [...resolvedAddresses], + }), sourcePath: "generated:nemoclaw-mcp-bridge", }; } diff --git a/test/mcp-policy-key-ownership.test.ts b/test/mcp-policy-key-ownership.test.ts index 532311241e0..35b86677cf2 100644 --- a/test/mcp-policy-key-ownership.test.ts +++ b/test/mcp-policy-key-ownership.test.ts @@ -533,7 +533,9 @@ registry.registerSandbox({ }); registry.addCustomPolicy("alpha", { name: entry.policyName, - content: bridge.buildMcpBridgePolicyYaml(entry.server, entry.url, entry.adapter), + content: bridge.buildMcpBridgePolicyYaml(entry.server, entry.url, entry.adapter, { + addresses: ["8.8.8.8"], + }), sourcePath: "generated:nemoclaw-mcp-bridge", }); diff --git a/test/mcp-policy-transition.test.ts b/test/mcp-policy-transition.test.ts index 636604740c2..2934b3c2294 100644 --- a/test/mcp-policy-transition.test.ts +++ b/test/mcp-policy-transition.test.ts @@ -34,13 +34,13 @@ const oldContent = generated.buildMcpBridgePolicyYaml( entry.server, entry.url, entry.adapter, - ["1.1.1.1"], + { addresses: ["1.1.1.1"] }, ); const desiredContent = generated.buildMcpBridgePolicyYaml( entry.server, entry.url, entry.adapter, - ["8.8.8.8"], + { addresses: ["8.8.8.8"] }, ); let liveContent = oldContent; let applyCalls = 0; @@ -189,7 +189,7 @@ const content = generated.buildMcpBridgePolicyYaml( entry.server, entry.url, entry.adapter, - ["8.8.8.8"], + { addresses: ["8.8.8.8"] }, ); registry.registerSandbox({ name: "alpha", agent: "openclaw" }); registry.addCustomPolicy("alpha", { diff --git a/test/mcp-restart-policy-order.test.ts b/test/mcp-restart-policy-order.test.ts index 072384f531f..fd510efc8c9 100644 --- a/test/mcp-restart-policy-order.test.ts +++ b/test/mcp-restart-policy-order.test.ts @@ -93,7 +93,9 @@ registry.registerSandbox({ }); registry.addCustomPolicy("alpha", { name: entry.policyName, - content: generated.buildMcpBridgePolicyYaml(entry.server, entry.url, entry.adapter, ["8.8.8.8"]), + content: generated.buildMcpBridgePolicyYaml(entry.server, entry.url, entry.adapter, { + addresses: ["8.8.8.8"], + }), sourcePath: "generated:nemoclaw-mcp-bridge", }); @@ -220,7 +222,9 @@ registry.registerSandbox({ }); registry.addCustomPolicy("alpha", { name: entry.policyName, - content: generated.buildMcpBridgePolicyYaml(entry.server, entry.url, entry.adapter, ["8.8.8.8"]), + content: generated.buildMcpBridgePolicyYaml(entry.server, entry.url, entry.adapter, { + addresses: ["8.8.8.8"], + }), sourcePath: "generated:nemoclaw-mcp-bridge", }); From f9f02ae33c6ec07bf49afb33ba909151c561910b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 7 Aug 2026 11:16:56 -0400 Subject: [PATCH 2/4] docs(mcp): clarify private endpoint pinning Signed-off-by: Julie Yaunches --- docs/inference/custom-endpoint-security.mdx | 6 +++++- docs/inference/set-up-openai-compatible-endpoint.mdx | 8 ++++++-- docs/manage-sandboxes/add-mcp-server.mdx | 2 +- docs/network-policy/create-custom-policy-presets.mdx | 3 +++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/inference/custom-endpoint-security.mdx b/docs/inference/custom-endpoint-security.mdx index 83daee9c8b4..d355ba30ef4 100644 --- a/docs/inference/custom-endpoint-security.mdx +++ b/docs/inference/custom-endpoint-security.mdx @@ -38,7 +38,11 @@ Managed provider defaults that do not provide an explicit custom endpoint throug Custom endpoint onboarding has one narrower operator-controlled exception for corporate inference gateways. Set `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` to a comma-separated list of exact hostnames or IP literals to admit an endpoint on RFC1918, carrier-grade network address translation (CGNAT), or IPv6 unique local address space. -NemoClaw still resolves DNS, pins the validation connection, and rejects wildcard or suffix matches, link-local metadata, reserved destinations, and resolver failures. +NemoClaw still resolves DNS and pins outbound validation to the complete canonical address set. +An exact trusted host can return both public and supported private addresses. +NemoClaw pins every canonical answer. +If any answer is a disallowed private, reserved, or special-purpose address, validation rejects the endpoint instead of discarding that answer. +Wildcard or suffix matches and resolver failures also remain blocked. This allowlist does not relax direct blueprint, `config set`, or unrelated persisted-URL validation. `NEMOCLAW_TRUSTED_PRIVATE_INFERENCE_HOSTS` remains an inference-only compatibility alias. diff --git a/docs/inference/set-up-openai-compatible-endpoint.mdx b/docs/inference/set-up-openai-compatible-endpoint.mdx index 6663f2370b9..569de140c10 100644 --- a/docs/inference/set-up-openai-compatible-endpoint.mdx +++ b/docs/inference/set-up-openai-compatible-endpoint.mdx @@ -150,8 +150,12 @@ NEMOCLAW_TRUSTED_PRIVATE_HOSTS=llm.corp.example \ $$nemoclaw onboard --non-interactive ``` -NemoClaw still resolves the host before probing and pins the probe to the resolved address. -Only RFC1918, carrier-grade network address translation (CGNAT), and IPv6 unique local address (ULA) destinations can be admitted; link-local metadata and other reserved ranges remain blocked. +NemoClaw still resolves the host before probing and pins outbound validation to the complete canonical address set. +An exact trusted host can return both public and supported private addresses. +NemoClaw pins every canonical answer. +If any answer is a disallowed private, reserved, or special-purpose address, validation rejects the endpoint instead of discarding that answer. +Among private answers, NemoClaw admits only RFC1918, carrier-grade network address translation (CGNAT), and IPv6 unique local address (ULA) destinations. +Link-local metadata and other reserved ranges remain blocked. An unlisted private host, a hostname suffix match, or a DNS failure also remains blocked. ## Related Topics diff --git a/docs/manage-sandboxes/add-mcp-server.mdx b/docs/manage-sandboxes/add-mcp-server.mdx index e15dc0c3fe4..b46ab566256 100644 --- a/docs/manage-sandboxes/add-mcp-server.mdx +++ b/docs/manage-sandboxes/add-mcp-server.mdx @@ -94,7 +94,7 @@ unset LOCAL_MCP_TOKEN The declaration must equal the normalized host from `--url`. NemoClaw rejects unused, unrelated, wildcard, suffix, CIDR, URL-shaped, duplicate, or malformed `--trusted-private-host` declarations before mutation. -It also rejects a hostname when any answer is outside the admitted private ranges or otherwise disallowed. +It also rejects a trusted-private hostname when its DNS answers mix public and private addresses, or when any answer is otherwise disallowed. As an alternative, set `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` to a comma-separated list of exact hosts for the current command. NemoClaw combines the environment list with any `--trusted-private-host` options. diff --git a/docs/network-policy/create-custom-policy-presets.mdx b/docs/network-policy/create-custom-policy-presets.mdx index 034d28cb583..a1440a50f86 100644 --- a/docs/network-policy/create-custom-policy-presets.mdx +++ b/docs/network-policy/create-custom-policy-presets.mdx @@ -97,6 +97,9 @@ NemoClaw combines the environment list with any `--trusted-private-host` options It normalizes and deduplicates environment entries and ignores entries unrelated to the custom preset batch. After schema validation, NemoClaw resolves each declared endpoint and inserts every validated address as an exact `allowed_ips` value in memory. +An exact trusted host can return both public and supported private addresses. +NemoClaw pins every canonical answer. +If any answer is a disallowed private, reserved, or special-purpose address, validation rejects the preset instead of discarding that answer. The dry-run output shows the generated pins for review. NemoClaw applies and records the transformed preset instead of the unpinned source file. Rebuild replays recorded pins from the sandbox registry without depending on the ambient environment. From e3028b21c2b2392143725390e81575f5d3347b8b Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 7 Aug 2026 11:37:18 -0400 Subject: [PATCH 3/4] fix(mcp): retain inner destination guards Signed-off-by: Julie Yaunches --- agents/hermes/mcp-config-transaction.py | 18 ++++++++++++++++-- .../managed-dcode-runtime.py | 18 ++++++++++++++++-- src/lib/actions/sandbox/mcp-bridge-policy.ts | 2 +- ...ermes-mcp-private-target-validation.test.ts | 7 ++++++- ...epagents-code-managed-mcp-hardening.test.ts | 7 ++++++- test/mcp-add-crash-consistency.test.ts | 1 + test/mcp-provider-ownership.test.ts | 1 + 7 files changed, 47 insertions(+), 7 deletions(-) diff --git a/agents/hermes/mcp-config-transaction.py b/agents/hermes/mcp-config-transaction.py index 9bc278172c8..4127ea8fdc3 100755 --- a/agents/hermes/mcp-config-transaction.py +++ b/agents/hermes/mcp-config-transaction.py @@ -58,6 +58,15 @@ MCP_DNS_LABEL_RE = re.compile( r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$" ) +MCP_ROUTED_PRIVATE_IPV4_NETWORKS = tuple( + ipaddress.ip_network(cidr) + for cidr in ( + "10.0.0.0/8", + "100.64.0.0/10", + "172.16.0.0/12", + "192.168.0.0/16", + ) +) ENV_PLACEHOLDER_RE = re.compile( r"^Bearer openshell:resolve:env:([A-Za-z_][A-Za-z0-9_]{0,127})$" ) @@ -313,8 +322,8 @@ def _validate_payload(action: str, payload: dict[str, object]) -> None: "Authenticated MCP OpenShell host aliases are unavailable with OpenShell v0.0.85" ) # Host preflight owns destination trust and binds every accepted endpoint to - # exact OpenShell address pins. Parse IP literals here only to distinguish a - # canonical address from an ambiguous numeric hostname. + # exact OpenShell address pins. This in-sandbox check revalidates canonical + # syntax and rejects IPv4 literals outside public or routed-private ranges. try: address = ipaddress.ip_address(hostname) except ValueError: @@ -332,6 +341,11 @@ def _validate_payload(action: str, payload: dict[str, object]) -> None: raise ValueError( "MCP mutation payload URL hostname must use canonical DNS labels" ) + elif not ( + (address.is_global and not address.is_multicast) + or any(address in network for network in MCP_ROUTED_PRIVATE_IPV4_NETWORKS) + ): + raise ValueError("MCP mutation payload URL uses a disallowed address") path = parsed.path or "/" path_segments = path.split("/") if ( diff --git a/agents/langchain-deepagents-code/managed-dcode-runtime.py b/agents/langchain-deepagents-code/managed-dcode-runtime.py index 1181da91a89..ec274df84d8 100644 --- a/agents/langchain-deepagents-code/managed-dcode-runtime.py +++ b/agents/langchain-deepagents-code/managed-dcode-runtime.py @@ -155,6 +155,15 @@ "host.docker.internal", "host.containers.internal", } +_MCP_ROUTED_PRIVATE_IPV4_NETWORKS = tuple( + ipaddress.ip_network(cidr) + for cidr in ( + "10.0.0.0/8", + "100.64.0.0/10", + "172.16.0.0/12", + "192.168.0.0/16", + ) +) _MANAGED_MCP_FD: int | None = None _MANAGED_MCP_BINDING: dict[str, int | str] | None = None _MANAGED_MCP_CHILD_BINDING: dict[str, int | str] | None = None @@ -345,8 +354,8 @@ def _validate_managed_mcp_hostname(hostname: str) -> None: ): raise RuntimeError("managed MCP server URL hostname is invalid") # Host preflight owns destination trust and binds every accepted endpoint to - # exact OpenShell address pins. This runtime revalidates canonical syntax, - # but it does not classify an already-admitted IPv4 or DNS destination. + # exact OpenShell address pins. This runtime revalidates canonical syntax and + # rejects IPv4 literals outside public or routed-private ranges. try: address = ipaddress.ip_address(hostname) except ValueError: @@ -359,6 +368,11 @@ def _validate_managed_mcp_hostname(hostname: str) -> None: return if address.version != 4: raise RuntimeError("managed MCP server URL does not support IPv6 literals") + if not ( + (address.is_global and not address.is_multicast) + or any(address in network for network in _MCP_ROUTED_PRIVATE_IPV4_NETWORKS) + ): + raise RuntimeError("managed MCP server URL address is not an admitted destination") def _validate_managed_mcp_url(value: object) -> str: diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.ts b/src/lib/actions/sandbox/mcp-bridge-policy.ts index 1c4e639dd14..1762d6058c6 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.ts @@ -777,7 +777,7 @@ export function assertGeneratedPolicyMutationSafe( if (state === "absent") return; if (!owned || state !== "match") { throw new McpBridgeError( - `Generated MCP policy '${entry.policyName}' is unowned, unreachable, or drifted. Refusing to mutate the adapter, provider, or same-key live policy until ownership is resolved.`, + `Generated MCP policy '${entry.policyName}' is unowned, unreachable, or drifted. Refusing to mutate the adapter, provider, or same-key live policy until ownership is resolved. The registry entry was preserved so cleanup can be retried.`, ); } } diff --git a/test/hermes-mcp-private-target-validation.test.ts b/test/hermes-mcp-private-target-validation.test.ts index d9dd34eb4cb..7dba0ead90f 100644 --- a/test/hermes-mcp-private-target-validation.test.ts +++ b/test/hermes-mcp-private-target-validation.test.ts @@ -31,6 +31,7 @@ sys.modules[spec.name] = module spec.loader.exec_module(module) accepted_urls = ( + "https://8.8.8.8/mcp", "https://10.20.30.40/mcp", "https://mcp.corp.internal/mcp", ) @@ -39,6 +40,8 @@ rejected_urls = ( "https://host.docker.internal/mcp", "https://host.containers.internal/mcp", "https://mcp..corp.internal/mcp", + "https://127.0.0.1/mcp", + "https://169.254.169.254/mcp", "https://0177.0.0.1/mcp", "https://[fc00::1]/mcp", ) @@ -66,12 +69,14 @@ print(json.dumps({"accepted": accepted, "rejected": rejected})) expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ - accepted: ["https://10.20.30.40/mcp", "https://mcp.corp.internal/mcp"], + accepted: ["https://8.8.8.8/mcp", "https://10.20.30.40/mcp", "https://mcp.corp.internal/mcp"], rejected: [ "https://host.openshell.internal/mcp", "https://host.docker.internal/mcp", "https://host.containers.internal/mcp", "https://mcp..corp.internal/mcp", + "https://127.0.0.1/mcp", + "https://169.254.169.254/mcp", "https://0177.0.0.1/mcp", "https://[fc00::1]/mcp", ], diff --git a/test/langchain-deepagents-code-managed-mcp-hardening.test.ts b/test/langchain-deepagents-code-managed-mcp-hardening.test.ts index 26fa9f1bfe1..296fdc33c53 100644 --- a/test/langchain-deepagents-code-managed-mcp-hardening.test.ts +++ b/test/langchain-deepagents-code-managed-mcp-hardening.test.ts @@ -139,6 +139,7 @@ managed = importlib.util.module_from_spec(spec) spec.loader.exec_module(managed) accepted_urls = ( + "https://8.8.8.8/mcp", "https://10.20.30.40/mcp", "https://mcp.corp.internal/mcp", ) @@ -147,6 +148,8 @@ rejected_urls = ( "https://host.docker.internal/mcp", "https://host.containers.internal/mcp", "https://mcp..corp.internal/mcp", + "https://127.0.0.1/mcp", + "https://169.254.169.254/mcp", "https://0177.0.0.1/mcp", "https://[fc00::1]/mcp", ) @@ -163,12 +166,14 @@ print(json.dumps({"accepted": accepted, "rejected": rejected})) expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ - accepted: ["https://10.20.30.40/mcp", "https://mcp.corp.internal/mcp"], + accepted: ["https://8.8.8.8/mcp", "https://10.20.30.40/mcp", "https://mcp.corp.internal/mcp"], rejected: [ "https://host.openshell.internal/mcp", "https://host.docker.internal/mcp", "https://host.containers.internal/mcp", "https://mcp..corp.internal/mcp", + "https://127.0.0.1/mcp", + "https://169.254.169.254/mcp", "https://0177.0.0.1/mcp", "https://[fc00::1]/mcp", ], diff --git a/test/mcp-add-crash-consistency.test.ts b/test/mcp-add-crash-consistency.test.ts index 49454d99525..4b2f74b1153 100644 --- a/test/mcp-add-crash-consistency.test.ts +++ b/test/mcp-add-crash-consistency.test.ts @@ -282,6 +282,7 @@ providerCommands.runOpenshellProviderCommand = (args) => { }; policies.getPresetContentGatewayState = () => marked("policy") ? "match" : "absent"; +policies.getLiveSandboxPolicyEntryDigest = () => marked("policy") ? "present" : null; policies.removePreset = () => { fs.rmSync(marker("policy"), { force: true }); return true; diff --git a/test/mcp-provider-ownership.test.ts b/test/mcp-provider-ownership.test.ts index cc6d9410411..fe5b8e95bb7 100644 --- a/test/mcp-provider-ownership.test.ts +++ b/test/mcp-provider-ownership.test.ts @@ -513,6 +513,7 @@ gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ after: { state: "healthy_named" }, }); policies.getPresetContentGatewayState = () => "absent"; +policies.getLiveSandboxPolicyEntryDigest = () => null; policies.removePreset = () => true; processRecovery.executeSandboxCommand = () => ({ status: 0, stdout: "", stderr: "" }); processRecovery.executeSandboxExecCommand = () => ({ status: 0, stdout: "", stderr: "" }); From 2ca1cb2d77e5b059fa178263221ec9c5d0ddf6f4 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Fri, 7 Aug 2026 12:10:53 -0400 Subject: [PATCH 4/4] refactor(mcp): remove unreachable validation paths Signed-off-by: Julie Yaunches --- src/lib/actions/sandbox/mcp-bridge-url-validation.ts | 7 +------ src/lib/security/trusted-private-endpoint.ts | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/lib/actions/sandbox/mcp-bridge-url-validation.ts b/src/lib/actions/sandbox/mcp-bridge-url-validation.ts index 7e3e26b6e58..46095939a35 100644 --- a/src/lib/actions/sandbox/mcp-bridge-url-validation.ts +++ b/src/lib/actions/sandbox/mcp-bridge-url-validation.ts @@ -400,12 +400,7 @@ export function parseMcpUrlWithValidatedTarget( } const trustedPrivateHost = authority.host; - let rawParsed: URL; - try { - rawParsed = new URL(rawUrl); - } catch { - return new URL(normalizeMcpServerUrl(rawUrl, { trustedPrivateHosts: [trustedPrivateHost] })); - } + const rawParsed = new URL(rawUrl); if (normalizeTrustedPrivateHost(rawParsed.hostname) !== trustedPrivateHost) { throw new McpBridgeError( `Validated private MCP target host '${trustedPrivateHost}' does not match URL host '${rawParsed.hostname}'.`, diff --git a/src/lib/security/trusted-private-endpoint.ts b/src/lib/security/trusted-private-endpoint.ts index b49b83752cb..772eb33f5ad 100644 --- a/src/lib/security/trusted-private-endpoint.ts +++ b/src/lib/security/trusted-private-endpoint.ts @@ -99,7 +99,7 @@ export interface EndpointSsrfPreflightResult { /** Human-readable reason, present only when `ok === false`. */ reason?: string; /** Stable failure classification for callers that must not parse `reason`. */ - reasonCode?: "mixed-answer" | "private-answer" | "rejected" | "unresolved"; + reasonCode?: "private-answer" | "rejected" | "unresolved"; /** Exact rejected DNS answer when `reasonCode` identifies an address failure. */ offendingAddress?: string; /**