From ed1115c9a51f8548e9bd6d1671a587a5f996dddf Mon Sep 17 00:00:00 2001 From: harjoth Date: Thu, 30 Jul 2026 19:08:50 -0700 Subject: [PATCH 1/5] fix(shields): keep registered network routes when shields down swaps policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shields down` replaces the whole live policy with the static permissive baseline. That baseline is a fixed allowlist, so a route registered at runtime — a generated MCP bridge entry — is dropped. The server stays in the registry with its provider attached and its adapter registered, but the gateway no longer serves it, so tool discovery and tool calls fail until `mcp restart` reapplies the generated policy. Union the live `network_policies` into the permissive document, the same way the helper already unions filesystem paths. The static base wins on a shared key, so every route the baseline declares is applied unchanged. Provider-composed `_provider_*` entries are never copied, because `policy set` must not receive them. Refs: #7952 Signed-off-by: harjoth --- src/lib/shields/flow.test.ts | 59 +++++++++++- src/lib/shields/index.ts | 11 ++- src/lib/shields/permissive-runtime.ts | 73 +++++++++++--- test/permissive-runtime.test.ts | 134 ++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 21 deletions(-) diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index b28e8cec047..13fba634e9f 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -8,6 +8,7 @@ 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"; @@ -26,6 +27,9 @@ setInterval(() => {}, 60000); `; 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; @@ -40,8 +44,10 @@ type ShieldsHarness = { let tmpDir: string; type HarnessOptions = { + basePermissiveYaml?: string; directSandboxUnavailable?: boolean; dockerExecFileSync?: (argv: unknown) => string; + livePolicyYaml?: string; failOpenClawGuardActions?: Array<"lock" | "unlock">; invokedAs?: "nemoclaw" | "nemohermes"; openClawGuardFailure?: { @@ -61,7 +67,6 @@ type HarnessOptions = { send: () => boolean; kill: () => boolean; }; - livePolicyYaml?: string; run?: (cmd: unknown) => { status: number }; }; @@ -69,6 +74,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)]; @@ -99,12 +112,19 @@ 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"]); + 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", @@ -215,6 +235,7 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { errorSpy.mockClear(); auditSpy.mockClear(); return { + appliedPolicies, auditSpy, errorSpy, logSpy, @@ -286,6 +307,38 @@ 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", + }); + + 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 83b664d6337..e5a4dff9b13 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -2862,11 +2862,12 @@ 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 + // 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, { diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index 46f523f60be..c074d306f72 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,14 @@ 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: a live-only route is copied in, and a + * route the base already declares keeps the base's definition. Provider- + * composed `_provider_*` entries are never copied. + * * 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. */ @@ -69,10 +73,12 @@ 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); + const liveNetworkNames = Object.keys(liveNetwork); - // 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; } @@ -108,6 +114,28 @@ 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: it is the more permissive of the + // two by construction, so the applied document is unchanged for every + // key the baseline already declares. + 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 { @@ -142,6 +170,25 @@ 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): Record { + const value = root?.network_policies; + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value as Record).filter( + ([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..cfe32dec917 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -7,6 +7,7 @@ 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 { buildRuntimePermissivePolicy } from "../src/lib/shields/permissive-runtime.js"; const BASE_PERMISSIVE = YAML.stringify({ @@ -18,6 +19,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 { @@ -197,3 +222,112 @@ 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, + }); + 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, + }); + 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, + }); + 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, + }); + 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, + }); + 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, + }); + expect(out).toBe(basePath); + }); + + 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"] }), + }); + 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]); + }); +}); From 2dd8c6b1baa996c027af6f495545e0ce2a74aee8 Mon Sep 17 00:00:00 2001 From: harjoth Date: Thu, 30 Jul 2026 19:36:18 -0700 Subject: [PATCH 2/5] test(shields): compare the inlined provider filter with the canonical one The permissive merge inlines the `_provider_` prefix instead of importing `withoutProviderComposedPolicies`, which is only reachable through a built artifact. Nothing kept the two in sync, so a change to the canonical filter could let provider-composed entries through this write path unnoticed. Drive the merge and the canonical filter with the same routes and assert they keep the same set. The test fails if either side drifts. Refs: #7952 Signed-off-by: harjoth --- test/permissive-runtime.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts index cfe32dec917..9efd8e9ed4a 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -8,6 +8,7 @@ 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({ @@ -319,6 +320,31 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { 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 } }), + }); + 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("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", { From 8c9862719fe9eaec7455e079cd77016b330117a0 Mon Sep 17 00:00:00 2001 From: harjoth Date: Thu, 30 Jul 2026 19:49:42 -0700 Subject: [PATCH 3/5] docs(shields): state the collision assumption in the permissive merge Base-wins on a shared network_policies key relies on the baseline being the more permissive of the two, and nothing enforces that. It holds because the dynamic writers namespace their keys. Say so, so a future dynamic preset does not reuse a baseline key and get silently ignored. Refs: #7952 Signed-off-by: harjoth --- src/lib/shields/permissive-runtime.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index c074d306f72..e17d8b8c5f6 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -120,9 +120,13 @@ export function buildRuntimePermissivePolicy( // 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: it is the more permissive of the - // two by construction, so the applied document is unchanged for every - // key the baseline already declares. + // 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 && From b55ebe3f4bfbc65ddb0288740e9d8231a583aa5b Mon Sep 17 00:00:00 2001 From: harjoth Date: Tue, 4 Aug 2026 09:14:23 -0700 Subject: [PATCH 4/5] fix(shields): carry only registry-owned routes and report a degraded merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked for two contract changes. Narrow the merge to the registered consumer. The permissive swap now carries a live network route only when the registry records it for that sandbox, so an unregistered live route is left behind instead of being promoted into the applied policy. `registeredNetworkPolicyKeys` reads the registry through the existing policy seam, so no module gains a dependency. Stop losing owned routes silently. When the baseline cannot be read or parsed, or the merged policy cannot be written, shields down still applies the static policy — aborting would strand the sandbox in a locked posture — but it now says which routes were dropped and how to restore them. Refs: #7952 Signed-off-by: harjoth --- src/lib/policy/index.ts | 18 ++++ src/lib/shields/flow.test.ts | 6 ++ src/lib/shields/index.ts | 18 ++-- src/lib/shields/permissive-runtime.ts | 46 ++++++++--- test/permissive-runtime.test.ts | 115 ++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 15 deletions(-) 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 0935277065e..ce3de8d4e0a 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -45,6 +45,7 @@ let tmpDir: string; type HarnessOptions = { basePermissiveYaml?: string; + ownedNetworkPolicyKeys?: readonly string[]; directSandboxUnavailable?: boolean; dockerExecFileSync?: (argv: unknown) => string; livePolicyYaml?: string; @@ -112,6 +113,9 @@ 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, "registeredNetworkPolicyKeys").mockImplementation( + () => options.ownedNetworkPolicyKeys ?? [], + ); const appliedPolicies: string[] = []; vi.spyOn(policy, "buildPolicySetCommand").mockImplementation((policyFile: unknown) => { appliedPolicies.push(readIfPresent(String(policyFile))); @@ -325,6 +329,8 @@ describe("shields command flow", () => { "", ].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 }); diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 3ccd3581922..e708764d4cf 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"); @@ -2878,16 +2879,23 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = if (policyName === "permissive") { const basePath = resolvePermissivePolicyPath(sandboxName); // Union the live sandbox's filesystem_policy.read_only/read_write and - // 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. + // 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) => { + console.warn( + ` Warning: ${detail}, so registered network routes were not carried into the permissive policy.`, + ); + console.warn(` Re-apply them with \`${CLI_NAME} ${sandboxName} mcp restart\`.`); + }, }); 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 e17d8b8c5f6..7790a198c58 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -39,9 +39,18 @@ 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: a live-only route is copied in, and a - * route the base already declares keeps the base's definition. Provider- - * composed `_provider_*` entries are never copied. + * `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 filesystem lists or network routes that need merging. @@ -64,6 +73,13 @@ 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. + onDegraded?: (detail: string) => void; } export function buildRuntimePermissivePolicy( @@ -73,9 +89,15 @@ 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); + 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); + return basePermissivePath; + }; + // 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) { @@ -86,11 +108,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" @@ -145,7 +167,7 @@ export function buildRuntimePermissivePolicy( try { return deps.writeTempPolicy(yaml); } catch { - return basePermissivePath; + return degrade("the merged policy could not be written"); } } let tmpPath: string | null = null; @@ -158,7 +180,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"); } } @@ -183,12 +205,16 @@ function safeYamlObject(text: string): Record | null { // helper must stay dependency-free. const PROVIDER_COMPOSED_PREFIX = "_provider_"; -function readNetworkPolicies(root: Record | null): Record { +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]) => !name.startsWith(PROVIDER_COMPOSED_PREFIX), + ([name]) => owned.has(name) && !name.startsWith(PROVIDER_COMPOSED_PREFIX), ), ); } diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts index 9efd8e9ed4a..93c35ab03a0 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -82,6 +82,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, }); @@ -102,6 +103,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, }); @@ -122,6 +124,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, }); @@ -143,6 +146,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, }); @@ -163,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, }); @@ -173,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, }); @@ -185,6 +191,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { filesystem_policy: { read_write: ["/proc"] }, }); const out = buildRuntimePermissivePolicy(basePath, { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => { throw new Error("ENOENT"); @@ -199,6 +206,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { filesystem_policy: { read_write: ["/proc"] }, }); const out = buildRuntimePermissivePolicy(basePath, { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => "::: not yaml :::", }); @@ -212,6 +220,7 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { }); let writeAttempts = 0; const out = buildRuntimePermissivePolicy(basePath, { + ownedNetworkPolicyKeys: [], livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE, writeTempPolicy: () => { @@ -234,6 +243,7 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { 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"); @@ -250,6 +260,7 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { 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"); @@ -273,6 +284,7 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { const out = buildRuntimePermissivePolicy("/unused-base.yaml", { livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: ["nvidia"], }); trackTempForCleanup(out, "/unused-base.yaml"); @@ -292,6 +304,9 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { 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"); @@ -306,6 +321,7 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { const out = buildRuntimePermissivePolicy(basePath, { livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: [MCP_KEY], }); expect(out).toBe(basePath); }); @@ -316,6 +332,7 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { const out = buildRuntimePermissivePolicy(basePath, { livePolicyYaml: liveYaml, readBasePolicy: () => BASE_PERMISSIVE_WITH_NETWORK, + ownedNetworkPolicyKeys: [MCP_KEY], }); expect(out).toBe(basePath); }); @@ -337,6 +354,9 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { // 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"); @@ -345,11 +365,106 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { 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) => degraded.push(detail), + }); + + // 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"]); + }); + + 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) => degraded.push(detail), + }); + + expect(out).toBe(basePath); + expect(degraded).toEqual(["the merged policy could not be written"]); + }); + + 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), + }); + + 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"); From 8c80c76532874770b6e72171a2514ee0f954b4e8 Mon Sep 17 00:00:00 2001 From: harjoth Date: Tue, 4 Aug 2026 09:23:26 -0700 Subject: [PATCH 5/5] fix(shields): name the lost routes and their real recovery command The degrade warning told the operator to run `mcp restart`, but the registry slot it reads holds two kinds of entry: generated MCP bridge policies and custom presets added with `policy add --from-file`. A dropped custom preset is not restored by `mcp restart`, so the instruction was wrong for half the cases it can fire on. Name the routes that were lost and give the command that owns each kind. Also say "applied policy" rather than naming the permissive document, matching the vocabulary the docs use. Refs: #7952 Signed-off-by: harjoth --- src/lib/shields/index.ts | 11 ++++++++--- src/lib/shields/permissive-runtime.ts | 5 +++-- test/permissive-runtime.test.ts | 9 +++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index e708764d4cf..578da181c9e 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -2890,11 +2890,16 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = livePolicyYaml: policyYaml, readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), ownedNetworkPolicyKeys: registeredNetworkPolicyKeys(sandboxName), - onDegraded: (detail) => { + onDegraded: (detail: string, lostKeys: readonly string[]) => { console.warn( - ` Warning: ${detail}, so registered network routes were not carried into the permissive policy.`, + ` 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.`, ); - console.warn(` Re-apply them with \`${CLI_NAME} ${sandboxName} mcp restart\`.`); }, }); policyFileIsTemp = policyFile !== basePath; diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index 7790a198c58..9933dbe9b28 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -79,7 +79,8 @@ export interface PermissiveRuntimeDeps { 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. - onDegraded?: (detail: string) => void; + // Receives the route keys that were lost. + onDegraded?: (detail: string, lostKeys: readonly string[]) => void; } export function buildRuntimePermissivePolicy( @@ -94,7 +95,7 @@ export function buildRuntimePermissivePolicy( // Report a degrade only when there was something owned to lose. const degrade = (detail: string): string => { - if (liveNetworkNames.length > 0) deps.onDegraded?.(detail); + if (liveNetworkNames.length > 0) deps.onDegraded?.(detail, liveNetworkNames); return basePermissivePath; }; diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts index 93c35ab03a0..2b268564e39 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -411,12 +411,12 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { throw new Error("ENOENT"); }, ownedNetworkPolicyKeys: [MCP_KEY], - onDegraded: (detail) => degraded.push(detail), + 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"]); + expect(degraded).toEqual([`the permissive baseline could not be read: ${MCP_KEY}`]); }); it("reports the loss when the merged policy cannot be written (#7952)", () => { @@ -431,11 +431,11 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { writeTempPolicy: () => { throw new Error("ENOSPC"); }, - onDegraded: (detail) => degraded.push(detail), + onDegraded: (detail, lostKeys) => degraded.push(`${detail}: ${lostKeys.join(",")}`), }); expect(out).toBe(basePath); - expect(degraded).toEqual(["the merged policy could not be written"]); + expect(degraded).toEqual([`the merged policy could not be written: ${MCP_KEY}`]); }); it("stays quiet when a degrade loses no owned route (#7952)", () => { @@ -454,6 +454,7 @@ describe("buildRuntimePermissivePolicy network routes (#7952)", () => { 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([]);