diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index dfcfc7f3f2f..8a17b00de0b 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -2092,6 +2092,23 @@ function listCustomPresets(sandboxName: string): PresetInfo[] { })); } +/** + * Return every network-policy key that registered custom content declares for + * the sandbox. Registry-only: no live policy read, and unparseable content is + * skipped rather than throwing, because callers use this to decide what to + * preserve during a transition and must not fail the transition on one bad + * registry row. + */ +function registeredNetworkPolicyKeys(sandboxName: string): string[] { + const keys = new Set(); + for (const entry of registry.getCustomPolicies(sandboxName)) { + for (const key of parsePresetPolicyKeysForOwnership(entry.content) ?? []) { + keys.add(key); + } + } + return [...keys]; +} + /** Return whether registered custom content owns an exact live network-policy key. */ function customPresetOwnsNetworkPolicyKey(sandboxName: string, policyKey: string): boolean { let candidates: ReturnType; @@ -2340,6 +2357,7 @@ export { parseCurrentPolicyOrEmpty as parseCurrentPolicy, parsePresetPolicyKeys, presetContentMatchesGateway, + registeredNetworkPolicyKeys, removeBuiltinPresetAttribution, removePreset, removePresetFromPolicy, diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index 73dc30cb306..1b15ff0d2ed 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -8,11 +8,15 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +import YAML from "yaml"; const requireDist = createRequire(import.meta.url); const shieldsModulePath = "./index.js"; type ShieldsHarness = { + // Every document handed to `openshell policy set`, read at call time — + // shieldsDown deletes a temp policy file as soon as the run returns. + appliedPolicies: string[]; auditSpy: MockInstance; errorSpy: MockInstance; logSpy: MockInstance; @@ -30,9 +34,12 @@ const currentProcessStartIdentity = ( ).readProcessStartIdentity(process.pid); type HarnessOptions = { + basePermissiveYaml?: string; beginContainment?: typeof import("../state/mcp-lifecycle-lock.js").beginCommittedMcpLifecycleContainmentSync; + ownedNetworkPolicyKeys?: readonly string[]; directSandboxUnavailable?: boolean; dockerExecFileSync?: (argv: unknown) => string; + livePolicyYaml?: string; failOpenClawGuardActions?: Array<"lock" | "unlock">; invokedAs?: "nemoclaw" | "nemohermes"; openClawGuardFailure?: { @@ -52,7 +59,6 @@ type HarnessOptions = { send: () => boolean; kill: () => boolean; }; - livePolicyYaml?: string; run?: (cmd: unknown) => { status: number }; }; @@ -60,6 +66,14 @@ function throwHarnessError(error: Error): never { throw error; } +function readIfPresent(file: string): string { + try { + return fs.readFileSync(file, "utf-8"); + } catch { + return ""; + } +} + function createHarness(options: HarnessOptions = {}): ShieldsHarness { vi.stubEnv("NEMOCLAW_INVOKED_AS", options.invokedAs ?? "nemoclaw"); delete require.cache[requireDist.resolve(shieldsModulePath)]; @@ -98,12 +112,22 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { }); options.fork && vi.spyOn(childProcess, "fork").mockImplementation(options.fork); vi.spyOn(policy, "buildPolicyGetCommand").mockReturnValue(["openshell", "policy", "get"]); - vi.spyOn(policy, "buildPolicySetCommand").mockReturnValue(["openshell", "policy", "set"]); + vi.spyOn(policy, "registeredNetworkPolicyKeys").mockImplementation( + () => options.ownedNetworkPolicyKeys ?? [], + ); + const appliedPolicies: string[] = []; + vi.spyOn(policy, "buildPolicySetCommand").mockImplementation((policyFile: unknown) => { + appliedPolicies.push(readIfPresent(String(policyFile))); + return ["openshell", "policy", "set"]; + }); vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)); vi.spyOn(policy, "resolvePermissivePolicyPath").mockReturnValue( path.join(tmpDir, "permissive.yaml"), ); - fs.writeFileSync(path.join(tmpDir, "permissive.yaml"), "version: 1\nnetwork_policies: {}\n"); + fs.writeFileSync( + path.join(tmpDir, "permissive.yaml"), + options.basePermissiveYaml ?? "version: 1\nnetwork_policies: {}\n", + ); vi.spyOn(agentConfig, "resolveAgentConfig").mockReturnValue({ agentName: "openclaw", configDir: "/sandbox/.openclaw", @@ -214,6 +238,7 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { errorSpy.mockClear(); auditSpy.mockClear(); return { + appliedPolicies, auditSpy, errorSpy, logSpy, @@ -334,6 +359,40 @@ describe("shields command flow", () => { ); }); + it("shieldsDown keeps a registered MCP route in the policy it applies (#7952)", { + timeout: 15_000, + }, () => { + const harness = createHarness({ + // A sandbox with a registered MCP bridge: the generated route exists + // only in the live policy, never in the static permissive baseline. + livePolicyYaml: [ + "version: 1", + "network_policies:", + " mcp_bridge_fake:", + " name: mcp_bridge_fake", + " endpoints:", + " - host: mcp.example.com", + " port: 443", + " protocol: mcp", + "", + ].join("\n"), + basePermissiveYaml: "version: 1\nnetwork_policies:\n nvidia: {}\n", + // The registry records this route, so shields down must carry it. + ownedNetworkPolicyKeys: ["mcp_bridge_fake"], + }); + + harness.shieldsDown("openclaw", { skipTimer: true, throwOnError: true }); + + const applied = YAML.parse(harness.appliedPolicies.at(-1) ?? ""); + // The live route survives the swap intact, and the baseline's own + // routes are still there. + expect(applied.network_policies.mcp_bridge_fake).toEqual({ + name: "mcp_bridge_fake", + endpoints: [{ host: "mcp.example.com", port: 443, protocol: "mcp" }], + }); + expect(applied.network_policies.nvidia).toBeDefined(); + }); + it("binds manual shields-up to the active auto-restore timer generation", () => { const stateDir = path.join(tmpDir, ".nemoclaw", "state"); const sandboxName = "openclaw"; diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 10eda298ec1..01906a6febd 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -38,6 +38,7 @@ const { buildPolicyGetCommand, buildPolicySetCommand, parseCurrentPolicy, + registeredNetworkPolicyKeys, resolvePermissivePolicyPath, } = require("../policy"); const { parseDuration, MAX_SECONDS, DEFAULT_SECONDS } = require("../domain/duration"); @@ -3147,16 +3148,29 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = let policyFileIsTemp = false; if (policyName === "permissive") { const basePath = resolvePermissivePolicyPath(sandboxName); - // Union the live sandbox's filesystem_policy.read_only/read_write into - // the static permissive baseline. OpenShell rejects removal of those - // paths on a live sandbox, and runtime-injected entries (/proc on - // GPU, /opt/hermes on Hermes, /home/linuxbrew on post-#3913 OpenClaw, - // etc.) are not present in the static YAML. See #3942, #3957, #3168. + // Union the live sandbox's filesystem_policy.read_only/read_write and + // its registry-owned network_policies into the static permissive + // baseline. OpenShell rejects removal of those paths on a live + // sandbox, and runtime entries — /proc on GPU, /opt/hermes on Hermes, + // /home/linuxbrew on post-#3913 OpenClaw, a generated MCP bridge route + // — are not present in the static YAML. See #3942, #3957, #3168, #7952. // policyYaml is the pre-parsed body we already captured for the // snapshot above — reuse it instead of re-fetching. policyFile = buildRuntimePermissivePolicy(basePath, { livePolicyYaml: policyYaml, readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), + ownedNetworkPolicyKeys: registeredNetworkPolicyKeys(sandboxName), + onDegraded: (detail: string, lostKeys: readonly string[]) => { + console.warn( + ` Warning: ${detail}, so these registered network routes were not carried into the applied policy: ${lostKeys.join(", ")}.`, + ); + // A registered route is either a generated MCP bridge policy or a + // custom preset the operator added, and they are restored by + // different commands. + console.warn( + ` Re-apply the owning policy: \`${CLI_NAME} ${sandboxName} mcp restart \` for a managed MCP server, or \`${CLI_NAME} ${sandboxName} policy add --from-file \` for a custom preset.`, + ); + }, }); policyFileIsTemp = policyFile !== basePath; } else if (fs.existsSync(policyName)) { diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index 46f523f60be..9933dbe9b28 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -10,14 +10,14 @@ const TEMP_FILE_PREFIX = "nemoclaw-permissive-runtime"; /** * Build a permissive policy YAML whose filesystem path lists - * (`filesystem_policy.read_only` + `filesystem_policy.read_write`) are a - * superset of the live sandbox's, so OpenShell never has to remove a path - * on a live transition. + * (`filesystem_policy.read_only` + `filesystem_policy.read_write`) and + * `network_policies` keys are a superset of the live sandbox's, so a live + * transition never has to remove a path or drop a registered route. * - * Only the two path lists are unioned. Other `filesystem_policy` fields - * (e.g. `include_workdir`) are preserved verbatim from the static base — - * the bug class this helper exists for is path removal on a live sandbox, - * not policy shape changes. + * Only those three collections are unioned. Other `filesystem_policy` + * fields (e.g. `include_workdir`) are preserved verbatim from the static + * base — the bug class this helper exists for is losing live entries on a + * live sandbox, not policy shape changes. * * Background (#3942, #3957, #3168): OpenShell refuses to remove a * `filesystem_policy.read_only` or `filesystem_policy.read_write` entry @@ -39,10 +39,23 @@ const TEMP_FILE_PREFIX = "nemoclaw-permissive-runtime"; * - Live `read_only` is merged into base `read_only` only when the same * path is not already granted `read_write` (either by base or by live). * + * `network_policies` merges by key, and only for keys the registry records + * for this sandbox — an unregistered live route is not NemoClaw's to + * preserve. Of those, a live-only route is copied in, a route the base + * already declares keeps the base's definition, and provider-composed + * `_provider_*` entries are never copied. + * + * When owned routes exist but the merge cannot be produced — the baseline + * is unreadable or unparseable, or the temp file cannot be written — the + * static policy is applied and those routes are lost. That degrade is + * deliberate: aborting shields-down on an I/O error would strand the + * sandbox in a locked posture. It is reported through `onDegraded` so the + * loss is visible rather than silent. + * * Returns the path to a freshly created temp YAML file when the live - * policy carries a filesystem section that needs merging. Falls back to - * the static base path when the live policy is empty / has no filesystem - * lists, when the base YAML cannot be parsed, or when temp-file I/O + * policy carries filesystem lists or network routes that need merging. + * Falls back to the static base path when the live policy is empty / has + * neither, when the base YAML cannot be parsed, or when temp-file I/O * fails — degrading to the existing static apply path rather than * aborting shields-down with an I/O error. */ @@ -60,6 +73,14 @@ export interface PermissiveRuntimeDeps { // secureTempFile when omitted. Exposed so tests can drive the // write-failure fallback path without monkey-patching node:fs. writeTempPolicy?: (yaml: string) => string; + // Network-policy keys the registry records for this sandbox. Only these + // are carried across; an unregistered live route is not NemoClaw's to + // preserve. Empty means carry nothing, which is the pre-#7952 behavior. + ownedNetworkPolicyKeys: readonly string[]; + // Called when owned routes exist but the merge degraded to the static + // policy, so the caller can say so instead of dropping them silently. + // Receives the route keys that were lost. + onDegraded?: (detail: string, lostKeys: readonly string[]) => void; } export function buildRuntimePermissivePolicy( @@ -69,10 +90,18 @@ export function buildRuntimePermissivePolicy( const live = deps.livePolicyYaml ? safeYamlObject(deps.livePolicyYaml) : null; const liveRw = readStringList(live, "read_write"); const liveRo = readStringList(live, "read_only"); + const liveNetwork = readNetworkPolicies(live, deps.ownedNetworkPolicyKeys); + const liveNetworkNames = Object.keys(liveNetwork); + + // Report a degrade only when there was something owned to lose. + const degrade = (detail: string): string => { + if (liveNetworkNames.length > 0) deps.onDegraded?.(detail, liveNetworkNames); + return basePermissivePath; + }; - // No live filesystem section to merge — keep the static path so the - // caller's apply path is unchanged. - if (liveRw.length === 0 && liveRo.length === 0) { + // Nothing live to merge — keep the static path so the caller's apply + // path is unchanged. + if (liveRw.length === 0 && liveRo.length === 0 && liveNetworkNames.length === 0) { return basePermissivePath; } @@ -80,11 +109,11 @@ export function buildRuntimePermissivePolicy( try { baseYaml = deps.readBasePolicy(); } catch { - return basePermissivePath; + return degrade("the permissive baseline could not be read"); } const base = safeYamlObject(baseYaml); if (!base) { - return basePermissivePath; + return degrade("the permissive baseline could not be parsed"); } const fsPolicy = base.filesystem_policy && typeof base.filesystem_policy === "object" @@ -108,12 +137,38 @@ export function buildRuntimePermissivePolicy( fsPolicy.read_write = [...baseRw]; fsPolicy.read_only = [...baseRo]; + // Carry live-only network routes across the swap. The static baseline is + // a fixed allowlist, so a route registered at runtime — a generated MCP + // bridge entry, a sandbox-scoped custom preset — is absent from it and + // would be dropped by the whole-document replacement, leaving the + // registry owning a route the gateway no longer serves (#7952). + // + // The static base wins on a shared key, so the applied document is + // unchanged for every key the baseline already declares. That relies on + // the baseline being the more permissive of the two, which nothing here + // enforces — it holds today because the dynamic writers namespace their + // keys (`mcp_bridge_*`) and so never collide with a baseline key. A + // future dynamic policy preset that reuses a baseline key would be + // silently ignored here; give it its own namespace. + if (liveNetworkNames.length > 0) { + const baseNetwork = + base.network_policies && + typeof base.network_policies === "object" && + !Array.isArray(base.network_policies) + ? (base.network_policies as Record) + : ((base.network_policies = {} as Record), + base.network_policies as Record); + for (const name of liveNetworkNames) { + if (!(name in baseNetwork)) baseNetwork[name] = liveNetwork[name]; + } + } + const yaml = YAML.stringify(base); if (deps.writeTempPolicy) { try { return deps.writeTempPolicy(yaml); } catch { - return basePermissivePath; + return degrade("the merged policy could not be written"); } } let tmpPath: string | null = null; @@ -126,7 +181,7 @@ export function buildRuntimePermissivePolicy( // writeFileSync failed. Clean it up so we do not leak a 0700 dir // on /tmp every time the write path errors. if (tmpPath) cleanupTempDir(tmpPath, TEMP_FILE_PREFIX); - return basePermissivePath; + return degrade("the merged policy could not be written"); } } @@ -142,6 +197,29 @@ function safeYamlObject(text: string): Record | null { return null; } +// `policy get --base` can defensively include provider-composed +// `_provider_*` entries that `policy set` must never receive. Every other +// read-modify-write path filters them (see withoutProviderComposedPolicies +// in nemoclaw/src/shared/openshell-policy-boundary.cts); this one is a +// write path too, so it filters them as well. Inlined rather than imported +// because that module is only reachable through a built artifact, and this +// helper must stay dependency-free. +const PROVIDER_COMPOSED_PREFIX = "_provider_"; + +function readNetworkPolicies( + root: Record | null, + ownedKeys: readonly string[], +): Record { + const value = root?.network_policies; + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const owned = new Set(ownedKeys); + return Object.fromEntries( + Object.entries(value as Record).filter( + ([name]) => owned.has(name) && !name.startsWith(PROVIDER_COMPOSED_PREFIX), + ), + ); +} + function readStringList( root: Record | null, key: "read_only" | "read_write", diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts index 504d9a2898d..2b268564e39 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -7,6 +7,8 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import YAML from "yaml"; +import { buildMcpBridgePolicyYaml } from "../src/lib/actions/sandbox/mcp-bridge-policy-render.js"; +import { withoutProviderComposedPolicies } from "../src/lib/policy/merge.js"; import { buildRuntimePermissivePolicy } from "../src/lib/shields/permissive-runtime.js"; const BASE_PERMISSIVE = YAML.stringify({ @@ -18,6 +20,30 @@ const BASE_PERMISSIVE = YAML.stringify({ landlock: { compatibility: "best_effort" }, }); +// Mirrors the shape of the shipped baseline: a fixed network allowlist that +// cannot see routes registered at runtime. +const BASE_PERMISSIVE_WITH_NETWORK = YAML.stringify({ + filesystem_policy: { + include_workdir: true, + read_only: ["/proc", "/etc"], + read_write: ["/tmp", "/sandbox/.openclaw"], + }, + network_policies: { + nvidia: { + name: "nvidia", + endpoints: [{ host: "integrate.api.nvidia.com", port: 443, access: "full" }], + binaries: [{ path: "/**" }], + }, + }, +}); + +// The real generated MCP entry, not a hand-written approximation. +const GENERATED_MCP = YAML.parse( + buildMcpBridgePolicyYaml("fake", "https://mcp.example.com/mcp", "hermes-config", ["203.0.113.7"]), +).network_policies as Record; + +const MCP_KEY = "mcp_bridge_fake"; + const tempFilesToClean: string[] = []; function trackTempForCleanup(out: string, basePath: string): void { @@ -56,6 +82,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, }); @@ -76,6 +103,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, }); @@ -96,6 +124,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, }); @@ -117,6 +146,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, }); @@ -137,6 +167,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { it("returns the static base path when live policy is empty", () => { const basePath = "/path/to/static.yaml"; const out = buildRuntimePermissivePolicy(basePath, { + ownedNetworkPolicyKeys: [], livePolicyYaml: "", readBasePolicy: () => BASE_PERMISSIVE, }); @@ -147,6 +178,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { const basePath = "/path/to/static.yaml"; const liveYaml = YAML.stringify({ landlock: { compatibility: "best_effort" } }); const out = buildRuntimePermissivePolicy(basePath, { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, }); @@ -159,6 +191,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { filesystem_policy: { read_write: ["/proc"] }, }); const out = buildRuntimePermissivePolicy(basePath, { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => { throw new Error("ENOENT"); @@ -173,6 +206,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { filesystem_policy: { read_write: ["/proc"] }, }); const out = buildRuntimePermissivePolicy(basePath, { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => "::: not yaml :::", }); @@ -186,6 +220,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); let writeAttempts = 0; const out = buildRuntimePermissivePolicy(basePath, { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, writeTempPolicy: () => { @@ -197,3 +232,244 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { expect(writeAttempts).toBe(1); }); }); + +describe("buildRuntimePermissivePolicy network routes (#7952)", () => { + it("keeps a registered MCP route that only the live policy knows about", () => { + const liveYaml = YAML.stringify({ + filesystem_policy: { read_only: ["/etc"], read_write: ["/tmp"] }, + network_policies: { ...GENERATED_MCP }, + }); + + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: [MCP_KEY], + }); + trackTempForCleanup(out, "/unused-base.yaml"); + expect(out).not.toBe("/unused-base.yaml"); + + const result = YAML.parse(fs.readFileSync(out, "utf-8")); + expect(result.network_policies[MCP_KEY]).toEqual(GENERATED_MCP[MCP_KEY]); + // The baseline's own routes survive untouched. + expect(result.network_policies.nvidia.name).toBe("nvidia"); + }); + + it("merges live routes even when the live policy has no filesystem section", () => { + const liveYaml = YAML.stringify({ network_policies: { ...GENERATED_MCP } }); + + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: [MCP_KEY], + }); + trackTempForCleanup(out, "/unused-base.yaml"); + expect(out).not.toBe("/unused-base.yaml"); + + const result = YAML.parse(fs.readFileSync(out, "utf-8")); + expect(result.network_policies[MCP_KEY]).toEqual(GENERATED_MCP[MCP_KEY]); + }); + + it("keeps the permissive baseline entry when a route name exists in both", () => { + const liveYaml = YAML.stringify({ + filesystem_policy: { read_write: ["/tmp"] }, + network_policies: { + nvidia: { + name: "nvidia", + // A narrower live rule must not tighten the permissive posture. + endpoints: [{ host: "integrate.api.nvidia.com", port: 443, access: "read" }], + }, + }, + }); + + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: ["nvidia"], + }); + trackTempForCleanup(out, "/unused-base.yaml"); + + const result = YAML.parse(fs.readFileSync(out, "utf-8")); + expect(result.network_policies.nvidia.endpoints[0].access).toBe("full"); + }); + + it("never carries provider-composed entries into the applied policy", () => { + const liveYaml = YAML.stringify({ + filesystem_policy: { read_write: ["/tmp"] }, + network_policies: { + ...GENERATED_MCP, + _provider_openai: { name: "_provider_openai", endpoints: [] }, + }, + }); + + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + // Even if a provider-composed key were recorded as owned, it must + // never reach `policy set`. + ownedNetworkPolicyKeys: [MCP_KEY, "_provider_openai"], + }); + trackTempForCleanup(out, "/unused-base.yaml"); + + const result = YAML.parse(fs.readFileSync(out, "utf-8")); + expect(result.network_policies[MCP_KEY]).toBeDefined(); + expect(result.network_policies._provider_openai).toBeUndefined(); + }); + + it("still returns the static base path when the live policy is entirely empty", () => { + const basePath = "/path/to/static.yaml"; + const liveYaml = YAML.stringify({ landlock: { compatibility: "best_effort" } }); + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: [MCP_KEY], + }); + expect(out).toBe(basePath); + }); + + it("ignores a live network_policies that is not a mapping", () => { + const basePath = "/path/to/static.yaml"; + const liveYaml = YAML.stringify({ network_policies: ["not", "a", "mapping"] }); + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: [MCP_KEY], + }); + expect(out).toBe(basePath); + }); + + it("carries across exactly what the canonical provider filter keeps (#7952)", () => { + // This helper inlines the provider-composed prefix instead of importing + // the canonical filter, which is only reachable through a built + // artifact. Compare the two by behavior so the copies cannot drift. + const live = { + ...GENERATED_MCP, + nvidia: { name: "nvidia" }, + _provider_openai: { name: "_provider_openai" }, + _provider_nvidia_nim: { name: "_provider_nvidia_nim" }, + }; + const expected = Object.keys(withoutProviderComposedPolicies(live)).sort(); + + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: YAML.stringify({ network_policies: live }), + // A base with no routes of its own, so the applied document holds + // exactly what the merge chose to carry across. + readBasePolicy: () => YAML.stringify({ filesystem_policy: { include_workdir: true } }), + // Every live key is registered here, so the only thing that can + // remove one is the provider-composed filter itself. + ownedNetworkPolicyKeys: Object.keys(live), + }); + trackTempForCleanup(out, "/unused-base.yaml"); + + const result = YAML.parse(fs.readFileSync(out, "utf-8")); + expect(Object.keys(result.network_policies).sort()).toEqual(expected); + expect(expected).toContain(MCP_KEY); + }); + + it("leaves an unregistered live route behind (#7952)", () => { + const liveYaml = YAML.stringify({ + network_policies: { + ...GENERATED_MCP, + // Present on the live sandbox but absent from the registry, so it + // is not NemoClaw's to carry into the permissive policy. + stray_route: { name: "stray_route", endpoints: [{ host: "stray.example.com" }] }, + }, + }); + + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: [MCP_KEY], + }); + trackTempForCleanup(out, "/unused-base.yaml"); + + const result = YAML.parse(fs.readFileSync(out, "utf-8")); + expect(result.network_policies[MCP_KEY]).toEqual(GENERATED_MCP[MCP_KEY]); + expect(result.network_policies.stray_route).toBeUndefined(); + }); + + it("carries nothing when the registry records no routes (#7952)", () => { + const basePath = "/path/to/static.yaml"; + const liveYaml = YAML.stringify({ network_policies: { ...GENERATED_MCP } }); + + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: [], + }); + + expect(out).toBe(basePath); + }); + + it("reports the loss when an owned route cannot be merged (#7952)", () => { + const basePath = "/path/to/static.yaml"; + const liveYaml = YAML.stringify({ network_policies: { ...GENERATED_MCP } }); + const degraded: string[] = []; + + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => { + throw new Error("ENOENT"); + }, + ownedNetworkPolicyKeys: [MCP_KEY], + onDegraded: (detail, lostKeys) => degraded.push(`${detail}: ${lostKeys.join(",")}`), + }); + + // Degrading to the static policy is deliberate; going silent is not. + expect(out).toBe(basePath); + expect(degraded).toEqual([`the permissive baseline could not be read: ${MCP_KEY}`]); + }); + + it("reports the loss when the merged policy cannot be written (#7952)", () => { + const basePath = "/path/to/static.yaml"; + const liveYaml = YAML.stringify({ network_policies: { ...GENERATED_MCP } }); + const degraded: string[] = []; + + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: [MCP_KEY], + writeTempPolicy: () => { + throw new Error("ENOSPC"); + }, + onDegraded: (detail, lostKeys) => degraded.push(`${detail}: ${lostKeys.join(",")}`), + }); + + expect(out).toBe(basePath); + expect(degraded).toEqual([`the merged policy could not be written: ${MCP_KEY}`]); + }); + + it("stays quiet when a degrade loses no owned route (#7952)", () => { + const basePath = "/path/to/static.yaml"; + const liveYaml = YAML.stringify({ + filesystem_policy: { read_write: ["/proc"] }, + network_policies: { ...GENERATED_MCP }, + }); + const degraded: string[] = []; + + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => { + throw new Error("ENOENT"); + }, + ownedNetworkPolicyKeys: [], + onDegraded: (detail) => degraded.push(detail), + }); + // Nothing owned was at stake, so there is nothing to warn about. + + expect(out).toBe(basePath); + expect(degraded).toEqual([]); + }); + + it("replaces a non-mapping base network_policies rather than indexing into it", () => { + const liveYaml = YAML.stringify({ network_policies: { ...GENERATED_MCP } }); + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => YAML.stringify({ network_policies: ["unexpected"] }), + ownedNetworkPolicyKeys: [MCP_KEY], + }); + trackTempForCleanup(out, "/unused-base.yaml"); + + const result = YAML.parse(fs.readFileSync(out, "utf-8")); + expect(result.network_policies[MCP_KEY]).toEqual(GENERATED_MCP[MCP_KEY]); + }); +});