diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 8d1def477b4..825a7032186 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -24,7 +24,7 @@ "src/lib/inference/web-search.ts": 21, "src/lib/messaging/channels/index.ts": 26, "src/lib/onboard/gateway-binding.ts": 48, - "src/lib/runner.ts": 89, + "src/lib/runner.ts": 88, "src/lib/security/redact.ts": 51, "src/lib/state/onboard-session.ts": 36, "src/lib/state/registry.ts": 101, diff --git a/docs/manage-sandboxes/runtime-controls.mdx b/docs/manage-sandboxes/runtime-controls.mdx index 0a233eb1385..ccfc887339f 100644 --- a/docs/manage-sandboxes/runtime-controls.mdx +++ b/docs/manage-sandboxes/runtime-controls.mdx @@ -114,6 +114,14 @@ NemoClaw does not signal that process because portable process inspection cannot After the owner releases the lock, auto-restore restores the restrictive policy and config posture. The ownership check includes both the process ID and process start identity so PID reuse does not grant control over an unrelated process. +Before a manual Shields transition replaces a policy, NemoClaw requires exact Model Context Protocol (MCP) agreement among the sandbox registry, generated-policy record, and live gateway policy. +`shields down` carries the proven managed MCP policy entries into the relaxed policy. +Restoration removes snapshot-time managed MCP entries before it overlays current exact entries. +If exact agreement is absent, a manual Shields transition refuses the replacement policy. +At an expired deadline, auto-restore omits unproven managed MCP policy entries, restores lockdown, and records the omission count in its audit entry. +An MCP server removed during the shields-down window stays removed. +A surviving server keeps its recorded endpoint and address pins while its policy ownership remains exact. + When an interactive command takes over an expired timer, NemoClaw makes up to 7 restoration attempts over an additional 30-second completion-grace window. The deadline gate remains closed during those attempts. If restoration cannot commit, NemoClaw records durable containment before the command returns an error. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 7d63c6a98ce..ea5a909bc92 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1172,6 +1172,14 @@ Durable containment blocks new mutations until you complete exact-generation ope Stop all NemoClaw processes for the sandbox, then follow the paths, identities, tokens, and removal order in the reported error. Verify each recorded generation is unchanged, remove only the exact stale generations first, and remove the exact containment generation last. +Before a manual Shields transition replaces a policy, NemoClaw requires exact agreement among the sandbox registry, generated-policy record, and live gateway policy. +`shields down` carries the proven managed MCP policy entries into the relaxed policy. +Restoration removes snapshot-time managed MCP entries before it overlays current exact entries. +If exact agreement is absent, a manual Shields transition refuses the replacement policy. +At an expired deadline, auto-restore omits unproven managed MCP policy entries, restores lockdown, and records the omission count in its audit entry. +An MCP server removed during the shields-down window stays removed. +A surviving server keeps its recorded endpoint and address pins while its policy ownership remains exact. + ### `$$nemoclaw recover` diff --git a/scripts/checks/openshell-policy-mutation-read.mts b/scripts/checks/openshell-policy-mutation-read.mts index 133a981dc62..63fc1e5db7b 100644 --- a/scripts/checks/openshell-policy-mutation-read.mts +++ b/scripts/checks/openshell-policy-mutation-read.mts @@ -58,7 +58,7 @@ export const MUTATION_READS: readonly AuditedMutationRead[] = [ }, { relativePath: "src/lib/shields/index.ts", - expectedReadCalls: 1, + expectedReadCalls: 3, baseCommand: "runCapture(buildPolicyGetCommand(sandboxName))", unsafeBaseCommand: "runCapture(buildPolicyGetCommand(sandboxName), {", fullCommand: "runCapture(buildPolicyGetFullCommand(sandboxName))", diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.ts b/src/lib/actions/sandbox/mcp-bridge-policy.ts index 88a5e024655..b951485b690 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.ts @@ -1,8 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { isIP } from "node:net"; +import { isDeepStrictEqual } from "node:util"; +import YAML from "yaml"; + import type { AgentMcpAdapter } from "../../agent/defs"; import * as policies from "../../policy"; +import { isBlockedMcpUrlTargetHost } from "../../security/mcp-url-target"; import type { McpBridgeEntry } from "../../state/registry"; import * as registry from "../../state/registry"; import { @@ -16,6 +21,7 @@ import { buildMcpBridgePolicyYaml, } from "./mcp-bridge-policy-render"; +export { MCP_BRIDGE_POLICY_SOURCE } from "./mcp-bridge-contracts"; export { buildMcpBridgePolicyKey, buildMcpBridgePolicyName, @@ -24,6 +30,463 @@ export { MCP_BRIDGE_POLICY_MAX_BODY_BYTES, } from "./mcp-bridge-policy-render"; +export interface ExactManagedMcpPolicy { + key: string; + networkPolicy: unknown; + policyName: string; + server: string; +} + +export interface ManagedMcpPolicyOmission { + key?: string; + policyName?: string; + server?: string; + reason: string; +} + +export interface ProvableManagedMcpPolicies { + policies: ExactManagedMcpPolicy[]; + omissions: ManagedMcpPolicyOmission[]; +} + +type ManagedMcpPolicyInspectionDeps = { + getSandbox: typeof registry.getSandbox; +}; + +const managedMcpPolicyInspectionDeps: ManagedMcpPolicyInspectionDeps = { + getSandbox: registry.getSandbox, +}; + +function parseManagedPolicyDocument(source: string, label: string): Record { + let parsed: unknown; + try { + parsed = YAML.parse(source); + } catch { + throw new Error(`${label} is not valid YAML`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${label} must be a YAML mapping`); + } + return parsed as Record; +} + +function readManagedNetworkPolicies( + document: Record, + label: string, +): Record { + const networkPolicies = document.network_policies; + if (networkPolicies === undefined || networkPolicies === null) return {}; + if (typeof networkPolicies !== "object" || Array.isArray(networkPolicies)) { + throw new Error(`${label} network_policies must be a mapping`); + } + return networkPolicies as Record; +} + +function requireCanonicalAllowedIps(networkPolicy: unknown, policyName: string): readonly string[] { + if (!networkPolicy || typeof networkPolicy !== "object" || Array.isArray(networkPolicy)) { + throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); + } + const endpoints = (networkPolicy as Record).endpoints; + if (!Array.isArray(endpoints) || endpoints.length !== 1) { + throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); + } + const endpoint = endpoints[0]; + if (!endpoint || typeof endpoint !== "object" || Array.isArray(endpoint)) { + throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); + } + const allowedIps = (endpoint as Record).allowed_ips; + if (!Array.isArray(allowedIps) || allowedIps.length === 0) { + throw new Error(`Managed MCP policy '${policyName}' has no exact public address pins`); + } + if ( + allowedIps.some( + (address) => + typeof address !== "string" || + address !== address.toLowerCase() || + address.includes("%") || + isIP(address) === 0 || + isBlockedMcpUrlTargetHost(address), + ) + ) { + throw new Error(`Managed MCP policy '${policyName}' has invalid public address pins`); + } + const pins = allowedIps as string[]; + if (new Set(pins).size !== pins.length || !isDeepStrictEqual(pins, [...pins].sort())) { + throw new Error(`Managed MCP policy '${policyName}' has non-canonical public address pins`); + } + return pins; +} + +function resolveCanonicalManagedMcpAdapter( + sandbox: registry.SandboxEntry, + bridge: McpBridgeEntry, +): AgentMcpAdapter { + if (isAgentMcpAdapter(bridge.adapter)) return bridge.adapter; + switch (sandbox.agent || "openclaw") { + case "openclaw": + return "mcporter"; + case "hermes": + return "hermes-config"; + case "langchain-deepagents-code": + return "deepagents-config"; + default: + throw new Error("Managed MCP bridge has no canonical adapter"); + } +} + +function requireCanonicalManagedPolicy( + sandbox: registry.SandboxEntry, + server: string, + livePolicies: Record, +): ExactManagedMcpPolicy { + const bridge = sandbox.mcp?.bridges[server]; + if (!bridge || bridge.addState || bridge.server !== server) { + throw new Error(`Managed MCP bridge '${server}' has an incomplete lifecycle transition`); + } + + const policyName = buildMcpBridgePolicyName(server); + const policyKey = buildMcpBridgePolicyKey(server); + if (bridge.policyName !== policyName) { + throw new Error(`Managed MCP bridge '${server}' has a non-canonical policy name`); + } + + const registrations = (sandbox.customPolicies ?? []).filter( + (policy) => policy.name === policyName, + ); + if (registrations.length !== 1) { + throw new Error( + `Managed MCP bridge '${server}' does not have one exact policy ownership record`, + ); + } + const [registration] = registrations; + if (registration?.sourcePath !== MCP_BRIDGE_POLICY_SOURCE) { + throw new Error(`Managed MCP bridge '${server}' has no NemoClaw-owned policy record`); + } + if (registration.pendingContent !== undefined) { + throw new Error(`Managed MCP bridge '${server}' has an incomplete policy transition`); + } + + const registeredDocument = parseManagedPolicyDocument( + registration.content, + `Managed MCP policy '${policyName}'`, + ); + const preset = registeredDocument.preset; + if ( + !preset || + typeof preset !== "object" || + Array.isArray(preset) || + (preset as Record).name !== policyName + ) { + throw new Error(`Managed MCP policy '${policyName}' has non-canonical preset metadata`); + } + const registeredPolicies = readManagedNetworkPolicies( + registeredDocument, + `Managed MCP policy '${policyName}'`, + ); + const registeredKeys = Object.keys(registeredPolicies); + if (registeredKeys.length !== 1 || registeredKeys[0] !== policyKey) { + throw new Error(`Managed MCP policy '${policyName}' has a non-canonical network policy key`); + } + + const registeredNetworkPolicy = registeredPolicies[policyKey]; + const allowedIps = requireCanonicalAllowedIps(registeredNetworkPolicy, policyName); + let expectedDocument: Record; + try { + expectedDocument = parseManagedPolicyDocument( + buildMcpBridgePolicyYaml( + bridge.server, + bridge.url, + resolveCanonicalManagedMcpAdapter(sandbox, bridge), + allowedIps, + ), + `Canonical managed MCP policy '${policyName}'`, + ); + } catch { + throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); + } + if (!isDeepStrictEqual(registeredDocument, expectedDocument)) { + throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); + } + + if (!Object.hasOwn(livePolicies, policyKey)) { + throw new Error(`Managed MCP policy '${policyName}' is absent from the live gateway policy`); + } + if (!isDeepStrictEqual(livePolicies[policyKey], registeredNetworkPolicy)) { + throw new Error(`Managed MCP policy '${policyName}' has drifted from its ownership record`); + } + + return { + key: policyKey, + networkPolicy: registeredNetworkPolicy, + policyName, + server, + }; +} + +/** + * Resolve the exact generated MCP entries that NemoClaw currently owns. + * + * The registry is an ownership claim, not sufficient authority to overwrite + * the gateway. Every committed bridge must have one canonical, fully + * committed custom-policy record whose sole network entry exactly matches the + * live base policy. + */ +export function inspectExactManagedMcpPolicies( + sandboxName: string, + livePolicyYaml: string, + deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, +): ExactManagedMcpPolicy[] { + const liveDocument = parseManagedPolicyDocument(livePolicyYaml, "Live gateway policy"); + const livePolicies = readManagedNetworkPolicies(liveDocument, "Live gateway policy"); + const sandbox = deps.getSandbox(sandboxName); + if (!sandbox) { + const unclassifiedKey = Object.keys(livePolicies).find((key) => key.startsWith("mcp_bridge_")); + if (unclassifiedKey) { + throw new Error( + `Reserved MCP policy key '${unclassifiedKey}' has no committed managed bridge ownership`, + ); + } + return []; + } + const generatedRegistrations = (sandbox.customPolicies ?? []).filter( + (policy) => policy.sourcePath === MCP_BRIDGE_POLICY_SOURCE, + ); + if (!sandbox.mcp) { + const orphaned = generatedRegistrations[0]; + if (orphaned) { + throw new Error( + `Generated MCP policy '${orphaned.name}' has no committed managed bridge ownership`, + ); + } + const unclassifiedKey = Object.keys(livePolicies).find((key) => key.startsWith("mcp_bridge_")); + if (unclassifiedKey) { + throw new Error( + `Reserved MCP policy key '${unclassifiedKey}' has no committed managed bridge ownership`, + ); + } + return []; + } + if (sandbox.mcp.destroyPreparedAt || sandbox.mcp.destroyPendingAt) { + throw new Error("Managed MCP sandbox destruction is incomplete"); + } + + const bridgeEntries = Object.entries(sandbox.mcp.bridges); + if (bridgeEntries.some(([, bridge]) => bridge.addState !== undefined)) { + throw new Error("A managed MCP bridge lifecycle transition is incomplete"); + } + const exact = bridgeEntries.map(([server]) => + requireCanonicalManagedPolicy(sandbox, server, livePolicies), + ); + + const committedPolicyNames = new Set(exact.map((entry) => entry.policyName)); + const orphaned = generatedRegistrations.find( + (registration) => !committedPolicyNames.has(registration.name), + ); + if (orphaned) { + throw new Error( + `Generated MCP policy '${orphaned.name}' has no committed managed bridge ownership`, + ); + } + + const keys = new Set(); + for (const entry of exact) { + if (keys.has(entry.key)) { + throw new Error(`Managed MCP policy key '${entry.key}' has ambiguous bridge ownership`); + } + keys.add(entry.key); + } + const unclassifiedKey = Object.keys(livePolicies).find( + (key) => key.startsWith("mcp_bridge_") && !keys.has(key), + ); + if (unclassifiedKey) { + throw new Error( + `Reserved MCP policy key '${unclassifiedKey}' has no committed managed bridge ownership`, + ); + } + return exact.sort((left, right) => left.key.localeCompare(right.key)); +} + +/** + * Deadline-only inspection for automatic Shields restoration. + * + * Each entry is admitted independently through the same exact committed/live + * proof as the strict path. Incomplete, drifted, orphaned, or ambiguous claims + * are omitted instead of extending the mutable window; registry state is never + * reconciled or rewritten here. + */ +export function inspectProvableManagedMcpPoliciesForDeadline( + sandboxName: string, + livePolicyYaml: string, + deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, +): ProvableManagedMcpPolicies { + const derivedIdentity = (server: string): { key?: string; policyName?: string } => { + try { + return { + key: buildMcpBridgePolicyKey(server), + policyName: buildMcpBridgePolicyName(server), + }; + } catch { + return {}; + } + }; + const omit = (reason: string, server?: string, policyName?: string): ManagedMcpPolicyOmission => { + const identity = server ? derivedIdentity(server) : {}; + return { + ...(server ? { server } : {}), + ...identity, + ...(policyName ? { policyName } : {}), + reason, + }; + }; + const sandbox = deps.getSandbox(sandboxName); + const generatedRegistrations = (sandbox?.customPolicies ?? []).filter( + (policy) => policy.sourcePath === MCP_BRIDGE_POLICY_SOURCE, + ); + const bridgeEntries = Object.entries(sandbox?.mcp?.bridges ?? {}); + + if (sandbox?.mcp?.destroyPreparedAt || sandbox?.mcp?.destroyPendingAt) { + const reason = "Managed MCP sandbox destruction is incomplete"; + const omissions = bridgeEntries.map(([server]) => omit(reason, server)); + for (const registration of generatedRegistrations) { + if (!omissions.some((entry) => entry.policyName === registration.name)) { + omissions.push(omit(reason, undefined, registration.name)); + } + } + if (omissions.length === 0) omissions.push({ reason }); + return { policies: [], omissions }; + } + + let livePolicies: Record; + try { + livePolicies = readManagedNetworkPolicies( + parseManagedPolicyDocument(livePolicyYaml, "Live gateway policy"), + "Live gateway policy", + ); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + const omissions = bridgeEntries.map(([server]) => omit(reason, server)); + for (const registration of generatedRegistrations) { + if (!omissions.some((entry) => entry.policyName === registration.name)) { + omissions.push(omit(reason, undefined, registration.name)); + } + } + return { policies: [], omissions }; + } + + const policies: ExactManagedMcpPolicy[] = []; + const omissions: ManagedMcpPolicyOmission[] = []; + if (!sandbox) { + for (const key of Object.keys(livePolicies).filter((candidate) => + candidate.startsWith("mcp_bridge_"), + )) { + omissions.push({ + key, + reason: `Reserved MCP policy key '${key}' has no committed managed bridge ownership`, + }); + } + return { policies, omissions }; + } + const claimedServersByKey = new Map(); + const claimedServersByPolicyName = new Map(); + for (const [server] of bridgeEntries) { + const identity = derivedIdentity(server); + if (identity.key) { + const servers = claimedServersByKey.get(identity.key) ?? []; + servers.push(server); + claimedServersByKey.set(identity.key, servers); + } + if (identity.policyName) { + const servers = claimedServersByPolicyName.get(identity.policyName) ?? []; + servers.push(server); + claimedServersByPolicyName.set(identity.policyName, servers); + } + } + const ambiguousServers = new Set(); + for (const servers of [...claimedServersByKey.values(), ...claimedServersByPolicyName.values()]) { + if (servers.length <= 1) continue; + for (const server of servers) ambiguousServers.add(server); + } + for (const [server] of bridgeEntries) { + if (ambiguousServers.has(server)) { + omissions.push(omit("Managed MCP policy identity has ambiguous bridge ownership", server)); + continue; + } + try { + policies.push(requireCanonicalManagedPolicy(sandbox, server, livePolicies)); + } catch (error) { + omissions.push(omit(error instanceof Error ? error.message : String(error), server)); + } + } + + const bridgePolicyNames = new Set( + bridgeEntries + .map(([server]) => derivedIdentity(server).policyName) + .filter((name): name is string => name !== undefined), + ); + for (const registration of generatedRegistrations) { + if (!bridgePolicyNames.has(registration.name)) { + omissions.push( + omit( + `Generated MCP policy '${registration.name}' has no committed managed bridge ownership`, + undefined, + registration.name, + ), + ); + } + } + + const policiesByKey = new Map(); + for (const policy of policies) { + const entries = policiesByKey.get(policy.key) ?? []; + entries.push(policy); + policiesByKey.set(policy.key, entries); + } + const exact: ExactManagedMcpPolicy[] = []; + for (const entries of policiesByKey.values()) { + if (entries.length === 1) { + exact.push(entries[0]!); + continue; + } + for (const entry of entries) { + omissions.push( + omit(`Managed MCP policy key '${entry.key}' has ambiguous ownership`, entry.server), + ); + } + } + const exactKeys = new Set(exact.map((entry) => entry.key)); + for (const key of Object.keys(livePolicies).filter( + (candidate) => candidate.startsWith("mcp_bridge_") && !exactKeys.has(candidate), + )) { + if (omissions.some((entry) => entry.key === key)) continue; + omissions.push({ + key, + reason: `Reserved MCP policy key '${key}' has no exact committed managed bridge ownership`, + }); + } + return { + policies: exact.sort((left, right) => left.key.localeCompare(right.key)), + omissions, + }; +} + +export function hasManagedMcpPolicyClaims( + sandboxName: string, + deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, +): boolean { + const sandbox = deps.getSandbox(sandboxName); + if (!sandbox) return false; + return ( + Boolean( + sandbox.mcp && + (Object.keys(sandbox.mcp.bridges).length > 0 || + (sandbox.mcp.managedServerNames?.length ?? 0) > 0 || + sandbox.mcp.destroyPreparedAt || + sandbox.mcp.destroyPendingAt), + ) || + (sandbox.customPolicies ?? []).some((policy) => policy.sourcePath === MCP_BRIDGE_POLICY_SOURCE) + ); +} + type GeneratedPolicyRegistrationState = { policy: registry.CustomPolicyEntry; state: "match" | "absent" | "drift" | null; diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index 66cc34876ac..4390e7cde09 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -8,14 +8,20 @@ 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"; +import { buildMcpBridgePolicyYaml } from "../actions/sandbox/mcp-bridge-policy-render"; +import type { SandboxEntry } from "../state/registry"; const requireDist = createRequire(import.meta.url); const shieldsModulePath = "./index.js"; type ShieldsHarness = { + applyShieldsPolicySnapshot: typeof import("./index.js").applyShieldsPolicySnapshot; auditSpy: MockInstance; + cleanupTempDirSpy: MockInstance; errorSpy: MockInstance; logSpy: MockInstance; + policySetBodies: string[]; runSpy: MockInstance; shieldsDown: typeof import("./index.js").shieldsDown; shieldsStatus: typeof import("./index.js").shieldsStatus; @@ -34,6 +40,7 @@ type HarnessOptions = { directSandboxUnavailable?: boolean; dockerExecFileSync?: (argv: unknown) => string; failOpenClawGuardActions?: Array<"lock" | "unlock">; + failStateSave?: boolean; invokedAs?: "nemoclaw" | "nemohermes"; openClawGuardFailure?: { code: string; @@ -52,18 +59,67 @@ type HarnessOptions = { send: () => boolean; kill: () => boolean; }; + livePolicy?: string; run?: (cmd: unknown) => { status: number }; + sandboxEntry?: SandboxEntry; }; +function managedMcpPolicy(server: string, address = "8.8.8.8") { + const content = buildMcpBridgePolicyYaml( + server, + `https://${server}.example.com/mcp`, + "hermes-config", + [address], + ); + const entries = Object.entries(YAML.parse(content).network_policies as Record); + expect(entries, `rendered MCP policies for ${server}`).toHaveLength(1); + const [key, networkPolicy] = entries[0]!; + return { content, key, networkPolicy, server }; +} + +function managedMcpSandbox(policies: Array>): SandboxEntry { + return { + name: "openclaw", + openshellDriver: "docker", + customPolicies: policies.map(({ content, server }) => ({ + name: `mcp-bridge-${server}`, + content, + sourcePath: "generated:nemoclaw-mcp-bridge", + })), + mcp: { + bridges: Object.fromEntries( + policies.map(({ server }) => [ + server, + { + server, + agent: "hermes", + adapter: "hermes-config", + url: `https://${server}.example.com/mcp`, + env: ["MCP_SECRET"], + policyName: `mcp-bridge-${server}`, + addedAt: "2026-07-30T00:00:00.000Z", + }, + ]), + ), + }, + }; +} + function throwHarnessError(error: Error): never { throw error; } +function recordPolicySetBody(policySetBodies: string[], file: unknown): void { + policySetBodies.push(fs.readFileSync(String(file), "utf-8")); +} + function createHarness(options: HarnessOptions = {}): ShieldsHarness { vi.stubEnv("NEMOCLAW_INVOKED_AS", options.invokedAs ?? "nemoclaw"); delete require.cache[requireDist.resolve(shieldsModulePath)]; delete require.cache[requireDist.resolve("./timer-bound-lock.js")]; delete require.cache[requireDist.resolve("./transition-lock.js")]; + delete require.cache[requireDist.resolve("./permissive-runtime.js")]; + delete require.cache[requireDist.resolve("../actions/sandbox/mcp-bridge-policy.js")]; delete require.cache[requireDist.resolve("../sandbox/privileged-exec.js")]; delete require.cache[requireDist.resolve("../cli/branding.js")]; const lifecycleLock = requireDist( @@ -85,17 +141,24 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { const privilegedExec = requireDist("../sandbox/privileged-exec.js"); const dockerExec = requireDist("../adapters/docker/exec.js"); const audit = requireDist("./audit.js"); + const tempFiles = requireDist("../onboard/temp-files.js"); const childProcess = requireDist("node:child_process"); + const policySetBodies: string[] = []; let openClawPosture: "locked" | "mutable" = "mutable"; vi.spyOn(runner, "validateName").mockImplementation((name: unknown) => String(name)); - vi.spyOn(runner, "runCapture").mockReturnValue("version: 1\nnetwork_policies:\n test: {}\n"); + vi.spyOn(runner, "runCapture").mockReturnValue( + options.livePolicy ?? "version: 1\nnetwork_policies:\n test: {}\n", + ); const runSpy = vi.spyOn(runner, "run").mockImplementation((cmd: unknown) => { return options.run ? options.run(cmd) : { status: 0 }; }); 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, "buildPolicySetCommand").mockImplementation((file: unknown) => { + recordPolicySetBody(policySetBodies, file); + return ["openshell", "policy", "set"]; + }); vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)); vi.spyOn(policy, "resolvePermissivePolicyPath").mockReturnValue( path.join(tmpDir, "permissive.yaml"), @@ -108,8 +171,13 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { configPath: "/sandbox/.openclaw/openclaw.json", format: "json", }); - vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "openclaw", openshellDriver: "docker" }); + vi.spyOn(registry, "getSandbox").mockReturnValue( + options.sandboxEntry ?? { name: "openclaw", openshellDriver: "docker" }, + ); vi.spyOn(registry, "listSandboxes").mockReturnValue({ sandboxes: [{ name: "openclaw" }] }); + const permissiveRuntime = requireDist( + "./permissive-runtime.js", + ) as typeof import("./permissive-runtime.js"); const directSandboxUnavailableError = new Error( "No running direct OpenShell sandbox container found for 'openclaw' (driver: docker). Expected a running container named openshell-openclaw or openshell-openclaw-*. Is the sandbox running?", ); @@ -205,15 +273,33 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { : ""; }); const auditSpy = vi.spyOn(audit, "appendAuditEntry").mockImplementation(() => undefined); + const cleanupTempDirSpy = vi.spyOn(tempFiles, "cleanupTempDir"); + const prepareStateSaveFailure = options.failStateSave + ? () => + fs.mkdirSync(path.join(tmpDir, ".nemoclaw", "state", "shields-openclaw.json"), { + recursive: true, + }) + : () => undefined; + const buildRuntimePermissivePolicy = permissiveRuntime.buildRuntimePermissivePolicy; + vi.spyOn(permissiveRuntime, "buildRuntimePermissivePolicy").mockImplementation( + (basePath, deps) => { + const runtimePolicy = buildRuntimePermissivePolicy(basePath, deps); + prepareStateSaveFailure(); + return runtimePolicy; + }, + ); const shields = requireDist(shieldsModulePath); logSpy.mockClear(); errorSpy.mockClear(); auditSpy.mockClear(); return { + applyShieldsPolicySnapshot: shields.applyShieldsPolicySnapshot, auditSpy, + cleanupTempDirSpy, errorSpy, logSpy, + policySetBodies, runSpy, shieldsDown: shields.shieldsDown, shieldsStatus: shields.shieldsStatus, @@ -301,6 +387,8 @@ describe("shields command flow", () => { delete require.cache[requireDist.resolve(shieldsModulePath)]; delete require.cache[requireDist.resolve("./timer-bound-lock.js")]; delete require.cache[requireDist.resolve("./transition-lock.js")]; + delete require.cache[requireDist.resolve("./permissive-runtime.js")]; + delete require.cache[requireDist.resolve("../actions/sandbox/mcp-bridge-policy.js")]; delete require.cache[requireDist.resolve("../cli/branding.js")]; }); @@ -331,6 +419,274 @@ describe("shields command flow", () => { ); }); + it("shieldsDown preserves an exact managed MCP policy and records its snapshot key (#7952)", { + timeout: 15_000, + }, () => { + const alpha = managedMcpPolicy("alpha"); + const harness = createHarness({ + livePolicy: YAML.stringify({ + version: 1, + network_policies: { + restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + mcp_bridge_alpha: alpha.networkPolicy, + }, + }), + sandboxEntry: managedMcpSandbox([alpha]), + }); + + harness.shieldsDown("openclaw", { + timeout: "5m", + reason: "managed MCP transition coverage", + skipTimer: true, + throwOnError: true, + }); + + const state = JSON.parse( + fs.readFileSync(path.join(tmpDir, ".nemoclaw", "state", "shields-openclaw.json"), "utf-8"), + ); + expect(state.shieldsManagedMcpPolicyKeys).toEqual(["mcp_bridge_alpha"]); + const applied = YAML.parse(harness.policySetBodies.at(-1)!); + expect(applied.network_policies.mcp_bridge_alpha).toEqual(alpha.networkPolicy); + expect(applied.network_policies).not.toHaveProperty("restrictive_baseline"); + }); + + it("cleans the staged managed MCP policy when timer startup fails", () => { + const alpha = managedMcpPolicy("alpha"); + const harness = createHarness({ + fork: () => { + throw new Error("timer startup failed"); + }, + livePolicy: YAML.stringify({ + version: 1, + network_policies: { [alpha.key]: alpha.networkPolicy }, + }), + sandboxEntry: managedMcpSandbox([alpha]), + }); + + expect(() => + harness.shieldsDown("openclaw", { + timeout: "5m", + reason: "cleanup coverage", + throwOnError: true, + }), + ).toThrow("Cannot start auto-restore timer: timer startup failed"); + + expect(harness.cleanupTempDirSpy).toHaveBeenCalledWith( + expect.stringContaining("nemoclaw-permissive-runtime"), + "nemoclaw-permissive-runtime", + ); + expect(harness.cleanupTempDirSpy).toHaveBeenCalledTimes(1); + const stagedPolicyPath = String(harness.cleanupTempDirSpy.mock.calls.at(-1)?.[0]); + expect(fs.existsSync(path.dirname(stagedPolicyPath))).toBe(false); + }); + + it("cleans the staged managed MCP policy when state persistence fails", () => { + const alpha = managedMcpPolicy("alpha"); + const harness = createHarness({ + failStateSave: true, + livePolicy: YAML.stringify({ + version: 1, + network_policies: { [alpha.key]: alpha.networkPolicy }, + }), + sandboxEntry: managedMcpSandbox([alpha]), + }); + + expect(() => + harness.shieldsDown("openclaw", { + timeout: "5m", + reason: "cleanup coverage", + skipTimer: true, + throwOnError: true, + }), + ).toThrow(/EISDIR|directory/i); + + expect(harness.cleanupTempDirSpy).toHaveBeenCalledWith( + expect.stringContaining("nemoclaw-permissive-runtime"), + "nemoclaw-permissive-runtime", + ); + expect(harness.cleanupTempDirSpy).toHaveBeenCalledTimes(1); + const stagedPolicyPath = String(harness.cleanupTempDirSpy.mock.calls.at(-1)?.[0]); + expect(fs.existsSync(path.dirname(stagedPolicyPath))).toBe(false); + }); + + it("timer restore uses persisted MCP ownership after its transition marker clears (#7952)", () => { + const alpha = managedMcpPolicy("alpha", "8.8.8.8"); + const beta = managedMcpPolicy("beta", "1.1.1.1"); + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + const snapshotPath = path.join(stateDir, "policy-snapshot-managed-restore.yaml"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + snapshotPath, + YAML.stringify({ + version: 1, + network_policies: { + restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + mcp_bridge_alpha: alpha.networkPolicy, + }, + }), + ); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify({ + shieldsDown: true, + shieldsPolicySnapshotPath: snapshotPath, + shieldsManagedMcpPolicyKeys: ["mcp_bridge_alpha"], + }), + ); + const harness = createHarness({ + livePolicy: YAML.stringify({ + version: 1, + network_policies: { + permissive_baseline: { endpoints: [{ host: "*" }] }, + mcp_bridge_alpha: alpha.networkPolicy, + mcp_bridge_beta: beta.networkPolicy, + }, + }), + sandboxEntry: managedMcpSandbox([alpha, beta]), + }); + + const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { + transitionProcessToken: "6".repeat(32), + }); + + expect(result.status).toBe(0); + const restored = YAML.parse(harness.policySetBodies.at(-1)!); + expect(Object.keys(restored.network_policies).sort()).toEqual([ + "mcp_bridge_alpha", + "mcp_bridge_beta", + "restrictive_baseline", + ]); + expect(restored.network_policies.mcp_bridge_beta).toEqual(beta.networkPolicy); + }); + + it("refuses manual restoration when persisted MCP ownership is malformed (#7952)", () => { + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + const snapshotPath = path.join(stateDir, "policy-snapshot-corrupt-state.yaml"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies: {}\n"); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify({ + shieldsDown: true, + shieldsPolicySnapshotPath: snapshotPath, + shieldsManagedMcpPolicyKeys: ["../not-a-managed-key"], + }), + ); + const harness = createHarness(); + + expect(() => harness.applyShieldsPolicySnapshot("openclaw", snapshotPath)).toThrow( + /Saved Shields MCP policy ownership is invalid/, + ); + expect(harness.policySetBodies).toHaveLength(0); + }); + + it("refuses a legacy restore whose persisted state names a different snapshot (#7952)", () => { + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + const expectedSnapshotPath = path.join(stateDir, "policy-snapshot-expected.yaml"); + const requestedSnapshotPath = path.join(stateDir, "policy-snapshot-requested.yaml"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(expectedSnapshotPath, "version: 1\nnetwork_policies: {}\n"); + fs.writeFileSync(requestedSnapshotPath, "version: 1\nnetwork_policies: {}\n"); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify({ + shieldsDown: true, + shieldsPolicySnapshotPath: expectedSnapshotPath, + }), + ); + const harness = createHarness(); + + expect(() => harness.applyShieldsPolicySnapshot("openclaw", requestedSnapshotPath)).toThrow( + /does not match the policy snapshot/, + ); + expect(harness.policySetBodies).toHaveLength(0); + }); + + it("uses token-bound transition ownership when the forward owner dies before state commit (#7952)", () => { + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + const processToken = "8".repeat(32); + const snapshotPath = path.join(stateDir, "policy-snapshot-new-cycle.yaml"); + const oldSnapshotPath = path.join(stateDir, "policy-snapshot-old-cycle.yaml"); + const alpha = managedMcpPolicy("alpha", "8.8.8.8"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + snapshotPath, + YAML.stringify({ + version: 1, + network_policies: { mcp_bridge_alpha: alpha.networkPolicy }, + }), + ); + fs.writeFileSync(oldSnapshotPath, "version: 1\nnetwork_policies: {}\n"); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify({ + shieldsDown: false, + shieldsPolicySnapshotPath: oldSnapshotPath, + shieldsManagedMcpPolicyKeys: [], + }), + ); + fs.writeFileSync( + path.join(stateDir, `shields-transition-openclaw-${processToken}.json`), + JSON.stringify({ + version: 1, + phase: "preparing", + ownerPid: process.pid, + ownerStartIdentity: "forward-owner", + processToken, + sandboxName: "openclaw", + snapshotPath, + managedMcpPolicyKeys: ["mcp_bridge_alpha"], + }), + ); + const harness = createHarness({ + livePolicy: YAML.stringify({ + version: 1, + network_policies: { mcp_bridge_alpha: alpha.networkPolicy }, + }), + sandboxEntry: managedMcpSandbox([alpha]), + }); + + const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { + transitionProcessToken: processToken, + }); + + expect(result.status).toBe(0); + expect(YAML.parse(harness.policySetBodies.at(-1)!).network_policies.mcp_bridge_alpha).toEqual( + alpha.networkPolicy, + ); + }); + + it("loads 257 managed keys recorded by Shields down (#7952)", { timeout: 15_000 }, () => { + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + const snapshotPath = path.join(stateDir, "policy-snapshot-many-managed-keys.yaml"); + const policies = Array.from({ length: 257 }, (_, index) => managedMcpPolicy(`server${index}`)); + const keys = policies.map(({ key }) => key); + const networkPolicies = Object.fromEntries( + policies.map(({ key, networkPolicy }) => [key, networkPolicy]), + ); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(snapshotPath, YAML.stringify({ network_policies: networkPolicies })); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify({ + shieldsDown: true, + shieldsPolicySnapshotPath: snapshotPath, + shieldsManagedMcpPolicyKeys: keys, + }), + ); + const harness = createHarness({ + livePolicy: YAML.stringify({ version: 1, network_policies: networkPolicies }), + sandboxEntry: managedMcpSandbox(policies), + }); + + expect(harness.applyShieldsPolicySnapshot("openclaw", snapshotPath).status).toBe(0); + const applied = YAML.parse(harness.policySetBodies.at(-1)!); + const appliedKeys = Object.keys(applied.network_policies); + expect([...appliedKeys].sort()).toEqual([...keys].sort()); + expect(appliedKeys).toHaveLength(257); + expect(appliedKeys).toContain("mcp_bridge_server256"); + }); + it("binds manual shields-up to the active auto-restore timer generation", () => { const stateDir = path.join(tmpDir, ".nemoclaw", "state"); const sandboxName = "openclaw"; @@ -656,6 +1012,7 @@ describe("shields command flow", () => { ownerPid: process.pid, sandboxName: "openclaw", snapshotPath: expect.stringContaining("policy-snapshot-"), + managedMcpPolicyKeys: [], }); expect(fs.existsSync(path.join(stateDir, "shields-timer-openclaw.json"))).toBe(true); }); diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index ad3bdbbd457..2c2cc8dfade 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -103,6 +103,22 @@ function withDefaultNodeExecFileSync( return defaultNodeExecFileSync(file, argv) || fallback(); } +function throwRegistryPermissionDenied(): never { + throw Object.assign(new Error("registry permission denied"), { code: "EACCES" }); +} + +function readFileWithUnreadableRegistry( + originalReadFileSync: typeof fs.readFileSync, + file: fs.PathOrFileDescriptor, + options?: unknown, +): unknown { + const readers = new Map unknown>([ + [true, throwRegistryPermissionDenied], + [false, () => originalReadFileSync(file, options as never)], + ]); + return readers.get(String(file).endsWith(`${path.sep}sandboxes.json`))!(); +} + function throwProcessNotRunning(): never { throw Object.assign(new Error("not running"), { code: "ESRCH" }); } @@ -116,6 +132,23 @@ function routeProcessKill(pid: number, signal?: string | number): true { return (processActions.get(`${pid}:${signal}`) ?? reportProcessRunning)(); } +function readRuntimePolicyBeforeCleanup( + cleanupDir: string, + readFile: typeof fs.readFileSync, +): string | null { + switch ( + path.basename(cleanupDir).startsWith("nemoclaw-permissive-runtime-") && + fs.existsSync(cleanupDir) + ) { + case false: + return null; + case true: { + const policyFile = fs.readdirSync(cleanupDir).find((name) => name.endsWith(".yaml")); + return policyFile ? readFile(path.join(cleanupDir, policyFile), "utf-8") : null; + } + } +} + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shields-test-")); vi.stubEnv("HOME", tmpDir); @@ -496,6 +529,65 @@ describe("shields — unit logic", () => { expect(logSpy).toHaveBeenCalledWith(" Shields: DOWN (temporarily unlocked)"); }); + it("deadline composition removes an unproven MCP add from the restrictive policy", async () => { + const snapshot = + "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n mcp_bridge_beta: {}\n"; + const { composeDeadlineManagedMcpPolicies } = await import("./mcp-policy-transition"); + const composition = composeDeadlineManagedMcpPolicies(snapshot, [], ["mcp_bridge_beta"]); + + expect(composition.yaml).toContain("restrictive_baseline"); + expect(composition.yaml).not.toContain("mcp_bridge_beta"); + }); + + it("deadline restore removes saved MCP keys when the registry cannot be read", async () => { + const sandboxName = "openclaw"; + const processToken = "b".repeat(32); + const snapshotPath = path.join(stateDir(), "policy-snapshot-unreadable-registry.yaml"); + fs.mkdirSync(stateDir(), { recursive: true }); + fs.writeFileSync( + snapshotPath, + "version: 1\nnetwork_policies:\n restrictive_baseline: {}\n mcp_bridge_alpha: {}\n", + ); + writeState(sandboxName, { + shieldsDown: true, + shieldsPolicySnapshotPath: snapshotPath, + shieldsManagedMcpPolicyKeys: ["mcp_bridge_alpha"], + }); + writeMarker(sandboxName, { + pid: 2_147_483_647, + sandboxName, + snapshotPath, + restoreAt: new Date(Date.now() - 1_000).toISOString(), + processToken, + }); + const originalReadFileSync = fs.readFileSync.bind(fs); + vi.spyOn(fs, "readFileSync").mockImplementation((file, options) => { + return readFileWithUnreadableRegistry(originalReadFileSync, file, options) as never; + }); + vi.spyOn(process, "kill").mockImplementation(routeProcessKill); + const originalRmSync = fs.rmSync.bind(fs); + let appliedPolicy = ""; + vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { + const cleanupDir = String(target); + appliedPolicy = + readRuntimePolicyBeforeCleanup(cleanupDir, originalReadFileSync) ?? appliedPolicy; + originalRmSync(target, options); + }); + const { applyShieldsPolicySnapshot } = await loadShieldsModule(); + + const result = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { + transitionProcessToken: processToken, + deadlineAuthoritative: true, + expiredTimerRecovery: true, + }); + + expect(result.managedMcpOmissions).toEqual([ + expect.objectContaining({ reason: expect.stringMatching(/Cannot read config file:/) }), + ]); + expect(appliedPolicy).toContain("restrictive_baseline"); + expect(appliedPolicy).not.toContain("mcp_bridge_alpha"); + }); + it("shieldsStatus warns and stays DOWN when inline recovery fails", async () => { const sandboxName = "openclaw"; const missingSnapshotPath = path.join(stateDir(), "missing-snapshot.yaml"); diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 81437c29cc4..975747f785d 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -56,7 +56,13 @@ const { resolveNemoclawStateDir } = require("../state/paths"); const { appendAuditEntry } = require("./audit"); const { resolveAgentConfig } = require("../sandbox/agent-config"); const { + assertLegacyMcpPolicyRestoreSafe, + buildDeadlineRuntimeManagedMcpPolicy, + buildRuntimeManagedMcpPolicy, buildRuntimePermissivePolicy, + hasManagedMcpPolicyClaims, + inspectExactManagedMcpPolicies, + inspectProvableManagedMcpPoliciesForDeadline, }: typeof import("./permissive-runtime") = require("./permissive-runtime"); const { cleanupTempDir } = require("../onboard/temp-files"); const { verifyShieldsLockState }: typeof import("./verify-lock") = require("./verify-lock"); @@ -102,6 +108,7 @@ const { }: typeof import("./mutable-config-repair") = require("./mutable-config-repair"); type MutableConfigPermsInspection = import("./mutable-config-perms").MutableConfigPermsInspection; type MutableConfigRepairResult = import("./mutable-config-perms").MutableConfigRepairResult; +type ManagedMcpPolicyOmission = import("./permissive-runtime").ManagedMcpPolicyOmission; type TimerMarker = import("./timer-control").TimerMarker; const STATE_DIR = resolveNemoclawStateDir(); const SHIELDS_TRANSITION_POLL_MS = 50; @@ -132,6 +139,8 @@ type ShieldsDownTransition = { processToken: string; sandboxName: string; snapshotPath: string; + /** Exact generated MCP keys owned when snapshotPath was captured. */ + managedMcpPolicyKeys?: string[]; }; const transitionPollBuffer = new Int32Array(new SharedArrayBuffer(4)); @@ -183,10 +192,19 @@ function isShieldsDownTransition(value: unknown): value is ShieldsDownTransition typeof value.processToken === "string" && /^[0-9a-f]{32}$/.test(value.processToken) && typeof value.sandboxName === "string" && - typeof value.snapshotPath === "string" + typeof value.snapshotPath === "string" && + isOptionalManagedMcpPolicyKeys(value.managedMcpPolicyKeys) ); } +function sameManagedMcpPolicyKeys( + left: readonly string[] | undefined, + right: readonly string[] | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right; + return left.length === right.length && left.every((key, index) => key === right[index]); +} + function readShieldsDownTransition( sandboxName: string, processToken: string, @@ -215,7 +233,8 @@ function writeShieldsDownTransition( current.phase !== expectedPhase || current.ownerPid !== transition.ownerPid || current.snapshotPath !== transition.snapshotPath || - current.ownerMcpProcessIdentity !== transition.ownerMcpProcessIdentity + current.ownerMcpProcessIdentity !== transition.ownerMcpProcessIdentity || + !sameManagedMcpPolicyKeys(current.managedMcpPolicyKeys, transition.managedMcpPolicyKeys) ) { throw new Error("Shields-down recovery ownership changed during the transition"); } @@ -322,7 +341,8 @@ function waitForShieldsDownForwardCommit( next.ownerStartIdentity !== observed.ownerStartIdentity || next.ownerMcpProcessIdentity !== observed.ownerMcpProcessIdentity || next.snapshotPath !== observed.snapshotPath || - next.processToken !== observed.processToken + next.processToken !== observed.processToken || + !sameManagedMcpPolicyKeys(next.managedMcpPolicyKeys, observed.managedMcpPolicyKeys) ) { throw new Error("Shields-down recovery ownership changed while waiting for forward commit"); } @@ -681,6 +701,8 @@ interface ShieldsState { shieldsDownReason?: string | null; shieldsDownPolicy?: string | null; shieldsPolicySnapshotPath?: string | null; + /** Exact generated MCP keys owned in the restrictive snapshot. */ + shieldsManagedMcpPolicyKeys?: string[]; chattrApplied?: boolean; // SHA-256 seal of each locked file, captured by `shields up` after the // lock verification passes. `shields status` re-hashes the same files @@ -1147,6 +1169,14 @@ function isOptionalHashMap(value: unknown): value is { [path: string]: string } return true; } +function isOptionalManagedMcpPolicyKeys(value: unknown): value is string[] | undefined { + if (value === undefined) return true; + // Preserve string entries exactly so deadline recovery can strip and audit + // malformed or duplicate ownership without delaying restrictive lockdown. + // Manual restoration validates the same entries strictly during composition. + return Array.isArray(value) && value.every((key) => typeof key === "string"); +} + function isShieldsState(value: unknown): value is ShieldsState { return ( isObjectRecord(value) && @@ -1156,6 +1186,7 @@ function isShieldsState(value: unknown): value is ShieldsState { isOptionalNullableString(value.shieldsDownReason) && isOptionalNullableString(value.shieldsDownPolicy) && isOptionalNullableString(value.shieldsPolicySnapshotPath) && + isOptionalManagedMcpPolicyKeys(value.shieldsManagedMcpPolicyKeys) && isOptionalBoolean(value.chattrApplied) && isOptionalHashMap(value.fileHashes) && isOptionalString(value.updatedAt) @@ -2435,6 +2466,7 @@ function synchronizeAutoRestoreTransition( processToken: string, snapshotPath: string, options: { + expiredTimerRecovery?: boolean; retainTransition?: boolean; assertTakeoverAuthority?: () => void; } = {}, @@ -2455,8 +2487,16 @@ function synchronizeAutoRestoreTransition( // above waits until the forward path has either committed its last weakening // mutation or its owner has died; restore the restrictive snapshot again at // that stable boundary before locking config. - const restoreResult = run(buildPolicySetCommand(transition.snapshotPath, sandboxName), { - ignoreError: true, + const marker = readTimerMarker(sandboxName); + const timerOwnsRecovery = + marker?.pid === process.pid && + marker.processToken === processToken && + marker.snapshotPath === transition.snapshotPath; + const deadlineAuthoritative = timerOwnsRecovery || options.expiredTimerRecovery === true; + const restoreResult = applyShieldsPolicySnapshot(sandboxName, transition.snapshotPath, { + transitionProcessToken: processToken, + ...(deadlineAuthoritative ? { deadlineAuthoritative: true } : {}), + ...(options.expiredTimerRecovery ? { expiredTimerRecovery: true } : {}), }); const status = typeof restoreResult.status === "number" ? restoreResult.status : 1; if (status !== 0) { @@ -2614,6 +2654,199 @@ function lockAgentConfig( }); } +function resolveExactManagedMcpPolicies( + sandboxName: string, + livePolicyYaml?: string, +): ReturnType { + let effectiveLivePolicy = livePolicyYaml; + if (!effectiveLivePolicy) { + let rawPolicy: string; + try { + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); + } catch (error) { + throw new Error("Cannot read the live gateway policy for managed MCP reconciliation", { + cause: error, + }); + } + effectiveLivePolicy = parseCurrentPolicy(rawPolicy); + } + if (!effectiveLivePolicy) { + throw new Error("Cannot parse the live gateway policy for managed MCP reconciliation"); + } + return inspectExactManagedMcpPolicies(sandboxName, effectiveLivePolicy); +} + +function resolveProvableManagedMcpPoliciesForDeadline( + sandboxName: string, +): ReturnType { + try { + let effectiveLivePolicy = ""; + try { + effectiveLivePolicy = parseCurrentPolicy(runCapture(buildPolicyGetCommand(sandboxName))); + } catch { + // The tolerant deadline inspector records exact omissions for every claim + // when the live policy cannot be parsed or read. + } + return inspectProvableManagedMcpPoliciesForDeadline(sandboxName, effectiveLivePolicy); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + policies: [], + omissions: [ + { + reason: `Managed MCP registry inspection failed at the auto-restore deadline: ${message}`, + }, + ], + }; + } +} + +/** + * Restore a saved complete policy while reconciling only exact generated MCP + * entries. Snapshot-time keys are removed before currently owned entries are + * overlaid, so changes made during the shields-down window survive both manual + * and timer restoration. + */ +interface ShieldsPolicySnapshotRestoreOptions { + transitionProcessToken?: string; + deadlineAuthoritative?: boolean; + expiredTimerRecovery?: boolean; +} + +type ShieldsPolicySnapshotRestoreResult = ReturnType & { + managedMcpOmissions?: ManagedMcpPolicyOmission[]; +}; + +function applyShieldsPolicySnapshot( + sandboxName: string, + snapshotPath: string, + options: ShieldsPolicySnapshotRestoreOptions = {}, +): ShieldsPolicySnapshotRestoreResult { + const state = loadShieldsState(sandboxName); + let transition: ShieldsDownTransition | null = null; + if (options.transitionProcessToken !== undefined) { + if (!/^[0-9a-f]{32}$/.test(options.transitionProcessToken)) { + throw new Error("Invalid Shields transition recovery token"); + } + transition = readShieldsDownTransition(sandboxName, options.transitionProcessToken); + if ( + !transition && + fs.existsSync(shieldsDownTransitionPath(sandboxName, options.transitionProcessToken)) + ) { + throw new Error("Shields transition recovery authority is invalid"); + } + if (transition && transition.snapshotPath !== snapshotPath) { + throw new Error("Shields transition does not authorize the policy snapshot being restored"); + } + } + if (options.deadlineAuthoritative) { + const marker = readTimerMarker(sandboxName); + const markerMatchesRecovery = + marker?.sandboxName === sandboxName && + marker.snapshotPath === snapshotPath && + marker.processToken === options.transitionProcessToken; + const restoreAtMs = marker ? new Date(marker.restoreAt).getTime() : Number.NaN; + const expiredTimerIsInactive = + options.expiredTimerRecovery === true && + markerMatchesRecovery && + Number.isFinite(restoreAtMs) && + restoreAtMs <= Date.now() && + (!isProcessAlive(marker!.pid) || !verifyTimerMarkerIdentity(marker!).verified); + if ( + options.transitionProcessToken === undefined || + !markerMatchesRecovery || + (marker!.pid !== process.pid && !expiredTimerIsInactive) + ) { + throw new Error("The active auto-restore timer does not authorize deadline restoration"); + } + } + + if (state._isCorrupt && !transition) { + throw new Error( + `Cannot restore a Shields policy while persisted state is corrupt: ${ + state._corruptError ?? "invalid state" + }`, + ); + } + // A preparing transition can outlive its owner before Shields state is + // committed; its token-bound marker is then the recovery authority. + // Every ordinary restore remains bound to the exact persisted snapshot. + if (!transition && state.shieldsPolicySnapshotPath !== snapshotPath) { + throw new Error("Shields state does not match the policy snapshot being restored"); + } + const persistedSnapshotMatches = state.shieldsPolicySnapshotPath === snapshotPath; + const ownershipOmissions: ManagedMcpPolicyOmission[] = []; + if ( + transition?.managedMcpPolicyKeys !== undefined && + persistedSnapshotMatches && + state.shieldsManagedMcpPolicyKeys !== undefined && + !sameManagedMcpPolicyKeys(transition.managedMcpPolicyKeys, state.shieldsManagedMcpPolicyKeys) + ) { + if (!options.deadlineAuthoritative) { + throw new Error("Shields transition ownership does not match persisted policy ownership"); + } + ownershipOmissions.push({ + reason: + "Shields transition ownership did not match persisted policy ownership at the auto-restore deadline", + }); + } + let snapshotManagedPolicyKeys = + transition?.managedMcpPolicyKeys ?? + (persistedSnapshotMatches ? state.shieldsManagedMcpPolicyKeys : undefined); + // Older Shields state has no exact snapshot-time ownership manifest. + // A manual restore preserves raw-snapshot behavior only when neither current + // state nor the snapshot can involve managed MCP. Deadline restoration + // instead strips every reserved key and overlays only independently proven + // current entries so legacy metadata cannot delay restrictive lockdown. + if (snapshotManagedPolicyKeys === undefined) { + if (options.deadlineAuthoritative) { + snapshotManagedPolicyKeys = []; + ownershipOmissions.push({ + reason: + "Legacy Shields state had no managed MCP ownership manifest at the auto-restore deadline", + }); + } else { + assertLegacyMcpPolicyRestoreSafe( + fs.readFileSync(snapshotPath, "utf-8"), + hasManagedMcpPolicyClaims(sandboxName), + ); + return run(buildPolicySetCommand(snapshotPath, sandboxName), { + ignoreError: true, + }); + } + } + let managedMcpOmissions: ManagedMcpPolicyOmission[] = []; + let runtimePolicyPath: string; + if (options.deadlineAuthoritative) { + const inspection = resolveProvableManagedMcpPoliciesForDeadline(sandboxName); + const runtime = buildDeadlineRuntimeManagedMcpPolicy(snapshotPath, { + managedMcpPolicies: inspection.policies, + snapshotManagedPolicyKeys, + readBasePolicy: () => fs.readFileSync(snapshotPath, "utf-8"), + }); + runtimePolicyPath = runtime.path; + managedMcpOmissions = [...ownershipOmissions, ...inspection.omissions, ...runtime.omissions]; + } else { + const managedMcpPolicies = resolveExactManagedMcpPolicies(sandboxName); + runtimePolicyPath = buildRuntimeManagedMcpPolicy(snapshotPath, { + managedMcpPolicies, + snapshotManagedPolicyKeys, + readBasePolicy: () => fs.readFileSync(snapshotPath, "utf-8"), + }); + } + const runtimePolicyIsTemp = runtimePolicyPath !== snapshotPath; + try { + const result = run(buildPolicySetCommand(runtimePolicyPath, sandboxName), { + ignoreError: true, + }); + return managedMcpOmissions.length > 0 ? { ...result, managedMcpOmissions } : result; + } finally { + if (runtimePolicyIsTemp) { + cleanupTempDir(runtimePolicyPath, "nemoclaw-permissive-runtime"); + } + } +} + function rollbackShieldsDown( sandboxName: string, target: AgentConfigTarget, @@ -2622,12 +2855,16 @@ function rollbackShieldsDown( cachedProtocol?: HermesShieldsProtocol, ): void { console.error(" Rolling back — restoring policy from snapshot..."); - const rollbackResult = run(buildPolicySetCommand(snapshotPath, sandboxName), { - ignoreError: true, - }); + let rollbackResult: ReturnType | null = null; + try { + rollbackResult = applyShieldsPolicySnapshot(sandboxName, snapshotPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(` Warning: Policy restore preparation failed during rollback: ${message}`); + } let rollbackChattrApplied: boolean | null = null; let rollbackFileHashes: { [path: string]: string } | null = null; - if (rollbackResult.status === 0) { + if (rollbackResult?.status === 0) { // Re-confirm after the settle window so a reconciler revert cannot leave // the rolled-back config DRIFTED — same fail-closed treatment as the // auto-restore path. Leaves the hashes null (→ "manual intervention" @@ -2668,6 +2905,7 @@ interface LockdownActivationResult { error?: string; chattrApplied?: boolean; fileHashes?: { [path: string]: string }; + managedMcpOmissions?: ManagedMcpPolicyOmission[]; } function activateLockdownFromSnapshot( @@ -2676,14 +2914,23 @@ function activateLockdownFromSnapshot( allowLegacyHermesProtocol = false, cachedTarget?: AgentConfigTarget, cachedProtocol?: HermesShieldsProtocol, + restoreOptions: ShieldsPolicySnapshotRestoreOptions = {}, ): LockdownActivationResult { if (!snapshotPath || !fs.existsSync(snapshotPath)) { return { ok: false, error: "saved snapshot is missing" }; } - const restoreResult = run(buildPolicySetCommand(snapshotPath, sandboxName), { - ignoreError: true, - }); + let restoreResult: ShieldsPolicySnapshotRestoreResult; + try { + restoreResult = applyShieldsPolicySnapshot(sandboxName, snapshotPath, restoreOptions); + } catch (error) { + return { + ok: false, + error: `policy restore preparation failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } const restoreStatus = typeof restoreResult.status === "number" ? restoreResult.status : 1; if (restoreStatus !== 0) { return { @@ -2725,6 +2972,9 @@ function activateLockdownFromSnapshot( ok: true, chattrApplied: relock.lastResult.chattrApplied, fileHashes: relock.lastResult.fileHashes, + ...(restoreResult.managedMcpOmissions + ? { managedMcpOmissions: restoreResult.managedMcpOmissions } + : {}), }; } @@ -2764,6 +3014,7 @@ function recoverExpiredAutoRestoreInline( if (marker.processToken && /^[0-9a-f]{32}$/.test(marker.processToken)) { try { synchronizeAutoRestoreTransition(sandboxName, marker.processToken, marker.snapshotPath, { + expiredTimerRecovery: true, retainTransition: true, assertTakeoverAuthority: () => assertTimerMarkerGeneration(sandboxName, marker), }); @@ -2786,6 +3037,15 @@ function recoverExpiredAutoRestoreInline( sandboxName, marker.snapshotPath, marker.allowLegacyHermesProtocol === true, + undefined, + undefined, + marker.processToken && /^[0-9a-f]{32}$/.test(marker.processToken) + ? { + transitionProcessToken: marker.processToken, + deadlineAuthoritative: true, + expiredTimerRecovery: true, + } + : {}, ); const nowIso = new Date().toISOString(); if (!activation.ok) { @@ -2826,6 +3086,13 @@ function recoverExpiredAutoRestoreInline( restored_by: "auto_timer", policy_snapshot: marker.snapshotPath, restored_at: nowIso, + ...(activation.managedMcpOmissions?.length + ? { + warning: `Inline auto-restore omitted ${String( + activation.managedMcpOmissions.length, + )} unproven managed MCP policy entries`, + } + : {}), }); return { attempted: true, restored: true }; } @@ -2921,6 +3188,19 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = return failShieldsCommand("Cannot capture current policy", opts.throwOnError); } + let managedMcpPolicies: ReturnType; + try { + managedMcpPolicies = resolveExactManagedMcpPolicies(sandboxName, policyYaml); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(` Cannot preserve managed MCP policy state: ${message}`); + return failShieldsCommand( + `Cannot preserve managed MCP policy state: ${message}`, + opts.throwOnError, + ); + } + const snapshotManagedMcpPolicyKeys = managedMcpPolicies.map((policy) => policy.key); + const ts = Date.now(); const snapshotPath = path.join(STATE_DIR, `policy-snapshot-${ts}.yaml`); fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); @@ -2930,136 +3210,153 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = // 2. Determine and apply relaxed policy let policyFile: string; 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. - // 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"), - }); - policyFileIsTemp = policyFile !== basePath; - } else if (fs.existsSync(policyName)) { - policyFile = path.resolve(policyName); - } else { - console.error(` Unknown policy "${policyName}". Use "permissive" or a path to a YAML file.`); - return failShieldsCommand(`Unknown policy "${policyName}"`, opts.throwOnError); + try { + 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. + // policyYaml is the pre-parsed body we already captured for the + // snapshot above — reuse it instead of re-fetching. Exact generated MCP + // entries are overlaid without copying any unrelated live egress. + policyFile = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: policyYaml, + managedMcpPolicies, + readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), + }); + policyFileIsTemp = policyFile !== basePath; + } else if (fs.existsSync(policyName)) { + const basePath = path.resolve(policyName); + policyFile = buildRuntimeManagedMcpPolicy(basePath, { + managedMcpPolicies, + readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), + }); + policyFileIsTemp = policyFile !== basePath; + } else { + console.error(` Unknown policy "${policyName}". Use "permissive" or a path to a YAML file.`); + fs.rmSync(snapshotPath, { force: true }); + return failShieldsCommand(`Unknown policy "${policyName}"`, opts.throwOnError); + } + } catch (error) { + fs.rmSync(snapshotPath, { force: true }); + const message = error instanceof Error ? error.message : String(error); + console.error(` Cannot compose Shields-down policy: ${message}`); + return failShieldsCommand(`Cannot compose Shields-down policy: ${message}`, opts.throwOnError); } const now = new Date().toISOString(); let transition: ShieldsDownTransition | null = null; - // Commit the host-side recovery authority before weakening policy or file - // permissions. If this process is killed later, the detached timer and its - // marker already exist and the persisted state honestly reports shields - // down. A crash can therefore never leave an untracked mutable window. - if (!opts.skipTimer) { - const restoreAt = new Date(Date.now() + timeoutSeconds * 1000); - const processToken = opts.processToken ?? randomBytes(16).toString("hex"); - if (!/^[0-9a-f]{32}$/.test(processToken)) { - throw new Error("Invalid shields-down recovery process token"); - } - const timerScript = path.join(__dirname, "timer.ts"); - const timerScriptJs = timerScript.replace(/\.ts$/, ".js"); - const actualScript = fs.existsSync(timerScriptJs) ? timerScriptJs : timerScript; - transition = { - version: 1, - phase: "preparing", - ownerPid: process.pid, - ownerStartIdentity: - readProcessStartIdentity(process.pid) ?? - (() => { - throw new Error("Cannot identify shields-down owner process"); - })(), - ownerMcpProcessIdentity: - readMcpLockProcessIdentity(process.pid, true) ?? - (() => { - throw new Error("Cannot identify shields-down lifecycle owner process"); - })(), - processToken, - sandboxName, - snapshotPath, - }; - const leaseOwnerPid = opts.deferAutoRestoreWhileOwnerAlive ? transition.ownerPid : null; - const leaseOwnerStartIdentity = opts.deferAutoRestoreWhileOwnerAlive - ? transition.ownerStartIdentity - : null; - let timerChild: ReturnType | null = null; + try { + // Commit the host-side recovery authority before weakening policy or file + // permissions. If this process is killed later, the detached timer and its + // marker already exist and the persisted state honestly reports shields + // down. A crash can therefore never leave an untracked mutable window. + if (!opts.skipTimer) { + const restoreAt = new Date(Date.now() + timeoutSeconds * 1000); + const processToken = opts.processToken ?? randomBytes(16).toString("hex"); + if (!/^[0-9a-f]{32}$/.test(processToken)) { + throw new Error("Invalid shields-down recovery process token"); + } + const timerScript = path.join(__dirname, "timer.ts"); + const timerScriptJs = timerScript.replace(/\.ts$/, ".js"); + const actualScript = fs.existsSync(timerScriptJs) ? timerScriptJs : timerScript; + transition = { + version: 1, + phase: "preparing", + ownerPid: process.pid, + ownerStartIdentity: + readProcessStartIdentity(process.pid) ?? + (() => { + throw new Error("Cannot identify shields-down owner process"); + })(), + ownerMcpProcessIdentity: + readMcpLockProcessIdentity(process.pid, true) ?? + (() => { + throw new Error("Cannot identify shields-down lifecycle owner process"); + })(), + processToken, + sandboxName, + snapshotPath, + managedMcpPolicyKeys: snapshotManagedMcpPolicyKeys, + }; + const leaseOwnerPid = opts.deferAutoRestoreWhileOwnerAlive ? transition.ownerPid : null; + const leaseOwnerStartIdentity = opts.deferAutoRestoreWhileOwnerAlive + ? transition.ownerStartIdentity + : null; + let timerChild: ReturnType | null = null; - try { - // Publish the forward-transition ownership marker before authorizing the - // timer. If the timeout expires while this command is still weakening - // policy/config, the timer waits for phase=active or owner death instead - // of racing the forward mutations. - writeShieldsDownTransition(transition, null); - timerChild = fork( - actualScript, - [ + try { + // Publish the forward-transition ownership marker before authorizing the + // timer. If the timeout expires while this command is still weakening + // policy/config, the timer waits for phase=active or owner death instead + // of racing the forward mutations. + writeShieldsDownTransition(transition, null); + timerChild = fork( + actualScript, + [ + sandboxName, + snapshotPath, + restoreAt.toISOString(), + target.configPath, + target.configDir, + processToken, + opts.allowLegacyHermesProtocol === true ? "1" : "0", + leaseOwnerPid === null ? "" : String(leaseOwnerPid), + leaseOwnerStartIdentity ?? "", + ], + { + detached: true, + stdio: ["ignore", "ignore", "ignore", "ipc"], + }, + ); + if (!timerChild.pid) throw new Error("auto-restore timer did not report a process id"); + writeTimerMarkerAtomic(sandboxName, { + pid: timerChild.pid, sandboxName, snapshotPath, - restoreAt.toISOString(), - target.configPath, - target.configDir, + restoreAt: restoreAt.toISOString(), processToken, - opts.allowLegacyHermesProtocol === true ? "1" : "0", - leaseOwnerPid === null ? "" : String(leaseOwnerPid), - leaseOwnerStartIdentity ?? "", - ], - { - detached: true, - stdio: ["ignore", "ignore", "ignore", "ipc"], - }, - ); - if (!timerChild.pid) throw new Error("auto-restore timer did not report a process id"); - writeTimerMarkerAtomic(sandboxName, { - pid: timerChild.pid, - sandboxName, - snapshotPath, - restoreAt: restoreAt.toISOString(), - processToken, - allowLegacyHermesProtocol: opts.allowLegacyHermesProtocol === true, - ...(leaseOwnerPid !== null && leaseOwnerStartIdentity - ? { leaseOwnerPid, leaseOwnerStartIdentity } - : {}), - }); - if (!timerChild.send({ type: "authorize", processToken })) { - throw new Error("auto-restore timer authorization channel closed early"); + allowLegacyHermesProtocol: opts.allowLegacyHermesProtocol === true, + ...(leaseOwnerPid !== null && leaseOwnerStartIdentity + ? { leaseOwnerPid, leaseOwnerStartIdentity } + : {}), + }); + if (!timerChild.send({ type: "authorize", processToken })) { + throw new Error("auto-restore timer authorization channel closed early"); + } + timerChild.disconnect(); + timerChild.unref(); + } catch (err) { + clearTimerMarker(sandboxName); + clearShieldsDownTransition(sandboxName, processToken); + const message = err instanceof Error ? err.message : String(err); + console.error(` Cannot start auto-restore timer: ${message}`); + return failShieldsCommand(`Cannot start auto-restore timer: ${message}`, opts.throwOnError); } - timerChild.disconnect(); - timerChild.unref(); - } catch (err) { - clearTimerMarker(sandboxName); - clearShieldsDownTransition(sandboxName, processToken); - const message = err instanceof Error ? err.message : String(err); - console.error(` Cannot start auto-restore timer: ${message}`); - return failShieldsCommand(`Cannot start auto-restore timer: ${message}`, opts.throwOnError); } - } - try { - saveShieldsState(sandboxName, { - shieldsDown: true, - shieldsDownAt: now, - shieldsDownTimeout: timeoutSeconds, - shieldsDownReason: reason, - shieldsDownPolicy: policyName, - shieldsPolicySnapshotPath: snapshotPath, - }); - } catch (error) { - if (transition) { - clearShieldsDownTransition(sandboxName, transition.processToken); - killTimer(sandboxName); + try { + saveShieldsState(sandboxName, { + shieldsDown: true, + shieldsDownAt: now, + shieldsDownTimeout: timeoutSeconds, + shieldsDownReason: reason, + shieldsDownPolicy: policyName, + shieldsPolicySnapshotPath: snapshotPath, + shieldsManagedMcpPolicyKeys: snapshotManagedMcpPolicyKeys, + }); + } catch (error) { + if (transition) { + clearShieldsDownTransition(sandboxName, transition.processToken); + killTimer(sandboxName); + } + throw error; } - throw error; - } - console.log(` Applying ${policyName} policy...`); - try { + console.log(` Applying ${policyName} policy...`); run(buildPolicySetCommand(policyFile, sandboxName)); } finally { if (policyFileIsTemp) { @@ -3676,6 +3973,7 @@ function clearShieldsState(sandboxName: string): void { // --------------------------------------------------------------------------- export { + applyShieldsPolicySnapshot, clearShieldsState, completeAutoRestoreTransition, DEFAULT_TIMEOUT_SECONDS, diff --git a/src/lib/shields/mcp-policy-transition.test.ts b/src/lib/shields/mcp-policy-transition.test.ts new file mode 100644 index 00000000000..181a97bbac3 --- /dev/null +++ b/src/lib/shields/mcp-policy-transition.test.ts @@ -0,0 +1,666 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +import { + hasManagedMcpPolicyClaims, + inspectProvableManagedMcpPoliciesForDeadline, + inspectExactManagedMcpPolicies as inspectRegisteredManagedMcpPolicies, + MCP_BRIDGE_POLICY_SOURCE, +} from "../actions/sandbox/mcp-bridge-policy"; +import { + buildMcpBridgePolicyKey, + buildMcpBridgePolicyName, + buildMcpBridgePolicyYaml, +} from "../actions/sandbox/mcp-bridge-policy-render"; +import type { SandboxEntry } from "../state/registry"; +import { + assertLegacyMcpPolicyRestoreSafe, + composeDeadlineManagedMcpPolicies, + composeManagedMcpPolicies, +} from "./mcp-policy-transition"; + +const ADAPTER = "hermes-config"; + +function registeredPolicy( + server: string, + address: string, +): NonNullable[number] { + return { + name: buildMcpBridgePolicyName(server), + content: buildMcpBridgePolicyYaml(server, `https://${server}.example.com/mcp`, ADAPTER, [ + address, + ]), + sourcePath: MCP_BRIDGE_POLICY_SOURCE, + }; +} + +function bridge(server: string): NonNullable["bridges"]>[string] { + return { + server, + agent: "hermes", + adapter: ADAPTER, + url: `https://${server}.example.com/mcp`, + env: ["MCP_SECRET"], + providerName: `sandbox-mcp-${server}`, + providerId: `provider-${server}`, + policyName: buildMcpBridgePolicyName(server), + addedAt: "2026-07-30T00:00:00.000Z", + }; +} + +function sandboxWithPolicies( + policies: Array>, + bridgeServers = policies.map((policy) => policy.name.replace(/^mcp-bridge-/, "")), +): SandboxEntry { + return { + name: "alpha", + agent: "hermes", + customPolicies: policies, + mcp: { + bridges: Object.fromEntries(bridgeServers.map((server) => [server, bridge(server)])), + }, + }; +} + +function networkEntry(content: string, server: string): unknown { + return YAML.parse(content).network_policies[buildMcpBridgePolicyKey(server)]; +} + +function mutateRegisteredNetworkPolicy( + policy: ReturnType, + server: string, + mutate: (entry: Record) => void, +): void { + const document = YAML.parse(policy.content) as { + network_policies: Record>; + }; + mutate(document.network_policies[buildMcpBridgePolicyKey(server)]!); + policy.content = YAML.stringify(document); +} + +function livePolicy( + entries: Array<{ content: string; server: string }>, + extra: Record = {}, +): string { + return YAML.stringify({ + version: 1, + network_policies: { + ...extra, + ...Object.fromEntries( + entries.map(({ content, server }) => [ + buildMcpBridgePolicyKey(server), + networkEntry(content, server), + ]), + ), + }, + }); +} + +function inspectExactManagedMcpPolicies(sandbox: SandboxEntry, livePolicyYaml: string) { + return inspectRegisteredManagedMcpPolicies("alpha", livePolicyYaml, { + getSandbox: () => sandbox, + }); +} + +describe("managed MCP Shields policy transitions (#7952)", () => { + it("admits only canonical committed registrations that exactly match the live policy", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const sandbox = sandboxWithPolicies([alpha]); + + const exact = inspectExactManagedMcpPolicies( + sandbox, + livePolicy([{ content: alpha.content, server: "alpha" }], { + unrelated_live_entry: { endpoints: [{ host: "unrelated.example.com" }] }, + }), + ); + + expect(exact).toEqual([ + expect.objectContaining({ + key: "mcp_bridge_alpha", + policyName: "mcp-bridge-alpha", + server: "alpha", + }), + ]); + }); + + it.each([ + { + label: "pending policy content", + mutate: (sandbox: SandboxEntry) => { + sandbox.customPolicies![0]!.pendingContent = sandbox.customPolicies![0]!.content; + }, + expected: /incomplete policy transition/, + }, + { + label: "an orphaned generated registration", + mutate: (sandbox: SandboxEntry) => { + sandbox.customPolicies!.push(registeredPolicy("orphan", "1.1.1.1")); + }, + expected: /no committed managed bridge ownership/, + }, + { + label: "an incomplete bridge add", + mutate: (sandbox: SandboxEntry) => { + sandbox.mcp!.bridges.alpha!.addState = "prepared"; + }, + expected: /lifecycle transition is incomplete/, + }, + ])("fails closed on $label", ({ mutate, expected }) => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const sandbox = sandboxWithPolicies([alpha]); + mutate(sandbox); + + expect(() => + inspectExactManagedMcpPolicies( + sandbox, + livePolicy( + (sandbox.customPolicies ?? []).map((policy) => ({ + content: policy.content, + server: policy.name.replace(/^mcp-bridge-/, ""), + })), + ), + ), + ).toThrow(expected); + }); + + it("fails closed when the live policy differs from the ownership record", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const drifted = registeredPolicy("alpha", "1.1.1.1"); + + expect(() => + inspectExactManagedMcpPolicies( + sandboxWithPolicies([alpha]), + livePolicy([{ content: drifted.content, server: "alpha" }]), + ), + ).toThrow(/drifted from its ownership record/); + }); + + it("rejects matching registry and live documents with weakened generated semantics", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + mutateRegisteredNetworkPolicy(alpha, "alpha", (entry) => { + const endpoint = (entry.endpoints as Array>)[0]!; + endpoint.enforcement = "observe"; + }); + const sandbox = sandboxWithPolicies([alpha]); + const live = livePolicy([{ content: alpha.content, server: "alpha" }]); + + expect(() => inspectExactManagedMcpPolicies(sandbox, live)).toThrow( + /non-canonical generated content/, + ); + expect( + inspectProvableManagedMcpPoliciesForDeadline("alpha", live, { + getSandbox: () => sandbox, + }), + ).toEqual({ + policies: [], + omissions: [ + expect.objectContaining({ + server: "alpha", + reason: expect.stringMatching(/non-canonical generated content/), + }), + ], + }); + }); + + it.each([ + { + label: "a private literal", + pins: ["127.0.0.1"], + expected: /invalid public address pins/, + }, + { + label: "a scoped public IPv6 literal", + pins: ["2001:4860:4860::8888%lo0"], + expected: /invalid public address pins/, + }, + { + label: "duplicate literals", + pins: ["8.8.8.8", "8.8.8.8"], + expected: /non-canonical public address pins/, + }, + { + label: "unsorted literals", + pins: ["8.8.8.8", "1.1.1.1"], + expected: /non-canonical public address pins/, + }, + ])("rejects matching registry and live documents with $label", ({ pins, expected }) => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + mutateRegisteredNetworkPolicy(alpha, "alpha", (entry) => { + const endpoint = (entry.endpoints as Array>)[0]!; + endpoint.allowed_ips = pins; + }); + + expect(() => + inspectExactManagedMcpPolicies( + sandboxWithPolicies([alpha]), + livePolicy([{ content: alpha.content, server: "alpha" }]), + ), + ).toThrow(expected); + }); + + it("fails closed on a generated policy record without managed MCP state", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const sandbox: SandboxEntry = { + name: "alpha", + agent: "hermes", + customPolicies: [alpha], + }; + const deps = { getSandbox: () => sandbox }; + + expect(hasManagedMcpPolicyClaims("alpha", deps)).toBe(true); + expect(() => + inspectRegisteredManagedMcpPolicies( + "alpha", + livePolicy([{ content: alpha.content, server: "alpha" }]), + deps, + ), + ).toThrow(/no committed managed bridge ownership/); + }); + + it("treats residual managed server history as an ownership claim", () => { + const sandbox: SandboxEntry = { + name: "alpha", + agent: "hermes", + mcp: { bridges: {}, managedServerNames: ["retired"] }, + }; + const deps = { getSandbox: () => sandbox }; + + expect(hasManagedMcpPolicyClaims("alpha", deps)).toBe(true); + expect( + inspectRegisteredManagedMcpPolicies( + "alpha", + livePolicy([], { unrelated_live_entry: {} }), + deps, + ), + ).toEqual([]); + }); + + it.each([ + { + label: "no sandbox registry entry", + sandbox: undefined, + }, + { + label: "only residual ownership history", + sandbox: { + name: "alpha", + agent: "hermes", + mcp: { bridges: {}, managedServerNames: ["retired"] }, + } satisfies SandboxEntry, + }, + ])("rejects an unclassified reserved live key with $label", ({ sandbox }) => { + expect(() => + inspectRegisteredManagedMcpPolicies("alpha", livePolicy([], { mcp_bridge_retired: {} }), { + getSandbox: () => sandbox ?? null, + }), + ).toThrow( + /Reserved MCP policy key 'mcp_bridge_retired'.*no committed managed bridge ownership/, + ); + }); + + it("retains additions while restoring the restrictive snapshot", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const beta = registeredPolicy("beta", "1.1.1.1"); + const current = inspectExactManagedMcpPolicies( + sandboxWithPolicies([alpha, beta]), + livePolicy([ + { content: alpha.content, server: "alpha" }, + { content: beta.content, server: "beta" }, + ]), + ); + const snapshot = YAML.stringify({ + version: 1, + network_policies: { + restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + mcp_bridge_alpha: networkEntry(alpha.content, "alpha"), + }, + }); + + const restored = YAML.parse(composeManagedMcpPolicies(snapshot, current, ["mcp_bridge_alpha"])); + + expect(Object.keys(restored.network_policies).sort()).toEqual([ + "mcp_bridge_alpha", + "mcp_bridge_beta", + "restrictive_baseline", + ]); + }); + + it("does not restore a managed MCP policy removed during the shields-down window", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const snapshot = YAML.stringify({ + version: 1, + network_policies: { + restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + mcp_bridge_alpha: networkEntry(alpha.content, "alpha"), + }, + }); + + const restored = YAML.parse(composeManagedMcpPolicies(snapshot, [], ["mcp_bridge_alpha"])); + + expect(restored.network_policies).toEqual({ + restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + }); + }); + + it("replaces a stale snapshot entry with the current exact registration", () => { + const oldAlpha = registeredPolicy("alpha", "8.8.8.8"); + const currentAlpha = registeredPolicy("alpha", "1.1.1.1"); + const current = inspectExactManagedMcpPolicies( + sandboxWithPolicies([currentAlpha]), + livePolicy([{ content: currentAlpha.content, server: "alpha" }]), + ); + const snapshot = YAML.stringify({ + version: 1, + network_policies: { + mcp_bridge_alpha: networkEntry(oldAlpha.content, "alpha"), + }, + }); + + const restored = YAML.parse(composeManagedMcpPolicies(snapshot, current, ["mcp_bridge_alpha"])); + + expect(restored.network_policies.mcp_bridge_alpha).toEqual( + networkEntry(currentAlpha.content, "alpha"), + ); + }); + + it("rejects an unclassified reserved key in the restrictive snapshot", () => { + const currentAlpha = registeredPolicy("alpha", "1.1.1.1"); + const current = inspectExactManagedMcpPolicies( + sandboxWithPolicies([currentAlpha]), + livePolicy([{ content: currentAlpha.content, server: "alpha" }]), + ); + const snapshot = YAML.stringify({ + version: 1, + network_policies: { + mcp_bridge_alpha: { + name: "operator-owned-alpha", + endpoints: [{ host: "operator.example.com" }], + }, + }, + }); + + expect(() => composeManagedMcpPolicies(snapshot, current, [])).toThrow( + /Reserved MCP policy key 'mcp_bridge_alpha'.*absent from the saved ownership manifest/, + ); + }); + + it("accepts an empty ownership manifest when the snapshot has no reserved keys", () => { + const snapshot = YAML.stringify({ + version: 1, + network_policies: { restrictive_baseline: {} }, + }); + + expect(YAML.parse(composeManagedMcpPolicies(snapshot, [], [])).network_policies).toEqual({ + restrictive_baseline: {}, + }); + }); + + it("rejects a saved managed key that is absent from its policy snapshot", () => { + const snapshot = YAML.stringify({ + version: 1, + network_policies: { + restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + }, + }); + + expect(() => composeManagedMcpPolicies(snapshot, [], ["mcp_bridge_alpha"])).toThrow( + /absent from its policy snapshot/, + ); + }); + + it.each([ + { + label: "current managed MCP ownership", + hasCurrentManagedClaims: true, + networkPolicies: { restrictive_baseline: {} }, + }, + { + label: "a managed-shaped key in the snapshot", + hasCurrentManagedClaims: false, + networkPolicies: { mcp_bridge_alpha: {} }, + }, + ])("refuses legacy restore with $label", ({ hasCurrentManagedClaims, networkPolicies }) => { + expect(() => + assertLegacyMcpPolicyRestoreSafe( + YAML.stringify({ version: 1, network_policies: networkPolicies }), + hasCurrentManagedClaims, + ), + ).toThrow(/no managed MCP ownership manifest/); + }); + + it("allows a legacy restore with no current or snapshot MCP ownership", () => { + expect(() => + assertLegacyMcpPolicyRestoreSafe( + YAML.stringify({ + version: 1, + network_policies: { restrictive_baseline: {} }, + }), + false, + ), + ).not.toThrow(); + }); + + it("proves committed bridges independently while omitting an incomplete add at the deadline", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const beta = registeredPolicy("beta", "1.1.1.1"); + const sandbox = sandboxWithPolicies([alpha, beta]); + sandbox.mcp!.bridges.beta!.addState = "prepared"; + + const result = inspectProvableManagedMcpPoliciesForDeadline( + "alpha", + livePolicy([ + { content: alpha.content, server: "alpha" }, + { content: beta.content, server: "beta" }, + ]), + { getSandbox: () => sandbox }, + ); + + expect(result.policies.map((policy) => policy.server)).toEqual(["alpha"]); + expect(result.omissions).toEqual([ + expect.objectContaining({ server: "beta", reason: expect.stringMatching(/incomplete/) }), + ]); + }); + + it("omits every deadline claimant whose canonical policy identity collides", () => { + const collidingPolicy = registeredPolicy("foo-bar", "8.8.8.8"); + const sandbox = sandboxWithPolicies([collidingPolicy], ["foo-bar", "foo_bar"]); + + const result = inspectProvableManagedMcpPoliciesForDeadline( + "alpha", + livePolicy([{ content: collidingPolicy.content, server: "foo-bar" }]), + { getSandbox: () => sandbox }, + ); + + expect(result.policies).toEqual([]); + expect(result.omissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + server: "foo-bar", + reason: expect.stringMatching(/ambiguous bridge ownership/), + }), + expect.objectContaining({ + server: "foo_bar", + reason: expect.stringMatching(/ambiguous bridge ownership/), + }), + ]), + ); + }); + + it.each([ + "destroyPreparedAt", + "destroyPendingAt", + ] as const)("omits every generated policy while %s is present", (marker) => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const sandbox = sandboxWithPolicies([alpha]); + sandbox.mcp![marker] = "2026-07-30T01:00:00.000Z"; + + const result = inspectProvableManagedMcpPoliciesForDeadline( + "alpha", + livePolicy([{ content: alpha.content, server: "alpha" }]), + { getSandbox: () => sandbox }, + ); + + expect(result.policies).toEqual([]); + expect(result.omissions).toEqual([ + expect.objectContaining({ server: "alpha", reason: expect.stringMatching(/destruction/) }), + ]); + }); + + it("omits drift and orphan claims without discarding another exact bridge", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const beta = registeredPolicy("beta", "1.1.1.1"); + const driftedBeta = registeredPolicy("beta", "9.9.9.9"); + const orphan = registeredPolicy("orphan", "4.4.4.4"); + const sandbox = sandboxWithPolicies([alpha, beta, orphan], ["alpha", "beta"]); + + const result = inspectProvableManagedMcpPoliciesForDeadline( + "alpha", + livePolicy([ + { content: alpha.content, server: "alpha" }, + { content: driftedBeta.content, server: "beta" }, + { content: orphan.content, server: "orphan" }, + ]), + { getSandbox: () => sandbox }, + ); + + expect(result.policies.map((policy) => policy.server)).toEqual(["alpha"]); + expect(result.omissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ server: "beta", reason: expect.stringMatching(/drifted/) }), + expect.objectContaining({ + policyName: "mcp-bridge-orphan", + reason: expect.stringMatching(/no committed managed bridge ownership/), + }), + ]), + ); + }); + + it("deadline inspection reports an unclassified reserved live key", () => { + const result = inspectProvableManagedMcpPoliciesForDeadline( + "alpha", + livePolicy([], { mcp_bridge_residual: {} }), + { getSandbox: () => null }, + ); + + expect(result).toEqual({ + policies: [], + omissions: [ + expect.objectContaining({ + key: "mcp_bridge_residual", + reason: expect.stringMatching(/no committed managed bridge ownership/), + }), + ], + }); + }); + + it("deadline composition strips unclassified reserved keys before overlaying proven entries", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + const beta = registeredPolicy("beta", "1.1.1.1"); + const current = inspectExactManagedMcpPolicies( + sandboxWithPolicies([alpha, beta]), + livePolicy([ + { content: alpha.content, server: "alpha" }, + { content: beta.content, server: "beta" }, + ]), + ); + const operatorEntry = { endpoints: [{ host: "operator.example.com" }] }; + const snapshot = YAML.stringify({ + version: 1, + network_policies: { + mcp_bridge_alpha: networkEntry(alpha.content, "alpha"), + mcp_bridge_beta: operatorEntry, + restrictive_baseline: {}, + }, + }); + + const result = composeDeadlineManagedMcpPolicies(snapshot, current, ["mcp_bridge_alpha"]); + const restored = YAML.parse(result.yaml); + + expect(restored.network_policies.mcp_bridge_alpha).toEqual( + networkEntry(alpha.content, "alpha"), + ); + expect(restored.network_policies.mcp_bridge_beta).toEqual(networkEntry(beta.content, "beta")); + expect(result.omissions).toEqual([ + expect.objectContaining({ + key: "mcp_bridge_beta", + reason: expect.stringMatching(/absent from the saved ownership manifest/), + }), + ]); + }); + + it("deadline composition strips every reserved shape with an empty manifest", () => { + const snapshot = YAML.stringify({ + version: 1, + network_policies: { + mcp_bridge_: {}, + mcp_bridge_legacy_invalid_name: {}, + restrictive_baseline: {}, + }, + }); + + const result = composeDeadlineManagedMcpPolicies(snapshot, [], []); + + expect(YAML.parse(result.yaml).network_policies).toEqual({ restrictive_baseline: {} }); + expect(result.omissions.map((entry) => entry.key)).toEqual([ + "mcp_bridge_", + "mcp_bridge_legacy_invalid_name", + ]); + }); + + it("deadline composition omits malformed and duplicate manifest entries without delaying lockdown", () => { + const snapshot = YAML.stringify({ + version: 1, + network_policies: { + mcp_bridge_: {}, + mcp_bridge_alpha: {}, + restrictive_baseline: {}, + }, + }); + + const result = composeDeadlineManagedMcpPolicies( + snapshot, + [], + ["mcp_bridge_", "restrictive_baseline", "mcp_bridge_alpha", "mcp_bridge_alpha"], + ); + + expect(YAML.parse(result.yaml).network_policies).toEqual({ restrictive_baseline: {} }); + expect(result.omissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "mcp_bridge_", + reason: expect.stringMatching(/ownership key.*invalid/), + }), + expect.objectContaining({ + key: "restrictive_baseline", + reason: expect.stringMatching(/ownership key.*invalid/), + }), + expect.objectContaining({ + key: "mcp_bridge_alpha", + reason: expect.stringMatching(/more than once/), + }), + ]), + ); + }); + + it("deadline composition restores the restrictive baseline when a saved key is absent", () => { + const snapshot = YAML.stringify({ + version: 1, + network_policies: { + restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + }, + }); + + const result = composeDeadlineManagedMcpPolicies(snapshot, [], ["mcp_bridge_alpha"]); + const restored = YAML.parse(result.yaml); + + expect(restored.network_policies).toEqual({ + restrictive_baseline: { endpoints: [{ host: "baseline.example.com" }] }, + }); + expect(result.omissions).toEqual([ + expect.objectContaining({ reason: expect.stringMatching(/already absent/) }), + ]); + }); +}); diff --git a/src/lib/shields/mcp-policy-transition.ts b/src/lib/shields/mcp-policy-transition.ts new file mode 100644 index 00000000000..858536bb790 --- /dev/null +++ b/src/lib/shields/mcp-policy-transition.ts @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import YAML from "yaml"; + +import type { + ExactManagedMcpPolicy, + ManagedMcpPolicyOmission, +} from "../actions/sandbox/mcp-bridge-policy"; + +const CANONICAL_MANAGED_MCP_POLICY_KEY_RE = /^mcp_bridge_[a-z][a-z0-9_]{0,63}$/; +const RESERVED_MANAGED_MCP_POLICY_KEY_RE = /^mcp_bridge_/; + +function parsePolicyDocument(source: string, label: string): Record { + let parsed: unknown; + try { + parsed = YAML.parse(source); + } catch { + throw new Error(`${label} is not valid YAML`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${label} must be a YAML mapping`); + } + return parsed as Record; +} + +function readNetworkPolicies( + document: Record, + label: string, +): Record { + const policies = document.network_policies; + if (policies === undefined || policies === null) return {}; + if (typeof policies !== "object" || Array.isArray(policies)) { + throw new Error(`${label} network_policies must be a mapping`); + } + return policies as Record; +} + +/** + * Reconcile generated MCP entries into a complete target policy. + * + * Snapshot-time keys are removed first so an MCP server deleted during the + * shields-down window cannot be restored. The current exact entries are then overlaid, + * retaining additions and replacing stale pins. Every non-MCP target entry + * remains authoritative; unrelated live entries are never copied. + */ +export function composeManagedMcpPolicies( + targetPolicyYaml: string, + currentPolicies: readonly ExactManagedMcpPolicy[], + snapshotManagedPolicyKeys: readonly string[] = [], +): string { + const target = parsePolicyDocument(targetPolicyYaml, "Target Shields policy"); + const targetPolicies = readNetworkPolicies(target, "Target Shields policy"); + + const snapshotKeys = new Set(); + for (const key of snapshotManagedPolicyKeys) { + if (!CANONICAL_MANAGED_MCP_POLICY_KEY_RE.test(key) || snapshotKeys.has(key)) { + throw new Error("Saved Shields MCP policy ownership is invalid"); + } + if (!Object.hasOwn(targetPolicies, key)) { + throw new Error(`Saved Shields MCP policy '${key}' is absent from its policy snapshot`); + } + snapshotKeys.add(key); + delete targetPolicies[key]; + } + const unclassifiedKey = Object.keys(targetPolicies).find((key) => + RESERVED_MANAGED_MCP_POLICY_KEY_RE.test(key), + ); + if (unclassifiedKey) { + throw new Error( + `Reserved MCP policy key '${unclassifiedKey}' is absent from the saved ownership manifest`, + ); + } + + const currentKeys = new Set(); + for (const policy of currentPolicies) { + if (!CANONICAL_MANAGED_MCP_POLICY_KEY_RE.test(policy.key) || currentKeys.has(policy.key)) { + throw new Error(`Managed MCP policy key '${policy.key}' has ambiguous ownership`); + } + currentKeys.add(policy.key); + targetPolicies[policy.key] = policy.networkPolicy; + } + + target.network_policies = targetPolicies; + return YAML.stringify(target); +} + +export interface DeadlineManagedMcpPolicyComposition { + yaml: string; + omissions: ManagedMcpPolicyOmission[]; +} + +/** + * Security-authoritative deadline composition. + * + * Every reserved key is removed from the snapshot, including keys missing from + * an incomplete manifest. Only independently proven current entries are then + * overlaid. + */ +export function composeDeadlineManagedMcpPolicies( + targetPolicyYaml: string, + currentPolicies: readonly ExactManagedMcpPolicy[], + snapshotManagedPolicyKeys: readonly string[], +): DeadlineManagedMcpPolicyComposition { + const target = parsePolicyDocument(targetPolicyYaml, "Target Shields policy"); + const targetPolicies = readNetworkPolicies(target, "Target Shields policy"); + + const snapshotKeys = new Set(); + const omissions: ManagedMcpPolicyOmission[] = []; + for (const key of snapshotManagedPolicyKeys) { + if (!CANONICAL_MANAGED_MCP_POLICY_KEY_RE.test(key)) { + if (RESERVED_MANAGED_MCP_POLICY_KEY_RE.test(key)) { + delete targetPolicies[key]; + } + omissions.push({ + key, + reason: `Saved Shields MCP policy ownership key '${key}' is invalid`, + }); + continue; + } + if (snapshotKeys.has(key)) { + omissions.push({ + key, + reason: `Saved Shields MCP policy '${key}' appeared more than once in its ownership manifest`, + }); + continue; + } + if (!Object.hasOwn(targetPolicies, key)) { + omissions.push({ + reason: `Saved Shields MCP policy '${key}' was already absent from its policy snapshot`, + }); + } + snapshotKeys.add(key); + delete targetPolicies[key]; + } + for (const key of Object.keys(targetPolicies)) { + if (!RESERVED_MANAGED_MCP_POLICY_KEY_RE.test(key)) continue; + delete targetPolicies[key]; + omissions.push({ + key, + reason: `Reserved MCP policy key '${key}' was absent from the saved ownership manifest`, + }); + } + + const currentKeys = new Set(); + for (const policy of currentPolicies) { + if (!CANONICAL_MANAGED_MCP_POLICY_KEY_RE.test(policy.key) || currentKeys.has(policy.key)) { + throw new Error(`Managed MCP policy key '${policy.key}' has ambiguous ownership`); + } + currentKeys.add(policy.key); + targetPolicies[policy.key] = policy.networkPolicy; + } + + target.network_policies = targetPolicies; + return { yaml: YAML.stringify(target), omissions }; +} + +export function isManagedMcpPolicyKey(value: unknown): value is string { + return typeof value === "string" && RESERVED_MANAGED_MCP_POLICY_KEY_RE.test(value); +} + +/** + * Refuse to guess managed ownership for a Shields snapshot captured before the + * ownership manifest existed. Current claims prove reconciliation is needed; + * a managed-shaped snapshot key may be a removed bridge or an operator entry. + * Either case requires explicit recovery instead of a destructive raw apply. + */ +export function assertLegacyMcpPolicyRestoreSafe( + snapshotPolicyYaml: string, + hasCurrentManagedClaims: boolean, +): void { + const snapshot = parsePolicyDocument(snapshotPolicyYaml, "Legacy Shields policy snapshot"); + const snapshotPolicies = readNetworkPolicies(snapshot, "Legacy Shields policy snapshot"); + if ( + hasCurrentManagedClaims || + Object.keys(snapshotPolicies).some((key) => isManagedMcpPolicyKey(key)) + ) { + throw new Error( + "Legacy Shields state has no managed MCP ownership manifest; refusing policy restore", + ); + } +} diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index 46f523f60be..897ed4d52a9 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -4,8 +4,30 @@ import fs from "node:fs"; import YAML from "yaml"; +export { + type ExactManagedMcpPolicy, + hasManagedMcpPolicyClaims, + inspectExactManagedMcpPolicies, + inspectProvableManagedMcpPoliciesForDeadline, + type ManagedMcpPolicyOmission, +} from "../actions/sandbox/mcp-bridge-policy"; + +import type { + ExactManagedMcpPolicy, + ManagedMcpPolicyOmission, +} from "../actions/sandbox/mcp-bridge-policy"; import { cleanupTempDir, secureTempFile } from "../onboard/temp-files"; +export { + assertLegacyMcpPolicyRestoreSafe, + isManagedMcpPolicyKey, +} from "./mcp-policy-transition"; + +import { + composeDeadlineManagedMcpPolicies, + composeManagedMcpPolicies, +} from "./mcp-policy-transition"; + const TEMP_FILE_PREFIX = "nemoclaw-permissive-runtime"; /** @@ -60,6 +82,10 @@ 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; + // Exact, live-matching generated MCP policies resolved by the Shields + // coordinator. These entries remain active while the static policy replaces + // the rest of the complete gateway policy. + managedMcpPolicies?: readonly ExactManagedMcpPolicy[]; } export function buildRuntimePermissivePolicy( @@ -69,21 +95,31 @@ export function buildRuntimePermissivePolicy( const live = deps.livePolicyYaml ? safeYamlObject(deps.livePolicyYaml) : null; const liveRw = readStringList(live, "read_write"); const liveRo = readStringList(live, "read_only"); + const managedMcpPolicies = deps.managedMcpPolicies ?? []; // 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) { + // caller's apply path is unchanged unless exact managed MCP entries must + // survive the complete-policy replacement. + if (liveRw.length === 0 && liveRo.length === 0 && managedMcpPolicies.length === 0) { return basePermissivePath; } let baseYaml: string; try { baseYaml = deps.readBasePolicy(); - } catch { + } catch (error) { + if (managedMcpPolicies.length > 0) { + throw new Error("Cannot read the Shields-down policy while managed MCP policies are active", { + cause: error, + }); + } return basePermissivePath; } const base = safeYamlObject(baseYaml); if (!base) { + if (managedMcpPolicies.length > 0) { + throw new Error("Cannot parse the Shields-down policy while managed MCP policies are active"); + } return basePermissivePath; } const fsPolicy = @@ -108,11 +144,17 @@ export function buildRuntimePermissivePolicy( fsPolicy.read_write = [...baseRw]; fsPolicy.read_only = [...baseRo]; - const yaml = YAML.stringify(base); + const yaml = composeManagedMcpPolicies(YAML.stringify(base), managedMcpPolicies); if (deps.writeTempPolicy) { try { return deps.writeTempPolicy(yaml); - } catch { + } catch (error) { + if (managedMcpPolicies.length > 0) { + throw new Error( + "Cannot stage the Shields-down policy while managed MCP policies are active", + { cause: error }, + ); + } return basePermissivePath; } } @@ -121,15 +163,111 @@ export function buildRuntimePermissivePolicy( tmpPath = secureTempFile(TEMP_FILE_PREFIX, ".yaml"); fs.writeFileSync(tmpPath, yaml, { mode: 0o600 }); return tmpPath; - } catch { + } catch (error) { // secureTempFile may have created an mkdtemp directory before // 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); + if (managedMcpPolicies.length > 0) { + throw new Error( + "Cannot stage the Shields-down policy while managed MCP policies are active", + { cause: error }, + ); + } return basePermissivePath; } } +export interface ManagedMcpRuntimePolicyDeps { + managedMcpPolicies: readonly ExactManagedMcpPolicy[]; + readBasePolicy: () => string; + snapshotManagedPolicyKeys?: readonly string[]; + writeTempPolicy?: (yaml: string) => string; +} + +/** + * Reconcile current generated MCP policies into a custom Shields-down policy + * or a saved restrictive snapshot. Unlike the legacy filesystem-only fallback, + * this path must fail closed: returning the unmodified base could silently + * discard a managed entry or restore one that was removed during the + * shields-down window. + */ +export function buildRuntimeManagedMcpPolicy( + _basePolicyPath: string, + deps: ManagedMcpRuntimePolicyDeps, +): string { + const snapshotManagedPolicyKeys = deps.snapshotManagedPolicyKeys ?? []; + + let baseYaml: string; + try { + baseYaml = deps.readBasePolicy(); + } catch (error) { + throw new Error("Cannot read the Shields policy for managed MCP reconciliation", { + cause: error, + }); + } + const yaml = composeManagedMcpPolicies( + baseYaml, + deps.managedMcpPolicies, + snapshotManagedPolicyKeys, + ); + if (deps.writeTempPolicy) { + try { + return deps.writeTempPolicy(yaml); + } catch (error) { + throw new Error("Cannot stage the Shields policy for managed MCP reconciliation", { + cause: error, + }); + } + } + + let tmpPath: string | null = null; + try { + tmpPath = secureTempFile(TEMP_FILE_PREFIX, ".yaml"); + fs.writeFileSync(tmpPath, yaml, { mode: 0o600 }); + return tmpPath; + } catch (error) { + if (tmpPath) cleanupTempDir(tmpPath, TEMP_FILE_PREFIX); + throw new Error("Cannot stage the Shields policy for managed MCP reconciliation", { + cause: error, + }); + } +} + +export interface DeadlineManagedMcpRuntimePolicy { + path: string; + omissions: ManagedMcpPolicyOmission[]; +} + +export function buildDeadlineRuntimeManagedMcpPolicy( + _basePolicyPath: string, + deps: ManagedMcpRuntimePolicyDeps, +): DeadlineManagedMcpRuntimePolicy { + const baseYaml = deps.readBasePolicy(); + const composition = composeDeadlineManagedMcpPolicies( + baseYaml, + deps.managedMcpPolicies, + deps.snapshotManagedPolicyKeys ?? [], + ); + let runtimePath: string | null = null; + try { + runtimePath = deps.writeTempPolicy + ? deps.writeTempPolicy(composition.yaml) + : secureTempFile(TEMP_FILE_PREFIX, ".yaml"); + if (!deps.writeTempPolicy) { + fs.writeFileSync(runtimePath, composition.yaml, { mode: 0o600 }); + } + return { path: runtimePath, omissions: composition.omissions }; + } catch (error) { + if (runtimePath && !deps.writeTempPolicy) { + cleanupTempDir(runtimePath, TEMP_FILE_PREFIX); + } + throw new Error("Cannot stage the deadline Shields policy for managed MCP reconciliation", { + cause: error, + }); + } +} + function safeYamlObject(text: string): Record | null { try { const parsed = YAML.parse(text); diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index 50df05618fb..43ea45c4601 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -7,6 +7,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 requireSource = createRequire(import.meta.url); const SHIELDS_MODULE = "./index.js"; @@ -285,3 +286,209 @@ describe("shields config lock without a shipped config hash", () => { expect(entries.get(CONFIG_DIR)).toEqual({ mode: "2770", owner: "sandbox:sandbox" }); }); }); + +describe("managed MCP policy deadline restoration (#7952)", () => { + let homeDir: string; + + function createRestoreHarness() { + delete require.cache[requireSource.resolve(SHIELDS_MODULE)]; + delete require.cache[requireSource.resolve("./permissive-runtime.js")]; + delete require.cache[requireSource.resolve("../actions/sandbox/mcp-bridge-policy.js")]; + + const runner = requireSource("../runner.js") as typeof import("../runner.js"); + const policy = requireSource("../policy/index.js") as typeof import("../policy/index.js"); + const registry = requireSource("../state/registry.js") as typeof import("../state/registry.js"); + const policySetBodies: string[] = []; + + vi.spyOn(runner, "runCapture").mockReturnValue( + "version: 1\nnetwork_policies:\n live_baseline: {}\n", + ); + vi.spyOn(runner, "run").mockReturnValue({ status: 0 } as never); + vi.spyOn(policy, "buildPolicyGetCommand").mockReturnValue(["openshell", "policy", "get"]); + vi.spyOn(policy, "buildPolicySetCommand").mockImplementation((file: unknown) => { + policySetBodies.push(fs.readFileSync(String(file), "utf-8")); + return ["openshell", "policy", "set"]; + }); + vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "openclaw", + openshellDriver: "docker", + }); + + const shields = requireSource(SHIELDS_MODULE) as typeof import("./index.js"); + return { applyShieldsPolicySnapshot: shields.applyShieldsPolicySnapshot, policySetBodies }; + } + + function writeCurrentProcessTimerMarker(snapshotPath: string, processToken: string): void { + fs.writeFileSync( + path.join(homeDir, ".nemoclaw", "state", "shields-timer-openclaw.json"), + JSON.stringify({ + pid: process.pid, + sandboxName: "openclaw", + snapshotPath, + restoreAt: new Date(Date.now() + 60_000).toISOString(), + processToken, + }), + { mode: 0o600 }, + ); + } + + beforeEach(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "shields-mcp-deadline-flow-")); + vi.stubEnv("HOME", homeDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(homeDir, { recursive: true, force: true }); + delete require.cache[requireSource.resolve(SHIELDS_MODULE)]; + delete require.cache[requireSource.resolve("./permissive-runtime.js")]; + delete require.cache[requireSource.resolve("../actions/sandbox/mcp-bridge-policy.js")]; + }); + + it("restores lockdown with malformed and duplicate ownership", () => { + const stateDir = path.join(homeDir, ".nemoclaw", "state"); + const processToken = "a".repeat(32); + const snapshotPath = path.join(stateDir, "policy-snapshot-malformed-deadline.yaml"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + snapshotPath, + YAML.stringify({ + version: 1, + network_policies: { + restrictive_baseline: {}, + mcp_bridge_: {}, + mcp_bridge_alpha: {}, + }, + }), + ); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify({ + shieldsDown: true, + shieldsPolicySnapshotPath: snapshotPath, + shieldsManagedMcpPolicyKeys: ["mcp_bridge_", "mcp_bridge_alpha", "mcp_bridge_alpha"], + }), + ); + writeCurrentProcessTimerMarker(snapshotPath, processToken); + const harness = createRestoreHarness(); + + const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { + transitionProcessToken: processToken, + deadlineAuthoritative: true, + }); + + expect(result.status).toBe(0); + expect(result.managedMcpOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "mcp_bridge_", + reason: expect.stringMatching(/ownership key.*invalid/), + }), + expect.objectContaining({ + key: "mcp_bridge_alpha", + reason: expect.stringMatching(/more than once/), + }), + ]), + ); + expect(YAML.parse(harness.policySetBodies.at(-1)!).network_policies).toEqual({ + restrictive_baseline: {}, + }); + }); + + it("restores lockdown when transition and persisted ownership differ", () => { + const stateDir = path.join(homeDir, ".nemoclaw", "state"); + const processToken = "b".repeat(32); + const snapshotPath = path.join(stateDir, "policy-snapshot-mismatched-deadline.yaml"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + snapshotPath, + YAML.stringify({ + version: 1, + network_policies: { + restrictive_baseline: {}, + mcp_bridge_alpha: {}, + mcp_bridge_beta: {}, + }, + }), + ); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify({ + shieldsDown: true, + shieldsPolicySnapshotPath: snapshotPath, + shieldsManagedMcpPolicyKeys: ["mcp_bridge_alpha"], + }), + ); + fs.writeFileSync( + path.join(stateDir, `shields-transition-openclaw-${processToken}.json`), + JSON.stringify({ + version: 1, + phase: "active", + ownerPid: process.pid, + ownerStartIdentity: "test-owner", + processToken, + sandboxName: "openclaw", + snapshotPath, + managedMcpPolicyKeys: ["mcp_bridge_beta"], + }), + { mode: 0o600 }, + ); + writeCurrentProcessTimerMarker(snapshotPath, processToken); + const harness = createRestoreHarness(); + + const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { + transitionProcessToken: processToken, + deadlineAuthoritative: true, + }); + + expect(result.status).toBe(0); + expect(result.managedMcpOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + reason: expect.stringMatching(/did not match persisted policy ownership/), + }), + ]), + ); + expect(YAML.parse(harness.policySetBodies.at(-1)!).network_policies).toEqual({ + restrictive_baseline: {}, + }); + }); + + it("restores lockdown from a legacy snapshot without ownership metadata", () => { + const stateDir = path.join(homeDir, ".nemoclaw", "state"); + const processToken = "c".repeat(32); + const snapshotPath = path.join(stateDir, "policy-snapshot-legacy-deadline.yaml"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + snapshotPath, + YAML.stringify({ + version: 1, + network_policies: { restrictive_baseline: {}, mcp_bridge_alpha: {} }, + }), + ); + fs.writeFileSync( + path.join(stateDir, "shields-openclaw.json"), + JSON.stringify({ shieldsDown: true, shieldsPolicySnapshotPath: snapshotPath }), + ); + writeCurrentProcessTimerMarker(snapshotPath, processToken); + const harness = createRestoreHarness(); + + const result = harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { + transitionProcessToken: processToken, + deadlineAuthoritative: true, + }); + + expect(result.status).toBe(0); + expect(result.managedMcpOmissions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ reason: expect.stringMatching(/no managed MCP ownership/) }), + expect.objectContaining({ key: "mcp_bridge_alpha" }), + ]), + ); + expect(YAML.parse(harness.policySetBodies.at(-1)!).network_policies).toEqual({ + restrictive_baseline: {}, + }); + }); +}); diff --git a/src/lib/shields/timer.test.ts b/src/lib/shields/timer.test.ts index 251dca46f78..82e91bf2ca8 100644 --- a/src/lib/shields/timer.test.ts +++ b/src/lib/shields/timer.test.ts @@ -9,6 +9,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getMcpLifecycleLockPath, withMcpLifecycleLock } from "../state/mcp-lifecycle-lock"; const shieldsIndexMock = vi.hoisted(() => ({ + applyShieldsPolicySnapshot: vi.fn( + (): { + status: number; + managedMcpOmissions?: Array<{ server: string; reason: string }>; + } => ({ status: 0 }), + ), completeAutoRestoreTransition: vi.fn(() => true), lockAgentConfig: vi.fn() as unknown, prepareAutoRestoreTransitionTakeover: vi.fn(), @@ -16,25 +22,6 @@ const shieldsIndexMock = vi.hoisted(() => ({ const PROCESS_TOKEN = "a".repeat(32); -const runMock = vi.fn(() => ({ status: 0 })); - -vi.mock("../runner", async (importOriginal) => ({ - ...(await importOriginal()), - run: runMock, -})); - -vi.mock("../policy", () => ({ - buildPolicySetCommand: vi.fn((file: string, name: string) => [ - "openshell", - "policy", - "set", - "--policy", - file, - "--wait", - name, - ]), -})); - vi.mock("../sandbox/agent-config", () => ({ DEFAULT_AGENT_CONFIG: Symbol("DEFAULT_AGENT_CONFIG"), resolveAgentConfig: vi.fn(() => ({ @@ -44,6 +31,7 @@ vi.mock("../sandbox/agent-config", () => ({ })); vi.mock("./index", () => ({ + applyShieldsPolicySnapshot: shieldsIndexMock.applyShieldsPolicySnapshot, completeAutoRestoreTransition: shieldsIndexMock.completeAutoRestoreTransition, get lockAgentConfig() { return shieldsIndexMock.lockAgentConfig; @@ -57,8 +45,8 @@ describe("shields timer authorization", () => { beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "shields-timer-")); vi.stubEnv("HOME", tmpHome); + shieldsIndexMock.applyShieldsPolicySnapshot.mockImplementation(() => ({ status: 0 })); shieldsIndexMock.lockAgentConfig = vi.fn(); - runMock.mockImplementation(() => ({ status: 0 })); vi.resetModules(); vi.clearAllMocks(); }); @@ -121,10 +109,13 @@ describe("shields timer authorization", () => { await waitForRetryBoundary(deadlinePath, auditPath); expect(exitSpy).not.toHaveBeenCalled(); expect(fs.existsSync(deadlinePath)).toBe(true); - const policyApplicationsBeforeRevocation = runMock.mock.calls.length; + const policyApplicationsBeforeRevocation = + shieldsIndexMock.applyShieldsPolicySnapshot.mock.calls.length; fs.rmSync(markerPath, { force: true }); await pending; - expect(runMock).toHaveBeenCalledTimes(policyApplicationsBeforeRevocation); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).toHaveBeenCalledTimes( + policyApplicationsBeforeRevocation, + ); } finally { fs.writeFileSync(markerPath, markerContents); exitSpy.mockRestore(); @@ -151,7 +142,7 @@ describe("shields timer authorization", () => { const exitCode = await invokeTimerAndCaptureExit(timer.runRestoreTimer, args); expect(exitCode).toBe(0); - expect(runMock).not.toHaveBeenCalled(); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).not.toHaveBeenCalled(); expect(JSON.parse(fs.readFileSync(stateFile, "utf-8"))).toEqual(initialState); }); @@ -193,7 +184,7 @@ describe("shields timer authorization", () => { const exitCode = await invokeTimerAndCaptureExit(timer.runRestoreTimer, args); expect(exitCode).toBe(0); - expect(runMock).not.toHaveBeenCalled(); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).not.toHaveBeenCalled(); expect(JSON.parse(fs.readFileSync(stateFile, "utf-8"))).toEqual(initialState); expect(fs.existsSync(markerPath)).toBe(true); }); @@ -237,7 +228,7 @@ describe("shields timer authorization", () => { const exitCode = await invokeTimerAndCaptureExit(timer.runRestoreTimer, args); expect(exitCode).toBe(0); - expect(runMock).not.toHaveBeenCalled(); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).not.toHaveBeenCalled(); expect(JSON.parse(fs.readFileSync(stateFile, "utf-8"))).toEqual(initialState); expect(fs.lstatSync(markerPath).isSymbolicLink()).toBe(true); expect(fs.readFileSync(markerTargetPath, "utf-8")).toBe(markerTarget); @@ -336,7 +327,7 @@ describe("shields timer authorization", () => { await timer.runRestoreTimer(args!); - expect(runMock).not.toHaveBeenCalled(); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); expect(vi.getTimerCount()).toBe(1); expect(fs.existsSync(markerPath)).toBe(true); @@ -346,7 +337,7 @@ describe("shields timer authorization", () => { } }); - it("audits a successful restore retry while retaining deadline ownership", async () => { + it("audits a successful restore retry without stale MCP warnings or timestamps", async () => { const timer = await import("./timer"); const stateDir = path.join(tmpHome, ".nemoclaw", "state"); fs.mkdirSync(stateDir, { recursive: true }); @@ -369,10 +360,17 @@ describe("shields timer authorization", () => { leaseOwnerStartIdentity: "proc:dead-owner", }), ); - runMock.mockImplementationOnce(() => { + shieldsIndexMock.applyShieldsPolicySnapshot.mockImplementationOnce(() => { expect(fs.existsSync(sandboxMutationLockPath)).toBe(true); expect(fs.existsSync(deadlinePath)).toBe(true); - return { status: 17 }; + return { + status: 17, + managedMcpOmissions: [{ server: "beta", reason: "incomplete add" }], + }; + }); + shieldsIndexMock.applyShieldsPolicySnapshot.mockReturnValueOnce({ + status: 0, + managedMcpOmissions: [], }); const args = timer.parseTimerArgs([ sandboxName, @@ -390,7 +388,7 @@ describe("shields timer authorization", () => { const exitCode = await invokeTimerAndCaptureExit(timer.runRestoreTimer, args); expect(exitCode).toBe(0); - expect(runMock).toHaveBeenCalledTimes(2); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).toHaveBeenCalledTimes(2); expect(shieldsIndexMock.completeAutoRestoreTransition).toHaveBeenCalledWith( sandboxName, PROCESS_TOKEN, @@ -404,14 +402,22 @@ describe("shields timer authorization", () => { .split("\n") .filter(Boolean) .map((line) => JSON.parse(line)); - expect(audits).toContainEqual( + const successAudits = audits.filter((audit) => audit.action === "shields_auto_restore"); + expect(successAudits).toEqual([ expect.objectContaining({ - action: "shields_up_failed", - error: "Policy restore exited with status 17", + action: "shields_auto_restore", + sandbox: sandboxName, }), + ]); + expect(successAudits[0]).not.toHaveProperty("warning"); + const failedAudit = audits.find( + (audit) => + audit.action === "shields_up_failed" && + audit.error === "Policy restore exited with status 17", ); - expect(audits).toContainEqual( - expect.objectContaining({ action: "shields_auto_restore", sandbox: sandboxName }), + expect(failedAudit).toEqual(expect.objectContaining({ timestamp: expect.any(String) })); + expect(Date.parse(successAudits[0].timestamp)).toBeGreaterThan( + Date.parse(failedAudit.timestamp), ); }); @@ -453,7 +459,7 @@ describe("shields timer authorization", () => { const exitCode = await invokeTimerAndCaptureExit(timer.runRestoreTimer, args); expect(exitCode).toBe(0); - expect(runMock).not.toHaveBeenCalled(); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).not.toHaveBeenCalled(); expect(JSON.parse(fs.readFileSync(stateFile, "utf-8"))).toEqual(initialState); expect(fs.existsSync(markerPath)).toBe(true); }); @@ -550,7 +556,7 @@ describe("shields timer authorization", () => { const exitCode = await invokeTimerAndCaptureExit(timer.runRestoreTimer, args); expect(exitCode).toBe(1); - expect(runMock).not.toHaveBeenCalled(); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).not.toHaveBeenCalled(); expect(shieldsIndexMock.completeAutoRestoreTransition).not.toHaveBeenCalled(); expect(fs.existsSync(mutationLockPath)).toBe(false); expect(fs.existsSync(deadlinePath)).toBe(false); @@ -592,7 +598,7 @@ describe("shields timer authorization", () => { const lockPath = path.join(stateDir, `shields-transition-lock-${sandboxName}.json`); const deadlinePath = `${sandboxMutationLockPath}.deadline`; - runMock.mockImplementationOnce(() => { + shieldsIndexMock.applyShieldsPolicySnapshot.mockImplementationOnce(() => { expect(fs.existsSync(sandboxMutationLockPath)).toBe(true); expect(fs.existsSync(deadlinePath)).toBe(true); expect(JSON.parse(fs.readFileSync(lockPath, "utf-8"))).toMatchObject({ @@ -600,7 +606,10 @@ describe("shields timer authorization", () => { command: "shields auto-restore", takeoverToken: PROCESS_TOKEN, }); - return { status: 0 }; + return { + status: 0, + managedMcpOmissions: [{ server: "beta", reason: "incomplete add" }], + }; }); const exitCode = await invokeTimerAndCaptureExit(timer.runRestoreTimer, args); @@ -608,7 +617,7 @@ describe("shields timer authorization", () => { const updatedState = JSON.parse(fs.readFileSync(stateFile, "utf-8")); expect(exitCode).toBe(0); - expect(runMock).toHaveBeenCalledTimes(1); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).toHaveBeenCalledTimes(1); expect(updatedState.shieldsDown).toBe(false); expect(updatedState.shieldsDownAt).toBeNull(); expect(fs.existsSync(markerPath)).toBe(false); @@ -619,6 +628,18 @@ describe("shields timer authorization", () => { PROCESS_TOKEN, snapshotPath, ); + expect( + fs + .readFileSync(path.join(stateDir, "shields-audit.jsonl"), "utf-8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)), + ).toContainEqual( + expect.objectContaining({ + action: "shields_auto_restore", + warning: "Auto-restore omitted 1 unproven managed MCP policy entries", + }), + ); }); it("keeps the deadline gate closed while a failed restore retries", async () => { @@ -641,7 +662,9 @@ describe("shields timer authorization", () => { processToken: PROCESS_TOKEN, }), ); - runMock.mockReturnValueOnce({ status: 1 }).mockReturnValue({ status: 0 }); + shieldsIndexMock.applyShieldsPolicySnapshot + .mockReturnValueOnce({ status: 1 }) + .mockReturnValue({ status: 0 }); const args = timer.parseTimerArgs([ sandboxName, snapshotPath, @@ -658,10 +681,13 @@ describe("shields timer authorization", () => { try { const restore = timer.runRestoreTimer(args!, { retryDelayMs: 100 }); - await vi.waitFor(() => expect(runMock).toHaveBeenCalledTimes(1), { - interval: 1, - timeout: 200, - }); + await vi.waitFor( + () => expect(shieldsIndexMock.applyShieldsPolicySnapshot).toHaveBeenCalledTimes(1), + { + interval: 1, + timeout: 200, + }, + ); expect(fs.existsSync(`${mutationLockPath}.deadline`)).toBe(true); const contender = withMcpLifecycleLock( @@ -676,7 +702,7 @@ describe("shields timer authorization", () => { expect(fs.existsSync(`${mutationLockPath}.deadline`)).toBe(true); await Promise.all([restore, contender]); - expect(runMock).toHaveBeenCalledTimes(2); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).toHaveBeenCalledTimes(2); expect(contenderEntered).toBe(true); expect(shieldsIndexMock.completeAutoRestoreTransition).toHaveBeenCalledWith( sandboxName, @@ -800,7 +826,12 @@ describe("shields timer authorization", () => { const updatedState = JSON.parse(fs.readFileSync(stateFile, "utf-8")); expect(exitCode).toBe(0); - expect(runMock).toHaveBeenCalledTimes(1); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).toHaveBeenCalledTimes(1); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).toHaveBeenCalledWith( + sandboxName, + snapshotPath, + { deadlineAuthoritative: true, transitionProcessToken: PROCESS_TOKEN }, + ); // #4663: relockAndReconfirm applies then re-confirms after the settle // window (0ms under test), so lockAgentConfig is invoked twice for a clean // lock. @@ -866,7 +897,7 @@ describe("shields timer authorization", () => { .split("\n") .map((line) => JSON.parse(line)); - expect(runMock).toHaveBeenCalledTimes(1); + expect(shieldsIndexMock.applyShieldsPolicySnapshot).toHaveBeenCalledTimes(1); expect(updatedState.shieldsDown).toBe(true); expect(auditEntries).toContainEqual( expect.objectContaining({ diff --git a/src/lib/shields/timer.ts b/src/lib/shields/timer.ts index 2737d62f17f..6226b5244b4 100644 --- a/src/lib/shields/timer.ts +++ b/src/lib/shields/timer.ts @@ -11,8 +11,6 @@ import fs from "node:fs"; import path from "node:path"; import { isObjectRecord, type UnknownRecord } from "../core/json-types"; -import { buildPolicySetCommand } from "../policy"; -import { run } from "../runner"; import { resolveAgentConfig } from "../sandbox/agent-config"; import { withMcpLifecycleDeadlineFence } from "../state/mcp-lifecycle-lock"; import { @@ -241,6 +239,7 @@ async function runRestoreTimer( : AUTO_RESTORE_RETRY_MS; let exitCode = 0; let retryScheduled = false; + let managedMcpWarning: string | undefined; const scheduleRetry = (): boolean => { if (!markerMatchesCurrentTimer(args)) return false; retryScheduled = true; @@ -299,10 +298,16 @@ async function runRestoreTimer( } // Restore policy (slow — openshell policy set --wait blocks) - const result = run(buildPolicySetCommand(args.snapshotPath, args.sandboxName), { - ignoreError: true, + const result = shields.applyShieldsPolicySnapshot(args.sandboxName, args.snapshotPath, { + transitionProcessToken: args.processToken, + deadlineAuthoritative: true, }); const status = typeof result.status === "number" ? result.status : 1; + managedMcpWarning = result.managedMcpOmissions?.length + ? `Auto-restore omitted ${String( + result.managedMcpOmissions.length, + )} unproven managed MCP policy entries` + : undefined; if (status !== 0) { appendAudit({ @@ -448,6 +453,7 @@ async function runRestoreTimer( restored_by: "auto_timer", policy_snapshot: args.snapshotPath, scheduled_restore_at: args.restoreAtIso, + ...(managedMcpWarning ? { warning: managedMcpWarning } : {}), }); cleanupOwnedTimerMarker(args); exitCode = 0; diff --git a/test/e2e/live/mcp-bridge-hermes-lifecycle.ts b/test/e2e/live/mcp-bridge-hermes-lifecycle.ts index 0f9b7a06881..5044cc5e31a 100644 --- a/test/e2e/live/mcp-bridge-hermes-lifecycle.ts +++ b/test/e2e/live/mcp-bridge-hermes-lifecycle.ts @@ -187,7 +187,6 @@ export async function assertHermesManagedAddSurvivesLockedGatewayRestartAndState }, ); expectExitZero(shieldsDown, "unlock Hermes config for remaining managed MCP lifecycle"); - await assertHermesReloadRollback(sandbox, sandboxName, mcpUrl); } /** diff --git a/test/e2e/live/mcp-bridge-sandbox.ts b/test/e2e/live/mcp-bridge-sandbox.ts index 5d9f5b8927e..aa801ef4c28 100644 --- a/test/e2e/live/mcp-bridge-sandbox.ts +++ b/test/e2e/live/mcp-bridge-sandbox.ts @@ -1,15 +1,91 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import YAML from "yaml"; import { shellQuote } from "../../../src/lib/core/shell-quote"; +import { parseOpenShellPolicy } from "../../../src/lib/policy/merge"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import { assertExitZero, resultText } from "../fixtures/clients/command.ts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; +import type { SandboxClient } from "../fixtures/clients/sandbox.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; const MCP_CURL_HTTP_CODE_MARKER = "NEMOCLAW_MCP_CURL_HTTP_CODE="; export type McpDnsRebindingAdapter = "mcporter" | "hermes-config" | "deepagents-config"; +export type CapturedManagedMcpPolicy = { + networkPolicies: Record; + policy: McpNetworkPolicy; +}; + +type McpNetworkPolicy = { + endpoints?: Array<{ + host?: string; + allowed_ips?: string[]; + [key: string]: unknown; + }>; + [key: string]: unknown; +}; + +export async function captureManagedMcpPolicy( + sandbox: SandboxClient, + options: { + artifactName: string; + label: string; + policyKey: string; + sandboxName: string; + url: string; + }, +): Promise { + const result = await sandbox.openshell(["policy", "get", "--full", options.sandboxName], { + artifactName: options.artifactName, + env: buildAvailabilityProbeEnv(), + timeoutMs: 60_000, + }); + assertExitZero(result, options.label); + const document = YAML.parse(parseOpenShellPolicy(resultText(result)).yamlBody) as { + network_policies?: Record; + }; + const networkPolicies = document.network_policies ?? {}; + const policy = networkPolicies[options.policyKey]; + if (!policy) { + throw new Error(`${options.label}: managed MCP policy '${options.policyKey}' is absent`); + } + const endpoint = policy.endpoints?.[0]; + const expectedHost = new URL(options.url).hostname; + if (endpoint?.host !== expectedHost) { + throw new Error(`${options.label}: expected managed MCP host '${expectedHost}'`); + } + if ( + !Array.isArray(endpoint.allowed_ips) || + endpoint.allowed_ips.length === 0 || + endpoint.allowed_ips.some((address) => typeof address !== "string") + ) { + throw new Error(`${options.label}: expected at least one managed MCP address pin`); + } + return { networkPolicies, policy }; +} + +export function assertManagedMcpPolicySurvivedRemoval( + before: McpNetworkPolicy, + after: CapturedManagedMcpPolicy, + removedPolicyKey: string, +): void { + assert.deepStrictEqual(after.policy, before); + assert.equal(after.networkPolicies[removedPolicyKey], undefined); +} + +export function expectExitNonZero(result: ShellProbeResult, label: string, pattern: RegExp): void { + assert.notEqual( + result.exitCode, + 0, + `${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + assert.match(resultText(result), pattern); +} + export async function hostAddressForSandbox(_host: HostCliClient): Promise { return "host.openshell.internal"; } diff --git a/test/e2e/live/mcp-bridge.test.ts b/test/e2e/live/mcp-bridge.test.ts index 3cf1043cdea..84789a9161e 100644 --- a/test/e2e/live/mcp-bridge.test.ts +++ b/test/e2e/live/mcp-bridge.test.ts @@ -4,14 +4,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import YAML from "yaml"; import { buildDeepAgentsMcpStatusCommand, buildHermesMcpStatusCommand, buildOpenClawMcporterInspectCommand, } from "../../../src/lib/actions/sandbox/mcp-bridge-adapter-status"; import { shellQuote } from "../../../src/lib/core/shell-quote"; -import { parseOpenShellPolicy } from "../../../src/lib/policy/merge"; import type { McpBridgeEntry } from "../../../src/lib/state/registry"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import type { CleanupRegistry } from "../fixtures/cleanup.ts"; @@ -27,13 +25,17 @@ import { assertHermesConfig, assertHermesInspectionRejectsUnmanagedFields, assertHermesManagedAddSurvivesLockedGatewayRestartAndStateLayout, + assertHermesReloadRollback, assertHermesRemovalSurvivesGatewayRestart, } from "./mcp-bridge-hermes-lifecycle.ts"; import { buildMcpBridgeExactMainEnv, buildMcpBridgeOnboardEnv } from "./mcp-bridge-onboard-env.ts"; import { MCP_BRIDGE_PHASES } from "./mcp-bridge-phases.ts"; import { retryAfterHermesRestartTransportFailure } from "./mcp-bridge-reliability.ts"; import { + assertManagedMcpPolicySurvivedRemoval, buildMcpDnsRebindingProbeScript, + captureManagedMcpPolicy, + expectExitNonZero, hostAddressForSandbox, hostPrivateAddressForSandbox, isExpectedMcpCurlPolicyDenial, @@ -75,12 +77,10 @@ const COMPATIBLE_MODEL = "mock/mcp-bridge"; const TOOL_CHALLENGE = "nemoclaw-authenticated-mcp-proof"; const REGISTRY_FILE = path.join(process.env.HOME ?? os.homedir(), ".nemoclaw", "sandboxes.json"); const selectedMcpBridgeShard = resolveMcpBridgeShard(); - function mcpBridgeShardTest(shard: McpBridgeShard) { return selectedMcpBridgeShard === shard ? e2eTest : e2eTest.skip; } const test = mcpBridgeShardTest("openclaw"); - type McpAgent = "openclaw" | "hermes" | "langchain-deepagents-code"; type McpAdapter = "mcporter" | "hermes-config" | "deepagents-config"; const MCP_MUTATION_TIMEOUT_MS: Record = { @@ -90,19 +90,6 @@ const MCP_MUTATION_TIMEOUT_MS: Record = { }; const MCP_BRIDGE_ALREADY_ABSENT = /No MCP servers are registered|No MCP server '.+' is registered|MCP server '.+' not found/iu; - -function expectExitNonZero(result: ShellProbeResult, label: string, pattern: RegExp): void { - expect( - result.exitCode, - `${label}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ).not.toBe(0); - expect(resultText(result)).toMatch(pattern); -} - -function parseCurrentPolicy(raw: string): string { - return parseOpenShellPolicy(raw).yamlBody; -} - async function cleanupMcpBridge( host: HostCliClient, sandboxName: string, @@ -120,7 +107,6 @@ async function cleanupMcpBridge( `cleanup MCP bridge ${server} on sandbox ${sandboxName}`, ); } - async function onboardAgent( host: HostCliClient, cleanup: CleanupRegistry, @@ -158,7 +144,6 @@ async function onboardAgent( ); expectExitZero(result, `onboard ${options.agent} sandbox for MCP bridge`); } - async function assertSecretAbsentFromSandbox( sandbox: SandboxClient, sandboxName: string, @@ -189,6 +174,7 @@ async function assertAdapterDnsRebindingDenied( artifactPrefix: string; sandboxName: string; secretPaths: string[]; + survivingMcpUrl: string; }, ): Promise { const rebindMcp = await startFakeMcpHttpsServer({ secret: REBIND_HOST_SECRET }); @@ -207,6 +193,14 @@ async function assertAdapterDnsRebindingDenied( cleanup.add(`restore ${options.artifactPrefix} DNS rebinding hosts fixture`, () => restoreDnsRebindingHostsFixture(host, options.sandboxName, hostsFixture), ); + const survivingPolicyBeforeAddResult = await captureManagedMcpPolicy(sandbox, { + artifactName: `${options.artifactPrefix}-mcp-dns-rebinding-surviving-policy-before-add`, + label: `${options.artifactPrefix} captures the surviving MCP policy before adding the rebinding route`, + policyKey: SERVER_POLICY_KEY, + sandboxName: options.sandboxName, + url: options.survivingMcpUrl, + }); + const survivingPolicyBeforeAdd = survivingPolicyBeforeAddResult.policy; await remapDnsRebindingHostname( host, options.sandboxName, @@ -261,19 +255,14 @@ async function assertAdapterDnsRebindingDenied( policy: { gatewayPresent: true }, adapter: { registered: true }, }); - const policy = await sandbox.openshell(["policy", "get", "--full", options.sandboxName], { + const rebindingPolicy = await captureManagedMcpPolicy(sandbox, { artifactName: `${options.artifactPrefix}-mcp-dns-rebinding-policy-pinned-public-ip`, - env: buildAvailabilityProbeEnv(), - timeoutMs: 60_000, + label: `${options.artifactPrefix} validates the add-time DNS pin`, + policyKey: REBIND_POLICY_KEY, + sandboxName: options.sandboxName, + url: rebindMcpUrl, }); - expectExitZero(policy, `${options.artifactPrefix} inspects add-time DNS pin`); - const policyJson = YAML.parse(parseCurrentPolicy(resultText(policy))) as { - network_policies?: Record< - string, - { endpoints?: Array<{ host?: string; allowed_ips?: string[] }> } - >; - }; - expect(policyJson.network_policies?.[REBIND_POLICY_KEY]?.endpoints?.[0]).toMatchObject({ + expect(rebindingPolicy.policy.endpoints?.[0]).toMatchObject({ host: REBIND_HOSTNAME, allowed_ips: [REBIND_PUBLIC_IP], }); @@ -322,6 +311,18 @@ async function assertAdapterDnsRebindingDenied( timeoutMs: MCP_MUTATION_TIMEOUT_MS[options.adapter], }); expectExitZero(remove, `${options.artifactPrefix} removes DNS rebinding route after proof`); + const survivingPolicyAfterRemoveResult = await captureManagedMcpPolicy(sandbox, { + artifactName: `${options.artifactPrefix}-mcp-dns-rebinding-surviving-policy-after-remove`, + label: `${options.artifactPrefix} inspects MCP policy after removing the rebinding route`, + policyKey: SERVER_POLICY_KEY, + sandboxName: options.sandboxName, + url: options.survivingMcpUrl, + }); + assertManagedMcpPolicySurvivedRemoval( + survivingPolicyBeforeAdd, + survivingPolicyAfterRemoveResult, + REBIND_POLICY_KEY, + ); } async function addBridgeAndReadStatus( host: HostCliClient, @@ -354,7 +355,6 @@ async function addBridgeAndReadStatus( }, ); expectExitZero(add, `${options.artifactPrefix} mcp add fake server`); - const status = await host.nemoclaw( [options.sandboxName, "mcp", "status", SERVER_NAME, "--json"], { @@ -981,6 +981,7 @@ test("mcp-bridge", { artifactPrefix: "openclaw", sandboxName: OPENCLAW_SANDBOX_NAME, secretPaths: ["/sandbox/.openclaw", "/sandbox/.mcp.json"], + survivingMcpUrl: mcpUrl, }); const requestCountBeforeAllowedNodeProof = fakeMcp.requests.length; @@ -1171,6 +1172,13 @@ mcpBridgeShardTest("hermes")( challenge: TOOL_CHALLENGE, resultToken: hermesResult, }); + const assertHermesToolCall = (artifactName: string) => + assertRealAdapterToolCall(sandbox, fakeMcp, { + agent: "hermes", + sandboxName: HERMES_SANDBOX_NAME, + resultToken: hermesResult, + artifactName, + }); cleanup.add("stop fake Hermes MCP HTTPS server", () => fakeMcp.close()); const fakeMcpTunnel = await startPublicMcpHttpsTunnel({ cleanup, @@ -1190,7 +1198,6 @@ mcpBridgeShardTest("hermes")( cleanup.add("remove Hermes MCP bridge", () => cleanupMcpBridge(host, HERMES_SANDBOX_NAME, SERVER_NAME, "hermes-config"), ); - progress.phase("configure and inspect the Hermes MCP bridge"); await assertConcurrentAddSerialized(host, cleanup, { sandboxName: HERMES_SANDBOX_NAME, @@ -1198,7 +1205,6 @@ mcpBridgeShardTest("hermes")( expectedAdapter: "hermes-config", artifactPrefix: "hermes", }); - const initialDiscoveryOffset = fakeMcp.requests.length; const providerName = await addBridgeAndReadStatus(host, { sandboxName: HERMES_SANDBOX_NAME, @@ -1233,6 +1239,8 @@ mcpBridgeShardTest("hermes")( HERMES_SANDBOX_NAME, mcpUrl, ); + await assertHermesToolCall("hermes-real-mcp-tool-call-immediately-after-shields-down"); + await assertHermesReloadRollback(sandbox, HERMES_SANDBOX_NAME, mcpUrl); await assertSecretAbsentFromSandbox( sandbox, HERMES_SANDBOX_NAME, @@ -1251,15 +1259,12 @@ mcpBridgeShardTest("hermes")( artifactPrefix: "hermes", sandboxName: HERMES_SANDBOX_NAME, secretPaths: ["/sandbox/.hermes"], + survivingMcpUrl: mcpUrl, }); + await assertHermesToolCall("hermes-real-mcp-tool-call-after-dns-rebinding-remove"); const survivingDiscoveryOffset = fakeMcp.requests.length; await restartBridgeWithoutHostSecret(host, HERMES_SANDBOX_NAME, "hermes"); - await assertRealAdapterToolCall(sandbox, fakeMcp, { - agent: "hermes", - sandboxName: HERMES_SANDBOX_NAME, - resultToken: hermesResult, - artifactName: "hermes-real-mcp-tool-call-after-rediscovery-restart", - }); + await assertHermesToolCall("hermes-real-mcp-tool-call-after-rediscovery-restart"); await assertAuthenticatedMcpRediscovery(survivingMcp, survivingDiscoveryOffset); fakeMcp.setSecret(ROTATED_HOST_SECRET); await rotateBridgeCredential(host, HERMES_SANDBOX_NAME, "hermes"); @@ -1418,6 +1423,7 @@ mcpBridgeShardTest("deepagents")( artifactPrefix: "deepagents", sandboxName: DEEPAGENTS_SANDBOX_NAME, secretPaths: ["/sandbox/.deepagents"], + survivingMcpUrl: mcpUrl, }); progress.phase("exercise lifecycle and confirm Deep Agents bridge removal"); await assertRealAdapterToolCall(sandbox, fakeMcp, { diff --git a/test/e2e/support/mcp-bridge-sandbox.test.ts b/test/e2e/support/mcp-bridge-sandbox.test.ts index 4346ae4b62b..c2e6017ee9f 100644 --- a/test/e2e/support/mcp-bridge-sandbox.test.ts +++ b/test/e2e/support/mcp-bridge-sandbox.test.ts @@ -12,6 +12,7 @@ import YAML from "yaml"; import { testTimeout } from "../../helpers/timeouts"; import type { HostCliClient } from "../fixtures/clients/host.ts"; import { + assertManagedMcpPolicySurvivedRemoval, buildMcpDnsRebindingProbeScript, hostAddressForSandbox, hostPrivateAddressForSandbox, @@ -302,45 +303,34 @@ network_policies: expect(contractSource).not.toContain("assertAdapterDnsRebindingDenied"); }); - it("runs the zero-upstream rebinding proof for all three adapters", () => { - const source = fs.readFileSync("test/e2e/live/mcp-bridge.test.ts", "utf8"); - - expect(source.match(/await assertAdapterDnsRebindingDenied/g)).toHaveLength(3); - for (const adapter of [ - 'adapter: "mcporter"', - 'adapter: "hermes-config"', - 'adapter: "deepagents-config"', - ]) { - expect(source).toContain(adapter); - } - expect(source).toContain("rebound request must not reach the upstream MCP server"); - expect(source).toContain(").toHaveLength(0);"); - }); + it("accepts an unchanged surviving policy only after the unrelated policy is absent", () => { + const survivingPolicy = { + endpoints: [{ host: "surviving.example.test", allowed_ips: ["203.0.113.10"] }], + }; - it("captures the Hermes rediscovery offset after route removal and before restart", () => { - const source = fs.readFileSync("test/e2e/live/mcp-bridge.test.ts", "utf8"); - const denialProof = source.indexOf("rebound request must not reach the upstream MCP server"); - const restore = source.indexOf("await restoreDnsRebindingHostsFixture", denialProof); - const remove = source.indexOf("const remove = await host.nemoclaw", denialProof); - const hermesTest = source.indexOf('mcpBridgeShardTest("hermes")'); - const rebinding = source.indexOf("await assertAdapterDnsRebindingDenied", hermesTest); - const offset = source.indexOf( - "const survivingDiscoveryOffset = fakeMcp.requests.length", - rebinding, - ); - const restart = source.indexOf("await restartBridgeWithoutHostSecret", offset); - const toolCall = source.indexOf("await assertRealAdapterToolCall", restart); - const rediscovery = source.indexOf("await assertAuthenticatedMcpRediscovery", toolCall); - - expect(denialProof).toBeGreaterThanOrEqual(0); - expect(restore).toBeGreaterThan(denialProof); - expect(remove).toBeGreaterThan(restore); - expect(rebinding).toBeGreaterThan(hermesTest); - expect(offset).toBeGreaterThan(rebinding); - expect(restart).toBeGreaterThan(offset); - expect(toolCall).toBeGreaterThan(restart); - expect(rediscovery).toBeGreaterThan(toolCall); - expect(source).toContain("Hermes MCP rediscovery after explicit restart"); + expect(() => + assertManagedMcpPolicySurvivedRemoval( + survivingPolicy, + { + networkPolicies: { mcp_bridge_surviving: survivingPolicy }, + policy: survivingPolicy, + }, + "mcp_bridge_rebinding", + ), + ).not.toThrow(); + expect(() => + assertManagedMcpPolicySurvivedRemoval( + survivingPolicy, + { + networkPolicies: { + mcp_bridge_rebinding: { endpoints: [] }, + mcp_bridge_surviving: survivingPolicy, + }, + policy: survivingPolicy, + }, + "mcp_bridge_rebinding", + ), + ).toThrow(); }); it("restores host DNS strictly while treating the ephemeral sandbox as best effort", async () => { diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts index 504d9a2898d..01b3f91212b 100644 --- a/test/permissive-runtime.test.ts +++ b/test/permissive-runtime.test.ts @@ -7,7 +7,10 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import YAML from "yaml"; -import { buildRuntimePermissivePolicy } from "../src/lib/shields/permissive-runtime.js"; +import { + buildRuntimePermissivePolicy, + type ExactManagedMcpPolicy, +} from "../src/lib/shields/permissive-runtime.js"; const BASE_PERMISSIVE = YAML.stringify({ filesystem_policy: { @@ -46,6 +49,51 @@ afterEach(() => { }); describe("buildRuntimePermissivePolicy (#3942)", () => { + it("preserves exact managed MCP entries without copying unrelated live egress (#7952)", () => { + const managedPolicy: ExactManagedMcpPolicy = { + key: "mcp_bridge_alpha", + networkPolicy: { + endpoints: [{ host: "alpha.example.com", port: 443, protocol: "mcp" }], + binaries: [{ path: "/opt/hermes/.venv/bin/python*" }], + }, + policyName: "mcp-bridge-alpha", + server: "alpha", + }; + const liveYaml = YAML.stringify({ + filesystem_policy: { read_write: ["/proc"] }, + network_policies: { + mcp_bridge_alpha: managedPolicy.networkPolicy, + unrelated_live_entry: { + endpoints: [{ host: "unrelated.example.com", port: 443 }], + }, + }, + }); + + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: liveYaml, + managedMcpPolicies: [managedPolicy], + readBasePolicy: () => + YAML.stringify({ + ...YAML.parse(BASE_PERMISSIVE), + network_policies: { + permissive_baseline: { + endpoints: [{ host: "*", port: 443 }], + }, + }, + }), + }); + trackTempForCleanup(out, "/unused-base.yaml"); + + const result = YAML.parse(fs.readFileSync(out, "utf-8")); + expect(result.network_policies).toMatchObject({ + mcp_bridge_alpha: managedPolicy.networkPolicy, + permissive_baseline: { + endpoints: [{ host: "*", port: 443 }], + }, + }); + expect(result.network_policies).not.toHaveProperty("unrelated_live_entry"); + }); + it("preserves /proc when the live GPU sandbox has it in read_write", () => { const liveYaml = YAML.stringify({ filesystem_policy: { @@ -167,6 +215,25 @@ describe("buildRuntimePermissivePolicy (#3942)", () => { expect(out).toBe(basePath); }); + it("fails closed when the base cannot be read with managed MCP policies active (#7952)", () => { + expect(() => + buildRuntimePermissivePolicy("/path/to/static.yaml", { + livePolicyYaml: "version: 1\nnetwork_policies: {}\n", + managedMcpPolicies: [ + { + key: "mcp_bridge_alpha", + networkPolicy: {}, + policyName: "mcp-bridge-alpha", + server: "alpha", + }, + ], + readBasePolicy: () => { + throw new Error("ENOENT"); + }, + }), + ).toThrow(/Cannot read the Shields-down policy/); + }); + it("returns the static base path when base YAML is unparseable", () => { const basePath = "/path/to/static.yaml"; const liveYaml = YAML.stringify({