Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 33 additions & 41 deletions agents/hermes/mcp-config-transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@
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])?$"
)
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})$"
)
Expand Down Expand Up @@ -83,29 +95,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",
Expand Down Expand Up @@ -310,7 +299,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:
Expand All @@ -332,28 +321,31 @@ 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. 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:
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)
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"
)
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 non-global address")
raise ValueError("MCP mutation payload URL uses a disallowed address")
path = parsed.path or "/"
path_segments = path.split("/")
if (
Expand Down
40 changes: 12 additions & 28 deletions agents/langchain-deepagents-code/managed-dcode-runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,28 +155,13 @@
"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",
_MCP_ROUTED_PRIVATE_IPV4_NETWORKS = tuple(
ipaddress.ip_network(cidr)
for cidr in (
"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
Expand Down Expand Up @@ -366,13 +351,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 and
# rejects IPv4 literals outside public or routed-private ranges.
try:
address = ipaddress.ip_address(hostname)
except ValueError:
Expand All @@ -383,12 +366,13 @@ 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)
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 public IPv4")
raise RuntimeError("managed MCP server URL address is not an admitted destination")


def _validate_managed_mcp_url(value: object) -> str:
Expand Down
6 changes: 5 additions & 1 deletion docs/inference/custom-endpoint-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions docs/inference/set-up-openai-compatible-endpoint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/manage-sandboxes/add-mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/network-policy/create-custom-policy-presets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 2 additions & 6 deletions src/lib/actions/sandbox/mcp-bridge-add-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -209,6 +204,7 @@ async function addMcpBridgeUnlocked(
const replay = replayTrustedPrivateEndpoint(
existingEntry.trustedPrivateHost,
existingEntry.allowedIps ?? [],
{ requireAllPrivate: true },
);
target = {
addresses: [...replay.addresses],
Expand Down
68 changes: 63 additions & 5 deletions src/lib/actions/sandbox/mcp-bridge-input-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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], {
Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 11 additions & 13 deletions src/lib/actions/sandbox/mcp-bridge-policy-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand Down
Loading
Loading