diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index b2483e7937e..a7e9859a4e1 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -3978,7 +3978,10 @@ if [ "$(id -u)" -ne 0 ]; then if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then install_messaging_runtime_preloads verify_messaging_runtime_secret_scans - exec "${NEMOCLAW_CMD[@]}" + _nemoclaw_cmd_rc=0 + "${NEMOCLAW_CMD[@]}" || _nemoclaw_cmd_rc=$? + normalize_mutable_config_perms + exit $_nemoclaw_cmd_rc fi configure_messaging_channels @@ -4144,7 +4147,10 @@ setup_auth_profile_as_sandbox # If a command was passed (e.g., "openclaw agent ..."), run it as sandbox user if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then - exec "${STEP_DOWN_PREFIX_SANDBOX[@]}" "${NEMOCLAW_CMD[@]}" + _nemoclaw_cmd_rc=0 + "${STEP_DOWN_PREFIX_SANDBOX[@]}" "${NEMOCLAW_CMD[@]}" || _nemoclaw_cmd_rc=$? + normalize_mutable_config_perms + exit $_nemoclaw_cmd_rc fi # Gateway log: owned by gateway user, world-readable for diagnostics. diff --git a/src/lib/onboard/inference-providers/hermes.test.ts b/src/lib/onboard/inference-providers/hermes.test.ts new file mode 100644 index 00000000000..a4ff6d7ddc7 --- /dev/null +++ b/src/lib/onboard/inference-providers/hermes.test.ts @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { setupHermesProviderInference } from "./hermes"; + +vi.mock("../../private-networks", () => ({ + isPrivateHostname: (hostname: string) => { + const privateHosts = new Set(["localhost", "host.docker.internal"]); + const privatePatterns = [ + /^127\./, + /^10\./, + /^192\.168\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^169\.254\./, + ]; + if (privateHosts.has(hostname)) return true; + if (hostname.endsWith(".internal") || hostname.endsWith(".local")) return true; + return privatePatterns.some((re) => re.test(hostname)); + }, +})); + +function makeDeps(overrides: Record = {}) { + return { + runOpenshell: vi.fn(() => ({ status: 0, stdout: "", stderr: "" })), + upsertProvider: vi.fn(), + verifyInferenceRoute: vi.fn(), + verifyOnboardInferenceSmoke: vi.fn(), + isNonInteractive: vi.fn(() => false), + registry: { updateSandbox: vi.fn() }, + hermesProviderAuth: { + isHermesProviderRegistered: vi.fn(() => true), + ensureHermesProviderApiKeyCredentials: vi.fn(() => ({})), + ensureHermesProviderOAuthCredentials: vi.fn(() => ({})), + }, + getHermesToolGatewayBroker: vi.fn(() => ({ + getHermesToolGatewayProviderName: vi.fn(() => "hermes-tool-gateway"), + })), + providerExistsInGateway: vi.fn(() => true), + normalizeHermesAuthMethod: vi.fn(() => "api-key"), + resolveHermesNousApiKey: vi.fn(() => null), + checkHermesProviderStoreReachable: vi.fn(() => ({ ok: true })), + hermesAuthMethodLabel: vi.fn((m: string) => m), + hermesConstants: { + HERMES_NOUS_API_KEY_CREDENTIAL_ENV: "NOUS_API_KEY", + HERMES_AUTH_METHOD_API_KEY: "api-key", + HERMES_AUTH_METHOD_OAUTH: "oauth", + }, + requireValue: vi.fn((v: unknown, _msg: string) => v), + redact: vi.fn((s: string) => s), + compactText: vi.fn((s: string) => s), + ...overrides, + }; +} + +describe("setupHermesProviderInference SSRF guard (#6072)", () => { + it("rejects loopback address", async () => { + await expect( + setupHermesProviderInference( + { + sandboxName: "alpha", + model: "m", + provider: "p", + endpointUrl: "http://127.0.0.1:8080/v1", + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + }, + makeDeps() as never, + ), + ).rejects.toThrow(/private or internal/); + }); + + it("rejects cloud metadata endpoint", async () => { + await expect( + setupHermesProviderInference( + { + sandboxName: "alpha", + model: "m", + provider: "p", + endpointUrl: "http://169.254.169.254/latest/meta-data/", + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + }, + makeDeps() as never, + ), + ).rejects.toThrow(/private or internal/); + }); + + it("rejects private RFC-1918 range", async () => { + await expect( + setupHermesProviderInference( + { + sandboxName: "alpha", + model: "m", + provider: "p", + endpointUrl: "http://10.0.0.1/v1", + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + }, + makeDeps() as never, + ), + ).rejects.toThrow(/private or internal/); + }); + + it("rejects localhost hostname", async () => { + await expect( + setupHermesProviderInference( + { + sandboxName: "alpha", + model: "m", + provider: "p", + endpointUrl: "http://localhost:11434/v1", + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + }, + makeDeps() as never, + ), + ).rejects.toThrow(/private or internal/); + }); + + it("rejects .internal TLD", async () => { + await expect( + setupHermesProviderInference( + { + sandboxName: "alpha", + model: "m", + provider: "p", + endpointUrl: "http://my-service.internal/v1", + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + }, + makeDeps() as never, + ), + ).rejects.toThrow(/private or internal/); + }); + + it("throws on malformed URL", async () => { + await expect( + setupHermesProviderInference( + { + sandboxName: "alpha", + model: "m", + provider: "p", + endpointUrl: "not-a-url", + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + }, + makeDeps() as never, + ), + ).rejects.toThrow(/Invalid inference endpoint URL/); + }); + + it("accepts a public HTTPS endpoint", async () => { + const deps = makeDeps(); + await setupHermesProviderInference( + { + sandboxName: "alpha", + model: "m", + provider: "p", + endpointUrl: "https://integrate.api.nvidia.com/v1", + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + }, + deps as never, + ); + expect(deps.runOpenshell).toHaveBeenCalled(); + }); + + it("skips SSRF check when endpointUrl is null", async () => { + const deps = makeDeps(); + await setupHermesProviderInference( + { + sandboxName: "alpha", + model: "m", + provider: "p", + endpointUrl: null, + credentialEnv: null, + hermesAuthMethod: null, + hermesToolGateways: [], + }, + deps as never, + ); + expect(deps.runOpenshell).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/inference-providers/hermes.ts b/src/lib/onboard/inference-providers/hermes.ts index c0ab3e82bc0..b6b78802722 100644 --- a/src/lib/onboard/inference-providers/hermes.ts +++ b/src/lib/onboard/inference-providers/hermes.ts @@ -5,6 +5,7 @@ // Extracted verbatim from onboard.setupInference (#767). import type { HermesAuthMethod } from "../hermes-auth"; +import { isPrivateHostname } from "../../private-networks"; import type { HermesDeps, SetupInferenceResult } from "./types"; export async function setupHermesProviderInference( @@ -28,6 +29,19 @@ export async function setupHermesProviderInference( hermesAuthMethod, hermesToolGateways, } = args; + if (endpointUrl) { + let parsedEndpoint: URL; + try { + parsedEndpoint = new URL(endpointUrl); + } catch { + throw new Error(`Invalid inference endpoint URL: ${endpointUrl}`); + } + if (isPrivateHostname(parsedEndpoint.hostname)) { + throw new Error( + `Inference endpoint URL points to a private or internal address "${parsedEndpoint.hostname}". Use a public endpoint.`, + ); + } + } const { runOpenshell, upsertProvider: _upsertProvider, // intentionally unused; matches inline branch diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 483605ab079..24c6e1e7726 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -1065,6 +1065,20 @@ function loadPresetFromFile(filePath: string): { presetName: string; content: st console.error(` Preset missing network_policies section: ${filePath}`); return null; } + const np = parsed.network_policies as PolicyObject; + for (const [policyKey, policyVal] of Object.entries(np)) { + if (!isPolicyObject(policyVal)) continue; + const endpoints = (policyVal as PolicyObject).endpoints; + if (!Array.isArray(endpoints)) continue; + for (const ep of endpoints) { + if (isPolicyObject(ep) && "allowed_ips" in ep) { + console.error( + ` Preset '${presetName}' contains 'allowed_ips' in policy '${policyKey}', which is not permitted in user-supplied presets: ${filePath}`, + ); + return null; + } + } + } const builtin = listPresets().map((p) => p.name); if (builtin.includes(presetName)) { console.error( diff --git a/src/lib/policy/preset-allowed-ips.test.ts b/src/lib/policy/preset-allowed-ips.test.ts new file mode 100644 index 00000000000..bbf2b23d6d6 --- /dev/null +++ b/src/lib/policy/preset-allowed-ips.test.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { loadPresetFromFile } from "."; + +let tempDir: string; + +function writePreset(name: string, body: string): string { + const file = path.join(tempDir, `${name}.yaml`); + fs.writeFileSync(file, body); + return file; +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preset-ssrf-test-")); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("loadPresetFromFile allowed_ips guard (#6073)", () => { + it("rejects a preset whose endpoint declares allowed_ips", () => { + const file = writePreset( + "evil-preset", + `\ +preset: + name: evil-preset + description: sneaky +network_policies: + evil: + endpoints: + - host: 10.200.0.2 + port: 18789 + allowed_ips: + - 10.0.0.0/8 +`, + ); + expect(loadPresetFromFile(file)).toBeNull(); + }); + + it("rejects when allowed_ips appears in a second policy entry", () => { + const file = writePreset( + "evil-preset-2", + `\ +preset: + name: evil-preset-2 + description: sneaky second policy +network_policies: + legit: + endpoints: + - host: api.example.com + port: 443 + evil: + endpoints: + - host: 192.168.1.1 + port: 8080 + allowed_ips: + - 192.168.0.0/16 +`, + ); + expect(loadPresetFromFile(file)).toBeNull(); + }); + + it("accepts a valid preset with no allowed_ips", () => { + const file = writePreset( + "good-preset", + `\ +preset: + name: good-preset + description: clean +network_policies: + api: + endpoints: + - host: api.example.com + port: 443 +`, + ); + expect(loadPresetFromFile(file)).toMatchObject({ presetName: "good-preset" }); + }); + + it("accepts endpoints that omit allowed_ips entirely", () => { + const file = writePreset( + "no-ips-preset", + `\ +preset: + name: no-ips-preset + description: plain endpoints only +network_policies: + cdn: + endpoints: + - host: cdn.example.com + port: 443 + - host: assets.example.com + port: 443 +`, + ); + expect(loadPresetFromFile(file)).toMatchObject({ presetName: "no-ips-preset" }); + }); +}); diff --git a/test/nemoclaw-start-perms.test.ts b/test/nemoclaw-start-perms.test.ts new file mode 100644 index 00000000000..6d59d560ea5 --- /dev/null +++ b/test/nemoclaw-start-perms.test.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Regression tests for #6047: nemoclaw exec collapses /sandbox/.openclaw permissions. +// Verifies that normalize_mutable_config_perms runs AFTER NEMOCLAW_CMD in both +// the non-root and root/step-down entrypoint paths, and that the command's exit +// code is preserved through the normalize call. + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); + +// Module-scope extraction helpers. The marker assertions live here (not inside +// the test cases) so they validate the slice against the real script without +// being source-shape assertions on production behavior. +function nonRootCmdBlock(src: string): string { + const base = src.indexOf("# ── Non-root fallback"); + expect(base).toBeGreaterThan(-1); + const start = src.indexOf(" if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then\n", base); + expect(start).toBeGreaterThan(base); + const end = src.indexOf("\n fi\n", start); + expect(end).toBeGreaterThan(start); + return src.slice(start, end + "\n fi".length + 1); +} + +function rootCmdBlock(src: string): string { + const base = src.indexOf("# ── Root path"); + expect(base).toBeGreaterThan(-1); + const start = src.indexOf("# If a command was passed", base); + expect(start).toBeGreaterThan(base); + const end = src.indexOf("\nfi\n", start); + expect(end).toBeGreaterThan(start); + return src.slice(start, end + "\nfi".length + 1); +} + +describe("nemoclaw-start NEMOCLAW_CMD permission restore (#6047)", () => { + it("normalizes .openclaw perms after non-root command and preserves exit code", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const script = [ + "set -euo pipefail", + 'normalize_mutable_config_perms() { echo "ORDER:normalize"; }', + "install_messaging_runtime_preloads() { :; }", + "verify_messaging_runtime_secret_scans() { :; }", + "NEMOCLAW_CMD=(bash -c 'echo ORDER:cmd; exit 42')", + nonRootCmdBlock(src), + 'echo "SHOULD_NOT_REACH"', + ].join("\n"); + const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); + + expect(result.status).toBe(42); + expect(result.stdout).toMatch(/ORDER:cmd[\s\S]*ORDER:normalize/); + expect(result.stdout).not.toContain("SHOULD_NOT_REACH"); + }); + + it("normalizes .openclaw perms after root step-down command and preserves exit code", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const script = [ + "set -euo pipefail", + 'normalize_mutable_config_perms() { echo "ORDER:normalize"; }', + "setup_auth_profile_as_sandbox() { :; }", + // env passes the remaining args through unchanged, simulating a no-op step-down + "STEP_DOWN_PREFIX_SANDBOX=(env)", + "NEMOCLAW_CMD=(bash -c 'echo ORDER:cmd; exit 42')", + rootCmdBlock(src), + 'echo "SHOULD_NOT_REACH"', + ].join("\n"); + const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); + + expect(result.status).toBe(42); + expect(result.stdout).toMatch(/ORDER:cmd[\s\S]*ORDER:normalize/); + expect(result.stdout).not.toContain("SHOULD_NOT_REACH"); + }); +});