diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 40d38eb732f..59da2040b7e 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -51,7 +51,7 @@ "src/lib/inference/vllm.ts": 21, "src/lib/onboard.ts": 201, "src/lib/onboard/machine/handlers/sandbox.ts": 21, - "src/lib/policy/index.ts": 22, + "src/lib/policy/index.ts": 23, "src/lib/sandbox/config.ts": 22, "src/lib/shields/index.ts": 25 } diff --git a/nemoclaw/src/blueprint/runner-identity.test.ts b/nemoclaw/src/blueprint/runner-identity.test.ts index c14da4d28e5..2799875615c 100644 --- a/nemoclaw/src/blueprint/runner-identity.test.ts +++ b/nemoclaw/src/blueprint/runner-identity.test.ts @@ -19,6 +19,7 @@ import { MATCHING_INFERENCE_ROUTE_LISTING, MATCHING_RUNTIME_PROVIDER_LISTING, providersV2EnabledResult, + resultWithBlueprintPolicyAuthority, successResult, } from "./runner-test-fixtures.js"; @@ -36,8 +37,10 @@ vi.mock("node:fs", async (importOriginal) => { const memory = inMemoryFsMethods(store, { realpaths, spy: vi.fn }); return { ...original, + existsSync: memory.existsSync, mkdirSync: memory.mkdirSync, readFileSync: memory.readFileSync, + renameSync: memory.renameSync, writeFileSync: memory.writeFileSync, readdirSync: memory.readdirSync, realpathSync: memory.realpathSync, @@ -53,9 +56,8 @@ vi.mock("./ssrf.js", async (importOriginal) => { }; }); -const { actionApply, actionPlan, actionRollback, actionStatus, loadBlueprint } = await import( - "./runner.js" -); +const { actionApply, actionPlan, actionRollback, actionStatus, loadBlueprint } = + await import("./runner.js"); const matchingProvider = MATCHING_RUNTIME_PROVIDER_LISTING; const matchingInferenceProvider = MATCHING_INFERENCE_PROVIDER_LISTING; @@ -82,10 +84,27 @@ function responseQueue( ]); mockExeca.mockImplementation(async (_command: string, args: string[]) => { const command = args.join(" "); - return responses.get(command)?.shift() ?? fallbacks.get(command) ?? success; + const fallback = responses.get(command)?.shift() ?? fallbacks.get(command) ?? success; + return fallback.exitCode === undefined + ? fallback + : resultWithBlueprintPolicyAuthority(args, { + ...fallback, + exitCode: fallback.exitCode ?? 1, + }); }); } +function nonAuthorityCommandLines(): string[] { + const authorityCommands = new Set([ + "openshell status", + "openshell policy list -g test-gateway --global --limit 1", + "openshell policy get -g test-gateway --full --output json test-sandbox", + ]); + return mockExeca.mock.calls + .map(([command, args]) => [command, ...(args ?? [])].join(" ")) + .filter((command) => !authorityCommands.has(command)); +} + function blueprint(overrides: Record = {}): Parameters[1] { return { components: { @@ -123,7 +142,10 @@ describe("blueprint identity wrapper", () => { realpaths.clear(); vi.clearAllMocks(); mockExeca.mockImplementation(async (_command: string, args: string[]) => - args.join(" ") === "settings get --global --json" ? providersV2Enabled : success, + resultWithBlueprintPolicyAuthority( + args, + args.join(" ") === "settings get --global --json" ? providersV2Enabled : success, + ), ); process.env.NEMOCLAW_BLUEPRINT_PATH = "/blueprint"; store.set("/blueprint", { type: "dir" }); @@ -294,9 +316,7 @@ describe("blueprint identity wrapper", () => { "provider refresh configure acme-okta-runtime --credential-key OKTA_ACCESS_TOKEN --strategy oauth2-refresh-token --material client_id=client-id --secret-material-env refresh_token=OKTA_REFRESH_TOKEN --secret-material-env client_secret=OKTA_CLIENT_SECRET", ), ).toBeLessThan(commands.indexOf("sandbox provider attach test-sandbox acme-okta-runtime")); - expect( - commands.indexOf("sandbox provider attach test-sandbox acme-okta-runtime"), - ).toBeLessThan( + expect(commands.indexOf("sandbox provider attach test-sandbox acme-okta-runtime")).toBeLessThan( commands.indexOf( "provider refresh rotate acme-okta-runtime --credential-key OKTA_ACCESS_TOKEN", ), @@ -337,9 +357,7 @@ describe("blueprint identity wrapper", () => { /Failed to inspect sandbox 'test-sandbox'.*gateway configuration not found/, ); - const commandLines = mockExeca.mock.calls.map(([command, args]) => - [command, ...(args ?? [])].join(" "), - ); + const commandLines = nonAuthorityCommandLines(); expect(commandLines).toEqual(["openshell sandbox get test-sandbox"]); }); @@ -358,9 +376,7 @@ describe("blueprint identity wrapper", () => { /Sandbox 'test-sandbox' is not reusable.*Ready phase.*Provisioning/, ); - const commandLines = mockExeca.mock.calls.map(([command, args]) => - [command, ...(args ?? [])].join(" "), - ); + const commandLines = nonAuthorityCommandLines(); expect(commandLines).toEqual(["openshell sandbox get test-sandbox"]); }); @@ -375,38 +391,41 @@ describe("blueprint identity wrapper", () => { { exitCode: 0, stdout: "Name: test-sandbox\nPhase: Provisioning", stderr: "" }, /Sandbox 'test-sandbox' is not reusable.*Ready phase.*Provisioning/, ], - ])("fails closed when a concurrently created sandbox %s", async (_label, racedSandbox, expectedError) => { - process.env.OKTA_CLIENT_ID = "client-id"; - process.env.OKTA_REFRESH_TOKEN = "refresh-secret"; - process.env.OKTA_CLIENT_SECRET = "client-secret"; - responseQueue([ - ["sandbox get test-sandbox", [failureResult("sandbox not found"), racedSandbox]], - [ - "provider get acme-okta-runtime", + ])( + "fails closed when a concurrently created sandbox %s", + async (_label, racedSandbox, expectedError) => { + process.env.OKTA_CLIENT_ID = "client-id"; + process.env.OKTA_REFRESH_TOKEN = "refresh-secret"; + process.env.OKTA_CLIENT_SECRET = "client-secret"; + responseQueue([ + ["sandbox get test-sandbox", [failureResult("sandbox not found"), racedSandbox]], [ - failureResult("provider not found"), - ...Array.from({ length: 4 }, () => ({ - exitCode: 0, - stdout: matchingProvider, - stderr: "", - })), + "provider get acme-okta-runtime", + [ + failureResult("provider not found"), + ...Array.from({ length: 4 }, () => ({ + exitCode: 0, + stdout: matchingProvider, + stderr: "", + })), + ], ], - ], - [ - "sandbox create --from openclaw --name test-sandbox --forward 18789", - [failureResult("sandbox already exists")], - ], - ]); + [ + "sandbox create --from openclaw --name test-sandbox --forward 18789", + [failureResult("sandbox already exists")], + ], + ]); - await expect(actionApply("default", blueprint({ identity: oktaIdentity() }))).rejects.toThrow( - expectedError, - ); + await expect(actionApply("default", blueprint({ identity: oktaIdentity() }))).rejects.toThrow( + expectedError, + ); - const commands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); - expect(commands.filter((command) => command === "sandbox get test-sandbox")).toHaveLength(2); - expect(commands).not.toContain("sandbox provider attach test-sandbox acme-okta-runtime"); - expect(commands).toContain("provider delete acme-okta-runtime"); - }); + const commands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); + expect(commands.filter((command) => command === "sandbox get test-sandbox")).toHaveLength(2); + expect(commands).not.toContain("sandbox provider attach test-sandbox acme-okta-runtime"); + expect(commands).toContain("provider delete acme-okta-runtime"); + }, + ); it("fails before identity mutation when a reused sandbox's inference provider cannot be inspected", async () => { process.env.OKTA_CLIENT_ID = "client-id"; @@ -424,9 +443,7 @@ describe("blueprint identity wrapper", () => { /Failed to inspect inference provider 'test-provider'.*gateway configuration not found/, ); - const commandLines = mockExeca.mock.calls.map(([command, args]) => - [command, ...(args ?? [])].join(" "), - ); + const commandLines = nonAuthorityCommandLines(); expect(commandLines).toEqual([ "openshell sandbox get test-sandbox", "openshell provider get test-provider", @@ -454,9 +471,7 @@ describe("blueprint identity wrapper", () => { /Inference provider 'test-provider' does not match the requested non-secret binding/, ); - const commandLines = mockExeca.mock.calls.map(([command, args]) => - [command, ...(args ?? [])].join(" "), - ); + const commandLines = nonAuthorityCommandLines(); expect(commandLines).toEqual([ "openshell sandbox get test-sandbox", "openshell provider get test-provider", @@ -483,9 +498,7 @@ describe("blueprint identity wrapper", () => { /Failed to inspect the active inference route.*gateway route inspection unavailable/, ); - const commandLines = mockExeca.mock.calls.map(([command, args]) => - [command, ...(args ?? [])].join(" "), - ); + const commandLines = nonAuthorityCommandLines(); expect(commandLines).toEqual([ "openshell sandbox get test-sandbox", "openshell provider get test-provider", @@ -786,7 +799,7 @@ describe("blueprint identity wrapper", () => { { exitCode: 0, stdout: matchingProvider, stderr: "" }, ], ], - ["policy get --base test-sandbox", [failureResult("policy read rejected")]], + ["policy get -g test-gateway --base test-sandbox", [failureResult("policy read rejected")]], ]); await expect( diff --git a/nemoclaw/src/blueprint/runner-mock-fixtures.ts b/nemoclaw/src/blueprint/runner-mock-fixtures.ts index ef394e91d0d..3e0d7aec5d9 100644 --- a/nemoclaw/src/blueprint/runner-mock-fixtures.ts +++ b/nemoclaw/src/blueprint/runner-mock-fixtures.ts @@ -69,6 +69,12 @@ export function inMemoryFsMethods(store: Map, options?: I writeFileSync: spy((p: string, data: string) => { store.set(p, { type: "file", content: String(data) }); }), + renameSync: spy((source: string, destination: string) => { + const entry = store.get(source); + if (!entry) return missingEntry(source); + store.set(destination, entry); + store.delete(source); + }), readdirSync: (p: string) => { const prefix = p.endsWith("/") ? p : `${p}/`; const entries = new Set( diff --git a/nemoclaw/src/blueprint/runner-name-validation.test.ts b/nemoclaw/src/blueprint/runner-name-validation.test.ts index 82159fb78d4..763444c2d4b 100644 --- a/nemoclaw/src/blueprint/runner-name-validation.test.ts +++ b/nemoclaw/src/blueprint/runner-name-validation.test.ts @@ -16,7 +16,11 @@ import { inMemoryFsMethods, resolvedEndpointFor, } from "./runner-mock-fixtures.js"; -import { minimalBlueprint, successResult } from "./runner-test-fixtures.js"; +import { + minimalBlueprint, + resultWithBlueprintPolicyAuthority, + successResult, +} from "./runner-test-fixtures.js"; const { store, addFile, addDir } = createRunnerFsStore(); @@ -36,6 +40,7 @@ vi.mock("node:fs", async (importOriginal) => { existsSync: memory.existsSync, mkdirSync: memory.mkdirSync, readFileSync: memory.readFileSync, + renameSync: memory.renameSync, writeFileSync: memory.writeFileSync, readdirSync: memory.readdirSync, }; @@ -114,7 +119,9 @@ describe("blueprint name validation (fail-closed integration)", () => { stdout.reset(); vi.clearAllMocks(); vi.spyOn(process.stdout, "write").mockImplementation(stdout.write); - mockExeca.mockResolvedValue(successResult()); + mockExeca.mockImplementation(async (_command: string, args: string[]) => + resultWithBlueprintPolicyAuthority(args, successResult()), + ); }); afterEach(() => { diff --git a/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts b/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts index 752e0c0930b..a9c3ff70580 100644 --- a/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts +++ b/nemoclaw/src/blueprint/runner-openshell-072-policy.test.ts @@ -8,14 +8,23 @@ import YAML from "yaml"; import { createRunnerFsStore, + createStdoutCapture, FAKE_HOME, FIXED_RUN_UUID, inMemoryFsMethods, resolvedEndpointFor, } from "./runner-mock-fixtures.js"; +import { + gatewayStatusResult, + globalPolicyAuthorityResult, + minimalBlueprint, + resultWithBlueprintPolicyAuthority, + sandboxPolicyAuthorityResult, +} from "./runner-test-fixtures.js"; const { store } = createRunnerFsStore(); const mockExeca = vi.fn(); +const stdoutCapture = createStdoutCapture(); vi.mock("node:crypto", () => ({ randomUUID: () => FIXED_RUN_UUID, @@ -30,7 +39,11 @@ vi.mock("node:fs", async (importOriginal) => { const memory = inMemoryFsMethods(store, { spy: vi.fn }); return { ...original, + existsSync: memory.existsSync, mkdirSync: memory.mkdirSync, + readFileSync: memory.readFileSync, + readdirSync: memory.readdirSync, + renameSync: memory.renameSync, writeFileSync: memory.writeFileSync, }; }); @@ -47,7 +60,10 @@ vi.mock("./ssrf.js", async (importOriginal) => { }; }); -const { actionApply } = await import("./runner.js"); +const { actionApply, actionReconcile, actionRollback, actionStatus, main } = + await import("./runner.js"); +const { renameSync } = await import("node:fs"); +const mockedRenameSync = vi.mocked(renameSync); const BASE_POLICY = `version: 1 future_policy: @@ -108,6 +124,14 @@ function policySetCalls(): unknown[][] { ); } +function defaultCommandResult(args: string[]) { + return resultWithBlueprintPolicyAuthority(args, { + exitCode: 0, + stdout: "", + stderr: "", + }); +} + function mergedPolicy(): Record { const key = [...store.keys()].find((candidate) => candidate.endsWith("/merged-policy.yaml")); expect(key).toBeDefined(); @@ -149,17 +173,19 @@ function blueprint(): Parameters[1] { describe("OpenShell 0.0.72 blueprint policy round-trip", () => { beforeEach(() => { store.clear(); + stdoutCapture.reset(); mockExeca.mockReset(); - vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stdout, "write").mockImplementation(stdoutCapture.write); const policyByCommand = new Map([ - ["policy get --base test-sandbox", policyOutput(BASE_POLICY)], - ["policy get --full test-sandbox", policyOutput(FULL_POLICY)], + ["policy get -g test-gateway --base test-sandbox", policyOutput(BASE_POLICY)], + ["policy get -g test-gateway --full test-sandbox", policyOutput(FULL_POLICY)], ]); - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ - exitCode: 0, - stdout: policyByCommand.get(args.slice(0, 4).join(" ")) ?? "", - stderr: "", - })); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => { + const policy = policyByCommand.get(args.join(" ")); + return policy === undefined + ? defaultCommandResult(args) + : { exitCode: 0, stdout: policy, stderr: "" }; + }); }); afterEach(() => { @@ -171,12 +197,12 @@ describe("OpenShell 0.0.72 blueprint policy round-trip", () => { expect(mockExeca).toHaveBeenCalledWith( "openshell", - ["policy", "get", "--base", "test-sandbox"], + ["policy", "get", "-g", "test-gateway", "--base", "test-sandbox"], expect.objectContaining({ reject: false }), ); expect(mockExeca).not.toHaveBeenCalledWith( "openshell", - ["policy", "get", "--full", "test-sandbox"], + ["policy", "get", "-g", "test-gateway", "--full", "test-sandbox"], expect.anything(), ); @@ -194,20 +220,29 @@ describe("OpenShell 0.0.72 blueprint policy round-trip", () => { nim_service: expect.any(Object), }); expect(merged.network_policies).not.toHaveProperty("_provider_nvidia-inference"); + const planEntry = [...store.values()].find((entry) => + entry.content?.includes('"policy_transition"'), + ); + expect(JSON.parse(planEntry?.content ?? "{}")).toMatchObject({ + policy_transition: { + status: "complete", + sandbox_name: "test-sandbox", + gateway: "test-gateway", + expected_authority: "nemoclaw-managed", + policy_addition_names: ["nim_service"], + }, + }); }); it.each([ ["scalar", "future_mode", "future_mode: strict\n"], ["sequence", "future_features", "future_features: [audit, attribution]\n"], ])("fails closed for an unknown top-level %s", async (_shape, key, fragment) => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ - exitCode: 0, - stdout: - args.slice(0, 4).join(" ") === "policy get --base test-sandbox" - ? policyOutput(`${fragment}${BASE_POLICY}`) - : "", - stderr: "", - })); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "policy get -g test-gateway --base test-sandbox" + ? { exitCode: 0, stdout: policyOutput(`${fragment}${BASE_POLICY}`), stderr: "" } + : defaultCommandResult(args), + ); await expect(actionApply("default", blueprint())).rejects.toThrow( `Current policy top-level field "${key}" must be a YAML mapping`, @@ -216,27 +251,31 @@ describe("OpenShell 0.0.72 blueprint policy round-trip", () => { }); it("fails closed when policy get --base fails", async () => { + const diagnostic = `MY_API_KEY=super-secret ${"policy details ".repeat(80)}`; mockExeca.mockImplementation(async (_cmd: string, args: string[]) => - args.slice(0, 4).join(" ") === "policy get --base test-sandbox" - ? { exitCode: 1, stdout: "", stderr: "gateway unavailable" } - : { exitCode: 0, stdout: "", stderr: "" }, + args.join(" ") === "policy get -g test-gateway --base test-sandbox" + ? { exitCode: 1, stdout: "", stderr: diagnostic } + : defaultCommandResult(args), ); - await expect(actionApply("default", blueprint())).rejects.toThrow( - /Failed to read current policy.*gateway unavailable/, + const error = await actionApply("default", blueprint()).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "Failed to read current policy before applying additions", ); + expect((error as Error).message).toContain("MY_API_KEY="); + expect((error as Error).message).not.toContain("super-secret"); + expect((error as Error).message).toContain("…"); + expect((error as Error).message.length).toBeLessThan(600); expect(policySetCalls()).toEqual([]); }); it("fails closed when policy get --base returns metadata without a policy document", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ - exitCode: 0, - stdout: - args.slice(0, 4).join(" ") === "policy get --base test-sandbox" - ? "Version: 1\nHash: sha256:test\n" - : "", - stderr: "", - })); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "policy get -g test-gateway --base test-sandbox" + ? { exitCode: 0, stdout: "Version: 1\nHash: sha256:test\n", stderr: "" } + : defaultCommandResult(args), + ); await expect(actionApply("default", blueprint())).rejects.toThrow( /does not contain a policy YAML document/, @@ -249,14 +288,11 @@ describe("OpenShell 0.0.72 blueprint policy round-trip", () => { malformedBase.network_policies["_provider_unexpected"] = { endpoints: [{ host: "provider.invalid", port: 443, access: "full" }], }; - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ - exitCode: 0, - stdout: - args.slice(0, 4).join(" ") === "policy get --base test-sandbox" - ? policyOutput(YAML.stringify(malformedBase)) - : "", - stderr: "", - })); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "policy get -g test-gateway --base test-sandbox" + ? { exitCode: 0, stdout: policyOutput(YAML.stringify(malformedBase)), stderr: "" } + : defaultCommandResult(args), + ); await actionApply("default", blueprint()); const merged = mergedPolicy() as { @@ -284,18 +320,471 @@ describe("OpenShell 0.0.72 blueprint policy round-trip", () => { }); it("fails closed for a legacy network_policies array instead of dropping it", async () => { - mockExeca.mockImplementation(async (_cmd: string, args: string[]) => ({ - exitCode: 0, - stdout: - args.slice(0, 4).join(" ") === "policy get --base test-sandbox" - ? policyOutput("version: 1\nnetwork_policies:\n - name: legacy\n") - : "", - stderr: "", - })); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "policy get -g test-gateway --base test-sandbox" + ? { + exitCode: 0, + stdout: policyOutput("version: 1\nnetwork_policies:\n - name: legacy\n"), + stderr: "", + } + : defaultCommandResult(args), + ); await expect(actionApply("default", blueprint())).rejects.toThrow( /network_policies must be a YAML mapping/, ); expect(policySetCalls()).toEqual([]); }); + + it("uses exact external additions without mutating policy (#9833)", async () => { + const bp = blueprint(); + const additions = bp.components!.policy!.additions!; + vi.stubEnv("OPENSHELL_SANDBOX_POLICY", "/tmp/caller-policy.yaml"); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "status" + ? gatewayStatusResult("recorded-gateway") + : args.join(" ") === "policy list -g recorded-gateway --global --limit 1" + ? { exitCode: 0, stdout: "VERSION STATUS\n1 loaded\n", stderr: "" } + : args.join(" ") === "policy get -g recorded-gateway --global --full --output json" + ? globalPolicyAuthorityResult(additions) + : args.join(" ") === "policy get -g recorded-gateway --full --output json test-sandbox" + ? sandboxPolicyAuthorityResult("test-sandbox", "externally-managed", additions) + : { exitCode: 0, stdout: "", stderr: "" }, + ); + + await actionApply("default", bp); + + const policyCalls = mockExeca.mock.calls.filter( + (call) => Array.isArray(call[1]) && call[1][0] === "policy", + ); + expect(policyCalls.every((call) => call[1].includes("recorded-gateway"))).toBe(true); + expect(policySetCalls()).toEqual([]); + const sandboxCreate = mockExeca.mock.calls.find( + (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", + ); + expect(sandboxCreate?.[2].env).not.toHaveProperty("OPENSHELL_SANDBOX_POLICY"); + }); + + it("refuses missing external additions before creating a sandbox (#9833)", async () => { + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "status" + ? gatewayStatusResult() + : args.join(" ") === "policy list -g test-gateway --global --limit 1" + ? { exitCode: 0, stdout: "VERSION STATUS\n1 loaded\n", stderr: "" } + : args.join(" ") === "policy get -g test-gateway --global --full --output json" + ? globalPolicyAuthorityResult() + : { exitCode: 0, stdout: "", stderr: "" }, + ); + + await expect(actionApply("default", blueprint())).rejects.toThrow( + /missing entries "nim_service"/, + ); + expect( + mockExeca.mock.calls.some( + (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", + ), + ).toBe(false); + }); + + it("fails closed on malformed global authority before sandbox creation (#9833)", async () => { + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "status" + ? gatewayStatusResult() + : args.join(" ") === "policy list -g test-gateway --global --limit 1" + ? { exitCode: 0, stdout: "VERSION STATUS\n1 loaded\n", stderr: "" } + : args.join(" ") === "policy get -g test-gateway --global --full --output json" + ? { exitCode: 0, stdout: "{", stderr: "" } + : { exitCode: 0, stdout: "", stderr: "" }, + ); + + await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( + /malformed global policy authority metadata/, + ); + expect( + mockExeca.mock.calls.some( + (call) => Array.isArray(call[1]) && call[1][0] === "sandbox" && call[1][1] === "create", + ), + ).toBe(false); + }); + + it("stops before provider and policy mutation when sandbox authority is malformed (#9833)", async () => { + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox" + ? { exitCode: 0, stdout: "{", stderr: "" } + : defaultCommandResult(args), + ); + + await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( + /malformed sandbox policy authority metadata/, + ); + expect( + mockExeca.mock.calls.some( + (call) => Array.isArray(call[1]) && call[1][0] === "provider" && call[1][1] === "create", + ), + ).toBe(false); + expect(policySetCalls()).toEqual([]); + }); + + it("rechecks authority immediately before a managed policy mutation (#9833)", async () => { + let sandboxAuthorityReads = 0; + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => { + switch (args.join(" ")) { + case "sandbox create --from openclaw --name test-sandbox --forward 18789": + return { exitCode: 1, stdout: "", stderr: "sandbox already exists" }; + case "policy get -g test-gateway --base test-sandbox": + return { + exitCode: 0, + stdout: "Version: 1\nHash: sha256:test\n---\nversion: 1\nnetwork_policies: {}\n", + stderr: "", + }; + case "policy get -g test-gateway --full --output json test-sandbox": + sandboxAuthorityReads += 1; + return sandboxPolicyAuthorityResult( + "test-sandbox", + sandboxAuthorityReads < 3 ? "nemoclaw-managed" : "externally-managed", + ); + default: + return defaultCommandResult(args); + } + }); + + await expect(actionApply("default", blueprint())).rejects.toThrow(/policy authority changed/); + expect(policySetCalls()).toEqual([]); + expect(sandboxAuthorityReads).toBe(3); + }); + + it("records and reports an incomplete reused-sandbox policy transition (#9833)", async () => { + let sandboxAuthorityReads = 0; + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => { + switch (args.join(" ")) { + case "sandbox create --from openclaw --name test-sandbox --forward 18789": + return { exitCode: 1, stdout: "", stderr: "sandbox already exists" }; + case "policy get -g test-gateway --base test-sandbox": + return { + exitCode: 0, + stdout: "Version: 1\nHash: sha256:test\n---\nversion: 1\nnetwork_policies: {}\n", + stderr: "", + }; + case "policy get -g test-gateway --full --output json test-sandbox": + sandboxAuthorityReads += 1; + return sandboxPolicyAuthorityResult( + "test-sandbox", + sandboxAuthorityReads < 4 ? "nemoclaw-managed" : "externally-managed", + ); + default: + return defaultCommandResult(args); + } + }); + + await expect(actionApply("default", blueprint())).rejects.toThrow(/policy authority changed/); + expect(policySetCalls()).toHaveLength(1); + expect(sandboxAuthorityReads).toBe(4); + + const planEntry = [...store.entries()].find(([path]) => path.endsWith("/plan.json")); + expect(planEntry).toBeDefined(); + const plan = JSON.parse(planEntry?.[1].content ?? "{}") as { + run_id: string; + sandbox_created_by_apply: boolean; + policy_transition: Record; + }; + expect(plan).toMatchObject({ + sandbox_created_by_apply: false, + policy_transition: { + status: "incomplete", + sandbox_name: "test-sandbox", + gateway: "test-gateway", + expected_authority: "nemoclaw-managed", + policy_addition_names: ["nim_service"], + }, + }); + + stdoutCapture.reset(); + actionStatus(plan.run_id); + expect(stdoutCapture.jsonOutput()).toMatchObject({ + policy_transition: { + status: "incomplete", + sandbox_name: "test-sandbox", + reconciliation_required: true, + reconciliation_action: expect.stringContaining("Run reconcile"), + }, + }); + + await expect(actionRollback(plan.run_id)).rejects.toThrow( + /policy transition for reused sandbox "test-sandbox".*is incomplete.*reconcile/u, + ); + expect(store.has(`${planEntry?.[0].replace(/\/plan\.json$/u, "")}/rolled_back`)).toBe(false); + + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox" + ? sandboxPolicyAuthorityResult( + "test-sandbox", + "nemoclaw-managed", + blueprint().components!.policy!.additions!, + ) + : defaultCommandResult(args), + ); + stdoutCapture.reset(); + await main(["reconcile", "--run-id", plan.run_id]); + expect(stdoutCapture.text()).toContain(`Policy transition for run ${plan.run_id} is complete.`); + expect(JSON.parse(store.get(planEntry?.[0] ?? "")?.content ?? "{}")).toMatchObject({ + policy_transition: { status: "complete" }, + }); + + await actionRollback(plan.run_id); + expect(store.has(`${planEntry?.[0].replace(/\/plan\.json$/u, "")}/rolled_back`)).toBe(true); + }); + + it("keeps a pending receipt when a reused-sandbox policy set fails (#9833)", async () => { + const diagnostic = `POLICY_TOKEN=super-secret ${"policy details ".repeat(80)}`; + const responses = new Map([ + [ + "sandbox create --from openclaw --name test-sandbox --forward 18789", + { exitCode: 1, stdout: "", stderr: "sandbox already exists" }, + ], + [ + "policy get -g test-gateway --base test-sandbox", + { + exitCode: 0, + stdout: policyOutput("version: 1\nnetwork_policies: {}\n"), + stderr: "", + }, + ], + ["policy set", { exitCode: 1, stdout: "", stderr: diagnostic }], + ]); + mockExeca.mockImplementation( + async (_cmd: string, args: string[]) => + responses.get(args.join(" ")) ?? + responses.get(args.slice(0, 2).join(" ")) ?? + defaultCommandResult(args), + ); + + const error = await actionApply("default", blueprint()).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Failed to apply policy additions"); + expect((error as Error).message).toContain("POLICY_TOKEN="); + expect((error as Error).message).not.toContain("super-secret"); + expect((error as Error).message).toContain("…"); + expect((error as Error).message.length).toBeLessThan(600); + const planEntry = [...store.entries()].find(([, entry]) => + entry.content?.includes('"policy_transition"'), + ); + const plan = JSON.parse(planEntry?.[1].content ?? "{}") as { run_id: string }; + expect(plan).toMatchObject({ + sandbox_created_by_apply: false, + policy_transition: { + status: "pending", + sandbox_name: "test-sandbox", + gateway: "test-gateway", + policy_addition_names: ["nim_service"], + }, + }); + + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox" + ? sandboxPolicyAuthorityResult( + "test-sandbox", + "nemoclaw-managed", + blueprint().components!.policy!.additions!, + ) + : defaultCommandResult(args), + ); + await actionReconcile(plan.run_id); + expect(JSON.parse(store.get(planEntry?.[0] ?? "")?.content ?? "{}")).toMatchObject({ + policy_transition: { status: "complete" }, + }); + }); + + it.each([ + ["authority change", "externally-managed" as const, blueprint().components!.policy!.additions!], + ["missing addition", "nemoclaw-managed" as const, {}], + ])( + "retains an incomplete receipt after a reconciliation %s (#9833)", + async (_case, authority, observed) => { + const runId = "incomplete-transition"; + const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; + const additions = blueprint().components!.policy!.additions!; + store.set(stateDir, { type: "dir" }); + store.set(`${stateDir}/plan.json`, { + type: "file", + content: JSON.stringify({ + run_id: runId, + sandbox_name: "test-sandbox", + sandbox_created_by_apply: false, + policy_additions: additions, + policy_transition: { + status: "incomplete", + sandbox_name: "test-sandbox", + gateway: "recorded-gateway", + expected_authority: "nemoclaw-managed", + policy_addition_names: ["nim_service"], + }, + }), + }); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "policy get -g recorded-gateway --full --output json test-sandbox" + ? sandboxPolicyAuthorityResult("test-sandbox", authority, observed) + : defaultCommandResult(args), + ); + + await expect(actionReconcile(runId)).rejects.toThrow(/Cannot reconcile/u); + expect(JSON.parse(store.get(`${stateDir}/plan.json`)?.content ?? "{}")).toMatchObject({ + policy_transition: { status: "incomplete" }, + }); + await expect(actionRollback(runId)).rejects.toThrow(/is incomplete/u); + }, + ); + + it("rejects an invalid persisted policy transition (#9833)", async () => { + const runId = "invalid-transition"; + const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; + store.set(stateDir, { type: "dir" }); + store.set(`${stateDir}/plan.json`, { + type: "file", + content: JSON.stringify({ + run_id: runId, + sandbox_name: "test-sandbox", + sandbox_created_by_apply: false, + policy_transition: { + status: "unknown", + sandbox_name: "test-sandbox", + gateway: "test-gateway", + expected_authority: "nemoclaw-managed", + policy_addition_names: ["nim_service"], + }, + }), + }); + + stdoutCapture.reset(); + actionStatus(runId); + expect(stdoutCapture.jsonOutput()).toMatchObject({ + run_id: runId, + status: "unknown", + receipt_error_kind: "invalid", + run_directory: stateDir, + recovery: expect.stringContaining("Do not reconstruct plan.json"), + }); + await expect(actionReconcile(runId)).rejects.toThrow(/policy transition receipt is invalid/u); + await expect(actionRollback(runId)).rejects.toThrow(/policy transition receipt is invalid/u); + expect(mockExeca).not.toHaveBeenCalled(); + }); + + const reconciliationPlan = { + run_id: "invalid-reconciliation", + sandbox_name: "test-sandbox", + sandbox_created_by_apply: false, + policy_additions: blueprint().components!.policy!.additions!, + policy_transition: { + status: "incomplete", + sandbox_name: "test-sandbox", + gateway: "test-gateway", + expected_authority: "nemoclaw-managed", + policy_addition_names: ["nim_service"], + }, + }; + + it.each([ + ["a non-object body", [], /plan\.json must contain a JSON object/u], + [ + "an apply-owned sandbox", + { ...reconciliationPlan, sandbox_created_by_apply: true }, + /requires a reused sandbox/u, + ], + [ + "a mismatched sandbox", + { ...reconciliationPlan, sandbox_name: "other-sandbox" }, + /sandbox does not match/u, + ], + [ + "invalid policy additions", + { ...reconciliationPlan, policy_additions: [] }, + /policy additions are invalid/u, + ], + [ + "mismatched policy addition names", + { + ...reconciliationPlan, + policy_transition: { + ...reconciliationPlan.policy_transition, + policy_addition_names: ["other"], + }, + }, + /additions do not match/u, + ], + ])("rejects a reconciliation plan with %s (#9833)", async (_case, plan, expected) => { + const runId = "invalid-reconciliation"; + const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; + store.set(stateDir, { type: "dir" }); + store.set(`${stateDir}/plan.json`, { type: "file", content: JSON.stringify(plan) }); + + await expect(actionReconcile(runId)).rejects.toThrow(expected); + }); + + it("rejects reconciliation for a missing run (#9833)", async () => { + await expect(actionReconcile("missing-reconciliation")).rejects.toThrow(/not found/u); + }); + + it("preserves the previous receipt when reconciliation replacement is interrupted (#9833)", async () => { + const runId = "interrupted-reconciliation"; + const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; + const planFile = `${stateDir}/plan.json`; + store.set(stateDir, { type: "dir" }); + store.set(planFile, { + type: "file", + content: JSON.stringify({ ...reconciliationPlan, run_id: runId }), + }); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + args.join(" ") === "policy get -g test-gateway --full --output json test-sandbox" + ? sandboxPolicyAuthorityResult( + "test-sandbox", + "nemoclaw-managed", + blueprint().components!.policy!.additions!, + ) + : defaultCommandResult(args), + ); + mockedRenameSync.mockImplementationOnce(() => { + throw new Error("simulated interrupted receipt replacement"); + }); + + await expect(actionReconcile(runId)).rejects.toThrow(/interrupted receipt replacement/u); + expect(JSON.parse(store.get(planFile)?.content ?? "{}")).toMatchObject({ + policy_transition: { status: "incomplete" }, + }); + + stdoutCapture.reset(); + actionStatus(runId); + expect(stdoutCapture.jsonOutput()).toMatchObject({ + policy_transition: { status: "incomplete", reconciliation_required: true }, + }); + await expect(actionRollback(runId)).rejects.toThrow(/is incomplete.*reconcile/u); + + await actionReconcile(runId); + expect(JSON.parse(store.get(planFile)?.content ?? "{}")).toMatchObject({ + policy_transition: { status: "complete" }, + }); + }); + + it("treats a complete reconciliation as idempotent and requires a CLI run ID (#9833)", async () => { + const runId = "complete-reconciliation"; + const stateDir = `${FAKE_HOME}/.nemoclaw/state/runs/${runId}`; + store.set(stateDir, { type: "dir" }); + store.set(`${stateDir}/plan.json`, { + type: "file", + content: JSON.stringify({ + ...reconciliationPlan, + run_id: runId, + policy_transition: { + ...reconciliationPlan.policy_transition, + status: "complete", + }, + }), + }); + + await actionReconcile(runId); + + expect(stdoutCapture.text()).toContain( + `Policy transition for run ${runId} is already complete.`, + ); + expect(mockExeca).not.toHaveBeenCalled(); + await expect(main(["reconcile"])).rejects.toThrow(/--run-id is required/u); + }); }); diff --git a/nemoclaw/src/blueprint/runner-test-fixtures.ts b/nemoclaw/src/blueprint/runner-test-fixtures.ts index 5096c501e01..1c577538096 100644 --- a/nemoclaw/src/blueprint/runner-test-fixtures.ts +++ b/nemoclaw/src/blueprint/runner-test-fixtures.ts @@ -82,9 +82,12 @@ export function resultForCommandFailure( command: readonly [string, string], stderr: string, ): { exitCode: number; stdout: string; stderr: string } { - return args[0] === command[0] && args[1] === command[1] - ? { exitCode: 1, stdout: "", stderr } - : { exitCode: 0, stdout: "", stderr: "" }; + return resultWithBlueprintPolicyAuthority( + args, + args[0] === command[0] && args[1] === command[1] + ? { exitCode: 1, stdout: "", stderr } + : { exitCode: 0, stdout: "", stderr: "" }, + ); } /** An empty successful command result. */ @@ -96,6 +99,74 @@ export function successResult(): { return { exitCode: 0, stdout: "", stderr: "" }; } +type CommandResult = { exitCode: number; stdout: string; stderr: string }; + +/** The connected gateway identity reported by `openshell status`. */ +export function gatewayStatusResult(gateway = "test-gateway"): CommandResult { + return { + exitCode: 0, + stdout: ["Gateway Status", "", " Status: Connected", ` Gateway: ${gateway}`, ""].join("\n"), + stderr: "", + }; +} + +/** Machine-readable effective policy metadata for one sandbox. */ +export function sandboxPolicyAuthorityResult( + sandboxName: string, + authority: "nemoclaw-managed" | "externally-managed" = "nemoclaw-managed", + networkPolicies: Record = {}, +): CommandResult { + return { + exitCode: 0, + stdout: JSON.stringify({ + scope: "sandbox", + sandbox: sandboxName, + status: "effective", + policy_source: authority === "nemoclaw-managed" ? "sandbox" : "global", + policy: { version: 1, network_policies: networkPolicies }, + }), + stderr: "", + }; +} + +/** Machine-readable external global policy metadata. */ +export function globalPolicyAuthorityResult( + networkPolicies: Record = {}, +): CommandResult { + return { + exitCode: 0, + stdout: JSON.stringify({ + scope: "global", + status: "loaded", + policy_source: "global", + policy: { version: 1, network_policies: networkPolicies }, + }), + stderr: "", + }; +} + +/** Standard gateway and policy-authority responses for blueprint apply tests. */ +export function resultWithBlueprintPolicyAuthority( + args: readonly string[], + fallback: CommandResult, + gateway = "test-gateway", +): CommandResult { + return args.join(" ") === "status" + ? gatewayStatusResult(gateway) + : args.join(" ") === `policy list -g ${gateway} --global --limit 1` + ? successResult() + : args[0] === "policy" && + args[1] === "get" && + args[2] === "-g" && + args[3] === gateway && + args[4] === "--full" && + args[5] === "--output" && + args[6] === "json" && + typeof args[7] === "string" + ? sandboxPolicyAuthorityResult(args[7]) + : fallback; +} + /** A failed command result carrying only stderr. */ export function failureResult(stderr: string): { exitCode: number; diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index 9aea233a2e9..b5cc15a27b1 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -14,9 +14,9 @@ import { } from "./runner-mock-fixtures.js"; import { blueprintWithPolicyAdditions, - failureResult, minimalBlueprint, resultForCommandFailure, + resultWithBlueprintPolicyAuthority, routedBlueprint, } from "./runner-test-fixtures.js"; @@ -39,7 +39,8 @@ vi.mock("node:fs", async (importOriginal) => { ...original, existsSync: memory.existsSync, mkdirSync: memory.mkdirSync, - readFileSync: memory.readFileSync, + readFileSync: vi.fn(memory.readFileSync), + renameSync: memory.renameSync, writeFileSync: memory.writeFileSync, readdirSync: memory.readdirSync, }; @@ -63,6 +64,8 @@ const mockedValidateEndpoint = vi.mocked(validateEndpointUrl); const { emitRunId, loadBlueprint, actionPlan, actionApply, actionStatus, actionRollback, main } = await import("./runner.js"); +const { readFileSync } = await import("node:fs"); +const mockedReadFileSync = vi.mocked(readFileSync); // ── Helpers ───────────────────────────────────────────────────── @@ -80,15 +83,14 @@ function seedBlueprintFile(bp?: Record): void { function mockCurrentPolicy(stdout: string): void { mockExeca.mockImplementation(async (_cmd: string, args: string[]) => { - if ( - args[0] === "policy" && - args[1] === "get" && - args[2] === "--base" && - args[3] === "test-sandbox" - ) { + if (args.join(" ") === "policy get -g test-gateway --base test-sandbox") { return { exitCode: 0, stdout, stderr: "" }; } - return { exitCode: 0, stdout: "", stderr: "" }; + return resultWithBlueprintPolicyAuthority(args, { + exitCode: 0, + stdout: "", + stderr: "", + }); }); } @@ -104,6 +106,7 @@ describe("runner", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); describe("emitRunId", () => { @@ -409,17 +412,15 @@ describe("runner", () => { expect(plan.dry_run).toBe(false); }); - it.each( - [ - "credential_env", - "credential_default", - "SECRET_KEY", - "default-secret-value", - "real-secret-value", - "future-token-value", - "future-authorization", - ], - )( + it.each([ + "credential_env", + "credential_default", + "SECRET_KEY", + "default-secret-value", + "real-secret-value", + "future-token-value", + "future-authorization", + ])( "does not expose credential field names or secret values in public plan output [%s]", async (leaked) => { captureStdout(); @@ -535,8 +536,13 @@ describe("runner", () => { describe("actionApply", () => { beforeEach(() => { captureStdout(); - // Default: all subprocess calls succeed - mockExeca.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + resultWithBlueprintPolicyAuthority(args, { + exitCode: 0, + stdout: "", + stderr: "", + }), + ); }); it("creates sandbox with correct arguments", async () => { @@ -655,12 +661,7 @@ describe("runner", () => { }, }); mockExeca.mockImplementation(async (_cmd: string, args: string[]) => { - if ( - args[0] === "policy" && - args[1] === "get" && - args[2] === "--base" && - args[3] === "test-sandbox" - ) { + if (args.join(" ") === "policy get -g test-gateway --base test-sandbox") { return { exitCode: 0, stdout: [ @@ -678,7 +679,11 @@ describe("runner", () => { stderr: "", }; } - return { exitCode: 0, stdout: "", stderr: "" }; + return resultWithBlueprintPolicyAuthority(args, { + exitCode: 0, + stdout: "", + stderr: "", + }); }); await actionApply("default", bp); @@ -688,6 +693,8 @@ describe("runner", () => { [ "policy", "set", + "-g", + "test-gateway", "--policy", expect.stringContaining("merged-policy.yaml"), "--wait", @@ -790,25 +797,27 @@ describe("runner", () => { expect(policySetCalls).toEqual([]); }); - it("skips policy commands when policy additions are empty", async () => { + it("skips policy mutation when policy additions are empty", async () => { await actionApply("default", minimalBlueprint()); const policyCalls = mockExeca.mock.calls.filter( (c) => Array.isArray(c[1]) && c[1][0] === "policy", ); - expect(policyCalls).toEqual([]); + expect(policyCalls.some((call) => call[1][1] === "set")).toBe(false); }); it("reuses sandbox when 'already exists' error", async () => { - mockExeca.mockResolvedValueOnce(failureResult("already exists")); - // Subsequent calls succeed - mockExeca.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + resultForCommandFailure(args, ["sandbox", "create"], "already exists"), + ); await actionApply("default", minimalBlueprint()); expect(stdoutText()).toContain("already exists, reusing"); }); it("throws when sandbox creation fails with other error", async () => { - mockExeca.mockResolvedValueOnce(failureResult("disk full")); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + resultForCommandFailure(args, ["sandbox", "create"], "disk full"), + ); await expect(actionApply("default", minimalBlueprint())).rejects.toThrow( /Failed to create sandbox.*disk full/, @@ -898,6 +907,7 @@ describe("runner", () => { "inference", "inference_provider_created_by_apply", "policy_additions", + "policy_authority", "profile", "run_id", "sandbox_created_by_apply", @@ -1276,16 +1286,46 @@ describe("runner", () => { addDir(`${RUNS_DIR}/nc-run-1`); actionStatus("nc-run-1"); - expect(stdoutText()).toContain('"status":"unknown"'); + expect(capturedJsonOutput()).toMatchObject({ + run_id: "nc-run-1", + status: "unknown", + receipt_error_kind: "missing", + recovery: expect.stringContaining("Do not reconstruct plan.json"), + }); }); - it("prints unknown status when plan.json is corrupt", () => { + it("reports recovery details when plan.json is corrupt", () => { addDir(`${RUNS_DIR}/nc-run-1`); addFile(`${RUNS_DIR}/nc-run-1/plan.json`, "{not valid json"); actionStatus("nc-run-1"); - expect(capturedJsonOutput()).toEqual({ run_id: "nc-run-1", status: "unknown" }); + expect(capturedJsonOutput()).toEqual({ + run_id: "nc-run-1", + status: "unknown", + receipt_error_kind: "corrupt", + receipt_error: expect.stringContaining("JSON"), + run_directory: `${RUNS_DIR}/nc-run-1`, + recovery: expect.stringContaining("trusted copy produced by this exact run"), + }); + expect(stdoutText()).not.toContain("Restore a complete plan.json"); + }); + + it("distinguishes an inaccessible plan receipt from a missing receipt", () => { + addDir(`${RUNS_DIR}/nc-run-1`); + addFile(`${RUNS_DIR}/nc-run-1/plan.json`, JSON.stringify({ run_id: "nc-run-1" })); + mockedReadFileSync.mockImplementationOnce(() => { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }); + + actionStatus("nc-run-1"); + + expect(capturedJsonOutput()).toMatchObject({ + run_id: "nc-run-1", + status: "unknown", + receipt_error_kind: "inaccessible", + recovery: expect.stringContaining("stop and ask a NemoClaw maintainer"), + }); }); // ── Path traversal rejection ────────────────────────────────── @@ -1377,7 +1417,13 @@ describe("runner", () => { describe("main (CLI)", () => { beforeEach(() => { captureStdout(); - mockExeca.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + mockExeca.mockImplementation(async (_cmd: string, args: string[]) => + resultWithBlueprintPolicyAuthority(args, { + exitCode: 0, + stdout: "", + stderr: "", + }), + ); seedBlueprintFile(); }); diff --git a/nemoclaw/src/blueprint/runner.ts b/nemoclaw/src/blueprint/runner.ts index ec9e53170df..eea1dd75cd0 100644 --- a/nemoclaw/src/blueprint/runner.ts +++ b/nemoclaw/src/blueprint/runner.ts @@ -13,7 +13,14 @@ */ import { randomUUID } from "node:crypto"; -import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { join, sep } from "node:path"; @@ -53,17 +60,23 @@ const sourceOrGeneratedOpenShellPolicyBoundary = importedOpenShellPolicyBoundary as typeof importedOpenShellPolicyBoundary & { default?: typeof importedOpenShellPolicyBoundary; }; -const { parseOpenShellPolicy, withoutProviderComposedPolicies } = - sourceOrGeneratedOpenShellPolicyBoundary.default ?? sourceOrGeneratedOpenShellPolicyBoundary; +const { + assertExternalPolicyRequirementContainment, + assertMatchingPolicyAuthority, + assertPolicyRequirementContainment, + parseOpenShellPolicy, + parseSandboxPolicyAuthorityMetadata, + withoutProviderComposedPolicies, +} = sourceOrGeneratedOpenShellPolicyBoundary.default ?? sourceOrGeneratedOpenShellPolicyBoundary; // sourceOfTruth: nemoclaw/src/shared/sandbox-name.cts const sourceOrGeneratedSandboxName = importedSandboxName as typeof importedSandboxName & { default?: typeof importedSandboxName; }; -const { assertValidName, assertValidProviderName } = +const { assertValidName, assertValidProviderName, isValidName } = sourceOrGeneratedSandboxName.default ?? sourceOrGeneratedSandboxName; -type Action = "plan" | "apply" | "status" | "rollback"; +type Action = "plan" | "apply" | "status" | "reconcile" | "rollback"; type RollbackPlanSource = { sandbox_name?: unknown; @@ -71,6 +84,11 @@ type RollbackPlanSource = { inference_provider_created_by_apply?: unknown; inference?: unknown; identity?: unknown; + policy_authority?: unknown; + policy_transition?: unknown; +}; +type ReconciliationPlanSource = RollbackPlanSource & { + policy_additions?: unknown; }; type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"; type RestProtocol = "rest"; @@ -101,6 +119,32 @@ interface PolicyAddition { type PolicyAdditions = { [name: string]: PolicyAddition }; +type BlueprintPolicyAuthorityInspection = + import("../shared/openshell-policy-boundary.cjs").SandboxPolicyAuthorityInspection; + +type BlueprintPolicyAuthorityReceipt = { + authority: BlueprintPolicyAuthorityInspection["authority"]; + gateway: string; + scope: "global" | "sandbox"; + sandbox_name?: string; +}; + +type BlueprintPolicyTransitionReceipt = { + status: "pending" | "incomplete" | "complete"; + sandbox_name: string; + gateway: string; + expected_authority: "nemoclaw-managed"; + policy_addition_names: string[]; +}; + +type StatusPolicyTransition = BlueprintPolicyTransitionReceipt & { + reconciliation_required: boolean; + reconciliation_action?: string; +}; + +const POLICY_TRANSITION_RECONCILIATION_ACTION = + "Run reconcile with this run ID before retrying apply or rollback."; + const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]); const REST_PROTOCOLS = new Set(["rest"]); const ENDPOINT_ENFORCEMENT_MODES = new Set(["enforce", "audit"]); @@ -110,6 +154,8 @@ const MISSING_SANDBOX_INSPECTION_PATTERN = /(?:\bsandbox\b[^\r\n]*\b(?:not found|does not exist)\b|\b(?:not found|does not exist)\b[^\r\n]*\bsandbox\b)/i; const MISSING_PROVIDER_INSPECTION_PATTERN = /(?:\bprovider\b[^\r\n]*\b(?:not found|does not exist)\b|\b(?:not found|does not exist)\b[^\r\n]*\bprovider\b|\bunknown provider\b)/i; +const POLICY_AUTHORITY_MAX_BYTES = 1024 * 1024; +const POLICY_AUTHORITY_TIMEOUT_MS = 30_000; interface InferenceRouteBinding { provider: string; @@ -168,7 +214,13 @@ function isUnconfiguredInferenceRoute(output: string): boolean { } function isAction(value: string | undefined): value is Action { - return value === "plan" || value === "apply" || value === "status" || value === "rollback"; + return ( + value === "plan" || + value === "apply" || + value === "status" || + value === "reconcile" || + value === "rollback" + ); } // Redact credential-shaped output before bounding OpenShell stderr to a compact, @@ -538,14 +590,25 @@ export function loadBlueprint(): Blueprint { async function runCmd( args: string[], - options?: { reject?: boolean }, + options?: { + maxBuffer?: number; + omitSandboxPolicy?: boolean; + reject?: boolean; + timeout?: number; + }, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const env = buildSubprocessEnv(); + if (options?.omitSandboxPolicy) { + delete env.OPENSHELL_SANDBOX_POLICY; + } const result = await execa(args[0], args.slice(1), { reject: options?.reject ?? true, stdout: "pipe", stderr: "pipe", - env: buildSubprocessEnv(), + env, extendEnv: false, + ...(options?.maxBuffer !== undefined ? { maxBuffer: options.maxBuffer } : {}), + ...(options?.timeout !== undefined ? { timeout: options.timeout } : {}), }); return { exitCode: result.exitCode ?? 1, @@ -554,6 +617,159 @@ async function runCmd( }; } +async function inspectActiveGatewayIdentity(): Promise { + const result = await runCmd(["openshell", "status"], { reject: false }); + const output = `${result.stderr}\n${result.stdout}`; + if (result.exitCode !== 0) { + throw new Error( + `Failed to inspect the active OpenShell gateway: ${boundedCommandError(output)}`, + ); + } + const lines = output.replace(/\u001b\[[0-9;]*m/g, "").split(/\r?\n/); + const gateways = lines + .map((line) => /^\s*Gateway:\s*(.+?)\s*$/i.exec(line)?.[1]?.trim()) + .filter((gateway): gateway is string => Boolean(gateway)); + const connected = lines.some((line) => /^\s*Status:\s*Connected\b/i.test(line)); + if (!connected || gateways.length !== 1) { + throw new Error( + `Failed to prove the active OpenShell gateway identity: ${boundedCommandError(output)}`, + ); + } + return assertValidName(gateways[0], "OpenShell gateway name"); +} + +async function runBlueprintPolicyAuthorityCommand( + command: string[], + subject: "global" | "sandbox", +): Promise>> { + let result: Awaited>; + try { + result = await runCmd(command, { + maxBuffer: POLICY_AUTHORITY_MAX_BYTES, + reject: false, + timeout: POLICY_AUTHORITY_TIMEOUT_MS, + }); + } catch { + throw new Error( + `OpenShell ${subject} policy authority inspection failed. Policy-dependent operations must stop.`, + ); + } + if ( + result.exitCode !== 0 || + Buffer.byteLength(result.stdout, "utf8") + Buffer.byteLength(result.stderr, "utf8") > + POLICY_AUTHORITY_MAX_BYTES + ) { + throw new Error( + `OpenShell ${subject} policy authority inspection failed. Policy-dependent operations must stop.`, + ); + } + return result; +} + +async function inspectBlueprintPolicyAuthority( + gateway: string, + sandboxName?: string, +): Promise { + const subject = sandboxName === undefined ? "global" : "sandbox"; + if (sandboxName === undefined) { + const history = await runBlueprintPolicyAuthorityCommand( + ["openshell", "policy", "list", "-g", gateway, "--global", "--limit", "1"], + subject, + ); + if (history.stdout.trim().length === 0) { + return { authority: "nemoclaw-managed", effectivePolicy: {} }; + } + } + const command = + sandboxName === undefined + ? ["openshell", "policy", "get", "-g", gateway, "--global", "--full", "--output", "json"] + : ["openshell", "policy", "get", "-g", gateway, "--full", "--output", "json", sandboxName]; + const result = await runBlueprintPolicyAuthorityCommand(command, subject); + if (sandboxName === undefined) { + if (result.stdout.trim().length === 0) { + throw new Error( + "OpenShell returned empty global policy authority metadata. Policy-dependent operations must stop.", + ); + } + let metadata: unknown; + try { + metadata = JSON.parse(result.stdout); + } catch { + throw new Error( + "OpenShell returned malformed global policy authority metadata. Policy-dependent operations must stop.", + ); + } + if ( + !isPlainObject(metadata) || + metadata.scope !== "global" || + (metadata.status !== "loaded" && metadata.status !== "superseded") || + (metadata.policy_source !== undefined && metadata.policy_source !== "global") || + Object.hasOwn(metadata, "sandbox") + ) { + throw new Error( + "OpenShell returned invalid global policy authority metadata. Policy-dependent operations must stop.", + ); + } + if (metadata.status === "superseded") { + return { authority: "nemoclaw-managed", effectivePolicy: {} }; + } + if (!isPlainObject(metadata.policy)) { + throw new Error( + "OpenShell returned invalid global policy authority metadata. Policy-dependent operations must stop.", + ); + } + return { authority: "externally-managed", effectivePolicy: metadata.policy }; + } + try { + return parseSandboxPolicyAuthorityMetadata(result.stdout, sandboxName); + } catch (error) { + const detail = error instanceof Error ? error.message : "OpenShell returned invalid metadata"; + throw new Error(`${detail}. Policy-dependent operations must stop.`); + } +} + +function assertBlueprintPolicyAuthorityMatches( + recorded: BlueprintPolicyAuthorityInspection, + observed: BlueprintPolicyAuthorityInspection, +): void { + try { + assertMatchingPolicyAuthority(recorded.authority, observed.authority); + } catch (error) { + const detail = error instanceof Error ? error.message : "policy authority is invalid"; + throw new Error(`Refusing to apply blueprint policy additions because ${detail}.`); + } +} + +function assertBlueprintExternalPolicyRequirements( + inspection: BlueprintPolicyAuthorityInspection, + additions: PolicyAdditions, +): void { + try { + assertExternalPolicyRequirementContainment(inspection, { + network_policies: additions, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "the policy requirement is invalid"; + throw new Error( + `Refusing to apply the blueprint: ${detail}. Ask the external policy authority to supply the exact required entries.`, + ); + } +} + +function assertBlueprintPolicyRequirements( + inspection: BlueprintPolicyAuthorityInspection, + additions: PolicyAdditions, +): void { + try { + assertPolicyRequirementContainment(inspection, { + network_policies: additions, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : "the policy requirement is invalid"; + throw new Error(`Cannot reconcile the blueprint policy transition: ${detail}.`); + } +} + async function runRuntimeIdentityCommand( args: string[], options?: RuntimeIdentityCommandOptions, @@ -694,6 +910,8 @@ interface PersistedRunPlan { sandbox_created_by_apply: boolean; inference_provider_created_by_apply: boolean; policy_additions: PolicyAdditions; + policy_authority: BlueprintPolicyAuthorityReceipt; + policy_transition?: BlueprintPolicyTransitionReceipt; inference: SafeInferencePlan; identity?: RuntimeIdentityReceipt; timestamp: string; @@ -711,6 +929,8 @@ type StatusRunPlan = { sandbox_created_by_apply?: boolean; inference_provider_created_by_apply?: boolean; policy_additions?: PolicyAdditions; + policy_authority?: BlueprintPolicyAuthorityReceipt; + policy_transition?: StatusPolicyTransition; inference?: SafeInferencePlan; identity?: RuntimeIdentityReceipt; router?: { @@ -726,6 +946,42 @@ function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function isBlueprintPolicyAuthorityReceipt( + value: unknown, +): value is BlueprintPolicyAuthorityReceipt { + if (!isPlainObject(value)) return false; + if ( + (value.authority !== "nemoclaw-managed" && value.authority !== "externally-managed") || + (value.scope !== "global" && value.scope !== "sandbox") || + !isValidName(value.gateway) + ) { + return false; + } + return value.scope === "global" + ? value.sandbox_name === undefined + : isValidName(value.sandbox_name); +} + +function isBlueprintPolicyTransitionReceipt( + value: unknown, +): value is BlueprintPolicyTransitionReceipt { + if (!isPlainObject(value)) return false; + if ( + (value.status !== "pending" && value.status !== "incomplete" && value.status !== "complete") || + !isValidName(value.sandbox_name) || + !isValidName(value.gateway) || + value.expected_authority !== "nemoclaw-managed" || + !Array.isArray(value.policy_addition_names) || + value.policy_addition_names.length === 0 || + !value.policy_addition_names.every( + (name): name is string => typeof name === "string" && name.length > 0, + ) + ) { + return false; + } + return new Set(value.policy_addition_names).size === value.policy_addition_names.length; +} + function buildSafeInferencePlan(source: InferenceProfile | UnknownRecord): SafeInferencePlan { return { provider_type: optionalString(source.provider_type), @@ -778,6 +1034,8 @@ function buildPersistedRunPlan(args: { sandboxCreatedByApply: boolean; inferenceProviderCreatedByApply: boolean; policyAdditions: PolicyAdditions; + policyAuthorityReceipt: BlueprintPolicyAuthorityReceipt; + policyTransition?: BlueprintPolicyTransitionReceipt; inferenceCfg: InferenceProfile; runtimeIdentityReceipt?: RuntimeIdentityReceipt; timestamp: string; @@ -789,15 +1047,25 @@ function buildPersistedRunPlan(args: { sandbox_created_by_apply: args.sandboxCreatedByApply, inference_provider_created_by_apply: args.inferenceProviderCreatedByApply, policy_additions: args.policyAdditions, + policy_authority: args.policyAuthorityReceipt, inference: buildSafeInferencePlan(args.inferenceCfg), timestamp: args.timestamp, }; if (args.runtimeIdentityReceipt) { plan.identity = args.runtimeIdentityReceipt; } + if (args.policyTransition) { + plan.policy_transition = args.policyTransition; + } return plan; } +function persistRunReceipt(planFile: string, plan: unknown): void { + const pendingFile = `${planFile}.pending`; + writeFileSync(pendingFile, JSON.stringify(plan, null, 2), { encoding: "utf-8", mode: 0o600 }); + renameSync(pendingFile, planFile); +} + function buildStatusRunPlan(source: unknown, fallbackRunId: string): StatusRunPlan | null { if (!isPlainObject(source)) { return null; @@ -847,6 +1115,25 @@ function buildStatusRunPlan(source: unknown, fallbackRunId: string): StatusRunPl if (isPolicyAdditions(source.policy_additions)) { safePlan.policy_additions = source.policy_additions; } + if (isBlueprintPolicyAuthorityReceipt(source.policy_authority)) { + safePlan.policy_authority = source.policy_authority; + } + if ( + source.policy_transition !== undefined && + !isBlueprintPolicyTransitionReceipt(source.policy_transition) + ) { + return null; + } + if (isBlueprintPolicyTransitionReceipt(source.policy_transition)) { + const reconciliationRequired = source.policy_transition.status !== "complete"; + safePlan.policy_transition = { + ...source.policy_transition, + reconciliation_required: reconciliationRequired, + ...(reconciliationRequired + ? { reconciliation_action: POLICY_TRANSITION_RECONCILIATION_ACTION } + : {}), + }; + } if (isPlainObject(source.inference)) { safePlan.inference = buildSafeInferencePlan(source.inference); @@ -948,7 +1235,9 @@ export async function actionApply( const sandboxName = sandboxCfg.name ?? "openclaw"; const sandboxImage = sandboxCfg.image ?? "openclaw"; const forwardPorts = sandboxCfg.forward_ports ?? [DASHBOARD_PORT]; - const policyAdditions = blueprint.components?.policy?.additions ?? {}; + const policyAdditions = withoutProviderComposedPolicies( + blueprint.components?.policy?.additions ?? {}, + ); const runtimeIdentityConfig = blueprint.components?.identity; const providerName = inferenceCfg.provider_name ?? "default"; const providerType = inferenceCfg.provider_type ?? "openai"; @@ -960,36 +1249,45 @@ export async function actionApply( if (credentialEnv) { credential = process.env[credentialEnv] ?? credentialDefault; } + const policyGateway = await inspectActiveGatewayIdentity(); + const initialPolicyAuthority = await inspectBlueprintPolicyAuthority(policyGateway); const stateDir = join(homedir(), ".nemoclaw", "state", "runs", rid); mkdirSync(stateDir, { recursive: true }); let runtimeIdentityReceipt: RuntimeIdentityReceipt | undefined; + let policyAuthorityReceipt: BlueprintPolicyAuthorityReceipt = { + authority: initialPolicyAuthority.authority, + gateway: policyGateway, + scope: "global", + }; + let sandboxPolicyAuthority: BlueprintPolicyAuthorityInspection | null = null; + let policyTransition: BlueprintPolicyTransitionReceipt | undefined; let sandboxCreatedByApply = false; let inferenceProviderCreatedByApply = false; const persistRunPlan = (): void => { - writeFileSync( + persistRunReceipt( join(stateDir, "plan.json"), - JSON.stringify( - buildPersistedRunPlan({ - runId: rid, - profile, - sandboxName, - sandboxCreatedByApply, - inferenceProviderCreatedByApply, - policyAdditions, - inferenceCfg, - runtimeIdentityReceipt, - timestamp: new Date().toISOString(), - }), - null, - 2, - ), + buildPersistedRunPlan({ + runId: rid, + profile, + sandboxName, + sandboxCreatedByApply, + inferenceProviderCreatedByApply, + policyAdditions, + policyAuthorityReceipt, + policyTransition, + inferenceCfg, + runtimeIdentityReceipt, + timestamp: new Date().toISOString(), + }), ); }; const identityDeps = runtimeIdentityDeps((receipt) => { runtimeIdentityReceipt = receipt; persistRunPlan(); }, options?.runtimeIdentityProfilePolicy); + persistRunPlan(); + assertBlueprintExternalPolicyRequirements(initialPolicyAuthority, policyAdditions); try { let reuseExistingSandbox = false; @@ -1075,7 +1373,10 @@ export async function actionApply( createArgs.push("--forward", String(port)); } - const createResult = await runCmd(createArgs, { reject: false }); + const createResult = await runCmd(createArgs, { + omitSandboxPolicy: initialPolicyAuthority.authority === "externally-managed", + reject: false, + }); sandboxCreatedByApply = createResult.exitCode === 0; if (sandboxCreatedByApply) { // Persist ownership immediately so a later-process rollback stays safe @@ -1102,6 +1403,25 @@ export async function actionApply( } } + { + const observedPolicyAuthority = await inspectBlueprintPolicyAuthority( + policyGateway, + sandboxName, + ); + if (sandboxCreatedByApply) { + assertBlueprintPolicyAuthorityMatches(initialPolicyAuthority, observedPolicyAuthority); + } + sandboxPolicyAuthority = observedPolicyAuthority; + policyAuthorityReceipt = { + authority: observedPolicyAuthority.authority, + gateway: policyGateway, + scope: "sandbox", + sandbox_name: sandboxName, + }; + persistRunPlan(); + assertBlueprintExternalPolicyRequirements(observedPolicyAuthority, policyAdditions); + } + // Keep runtime credentials unattached until OpenShell accepts the // sandbox's requested inference route. progress(50, "Configuring inference provider"); @@ -1222,32 +1542,81 @@ export async function actionApply( } if (Object.keys(policyAdditions).length > 0) { - progress(78, "Applying policy additions"); - const currentPolicy = await runCmd(["openshell", "policy", "get", "--base", sandboxName], { - reject: false, - }); - if (currentPolicy.exitCode !== 0) { - throw new Error( - `Failed to read current policy before applying additions: ${currentPolicy.stderr}`, - ); + if (!sandboxPolicyAuthority) { + throw new Error("Sandbox policy authority is unavailable before applying additions."); } + const observedPolicyAuthority = await inspectBlueprintPolicyAuthority( + policyGateway, + sandboxName, + ); + assertBlueprintPolicyAuthorityMatches(sandboxPolicyAuthority, observedPolicyAuthority); + assertBlueprintExternalPolicyRequirements(observedPolicyAuthority, policyAdditions); + if (observedPolicyAuthority.authority === "nemoclaw-managed") { + progress(78, "Applying policy additions"); + const currentPolicy = await runCmd( + ["openshell", "policy", "get", "-g", policyGateway, "--base", sandboxName], + { reject: false }, + ); + if (currentPolicy.exitCode !== 0) { + throw new Error( + `Failed to read current policy before applying additions: ${boundedCommandError(currentPolicy.stderr)}`, + ); + } - const mergedPolicyFile = join(stateDir, "merged-policy.yaml"); - writeFileSync(mergedPolicyFile, mergePolicyAdditions(currentPolicy.stdout, policyAdditions), { - encoding: "utf-8", - mode: 0o600, - }); + const mergedPolicyFile = join(stateDir, "merged-policy.yaml"); + writeFileSync( + mergedPolicyFile, + mergePolicyAdditions(currentPolicy.stdout, policyAdditions), + { + encoding: "utf-8", + mode: 0o600, + }, + ); - const policySet = await runCmd( - ["openshell", "policy", "set", "--policy", mergedPolicyFile, "--wait", sandboxName], - { reject: false }, - ); - if (policySet.exitCode !== 0) { - throw new Error(`Failed to apply policy additions: ${policySet.stderr}`); + const beforeMutation = await inspectBlueprintPolicyAuthority(policyGateway, sandboxName); + assertBlueprintPolicyAuthorityMatches(sandboxPolicyAuthority, beforeMutation); + policyTransition = { + status: "pending", + sandbox_name: sandboxName, + gateway: policyGateway, + expected_authority: "nemoclaw-managed", + policy_addition_names: Object.keys(policyAdditions).sort(), + }; + persistRunPlan(); + const policySet = await runCmd( + [ + "openshell", + "policy", + "set", + "-g", + policyGateway, + "--policy", + mergedPolicyFile, + "--wait", + sandboxName, + ], + { reject: false }, + ); + if (policySet.exitCode !== 0) { + throw new Error( + `Failed to apply policy additions: ${boundedCommandError(policySet.stderr)}`, + ); + } + policyTransition = { ...policyTransition, status: "incomplete" }; + persistRunPlan(); } } progress(85, "Saving run state"); + if (!sandboxPolicyAuthority) { + throw new Error("Sandbox policy authority is unavailable before saving run state."); + } + const finalPolicyAuthority = await inspectBlueprintPolicyAuthority(policyGateway, sandboxName); + assertBlueprintPolicyAuthorityMatches(sandboxPolicyAuthority, finalPolicyAuthority); + assertBlueprintExternalPolicyRequirements(finalPolicyAuthority, policyAdditions); + if (policyTransition) { + policyTransition = { ...policyTransition, status: "complete" }; + } persistRunPlan(); progress(100, "Apply complete"); @@ -1277,6 +1646,9 @@ export async function actionApply( }); if (remove.exitCode === 0 || MISSING_SANDBOX_PATTERN.test(remove.stderr)) { sandboxCreatedByApply = false; + if (policyTransition) { + policyTransition = { ...policyTransition, status: "complete" }; + } persistRunPlan(); } else { cleanupFailures.push( @@ -1349,17 +1721,128 @@ export function actionStatus(rid?: string): void { } const name = runDir.split("/").pop() ?? "unknown"; + const planFile = join(runDir, "plan.json"); + const unknownStatus = ( + receiptErrorKind: "corrupt" | "inaccessible" | "invalid" | "missing", + error: unknown, + ): void => { + const detail = boundedCommandError(error instanceof Error ? error.message : String(error)); + log( + JSON.stringify( + { + run_id: name, + status: "unknown", + receipt_error_kind: receiptErrorKind, + receipt_error: detail, + run_directory: runDir, + recovery: + "Do not reconstruct plan.json. Reconcile and rollback remain disabled. Recover the original receipt from a trusted copy produced by this exact run, then ask a NemoClaw maintainer to validate its run ID, sandbox ownership, provider ownership, and policy transition before using it. If no trusted copy exists, stop and ask a NemoClaw maintainer for recovery direction.", + }, + null, + 2, + ), + ); + }; + + if (!existsSync(planFile)) { + unknownStatus("missing", new Error("plan.json is missing")); + return; + } + let planData: string; try { - const planData = readFileSync(join(runDir, "plan.json"), "utf-8"); - const parsedPlan: unknown = JSON.parse(planData); - const safePlan = buildStatusRunPlan(parsedPlan, name); - if (!safePlan) { + planData = readFileSync(planFile, "utf-8"); + } catch (error) { + const code = isPlainObject(error) && typeof error.code === "string" ? error.code : undefined; + unknownStatus(code === "ENOENT" ? "missing" : "inaccessible", error); + return; + } + + let parsedPlan: unknown; + try { + parsedPlan = JSON.parse(planData); + } catch (error) { + unknownStatus("corrupt", error); + return; + } + const safePlan = buildStatusRunPlan(parsedPlan, name); + if (!safePlan) { + unknownStatus("invalid", new Error("plan.json must contain a valid run receipt")); + return; + } + log(JSON.stringify(safePlan, null, 2)); +} + +export async function actionReconcile(rid: string): Promise { + emitRunId(); + + const runsDir = join(homedir(), ".nemoclaw", "state", "runs"); + const stateDir = safeRunDir(runsDir, rid); + try { + readdirSync(stateDir); + } catch { + throw new Error(`Run ${rid} not found.`); + } + + const planFile = join(stateDir, "plan.json"); + let plan: ReconciliationPlanSource; + let transition: BlueprintPolicyTransitionReceipt; + let additions: PolicyAdditions; + try { + const parsedPlan: unknown = JSON.parse(readFileSync(planFile, "utf-8")); + if (!isPlainObject(parsedPlan)) { throw new Error("plan.json must contain a JSON object"); } - log(JSON.stringify(safePlan, null, 2)); - } catch { - log(JSON.stringify({ run_id: name, status: "unknown" })); + plan = parsedPlan; + if (plan.sandbox_created_by_apply !== false) { + throw new Error("policy reconciliation requires a reused sandbox"); + } + const sandboxName = readRollbackSandboxName(plan); + if (!isBlueprintPolicyTransitionReceipt(plan.policy_transition)) { + throw new Error("policy transition receipt is invalid"); + } + transition = plan.policy_transition; + if (transition.sandbox_name !== sandboxName) { + throw new Error("policy transition sandbox does not match the run plan"); + } + if (!isPolicyAdditions(plan.policy_additions)) { + throw new Error("policy additions are invalid"); + } + additions = withoutProviderComposedPolicies(plan.policy_additions); + const additionNames = Object.keys(additions).sort(); + if ( + additionNames.length === 0 || + additionNames.length !== transition.policy_addition_names.length || + additionNames.some((name, index) => name !== transition.policy_addition_names[index]) + ) { + throw new Error("policy transition additions do not match the run plan"); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot read reconciliation plan for run ${rid}: ${detail}`); + } + + if (transition.status === "complete") { + log(`Policy transition for run ${rid} is already complete.`); + return; + } + + const observed = await inspectBlueprintPolicyAuthority( + transition.gateway, + transition.sandbox_name, + ); + try { + assertMatchingPolicyAuthority(transition.expected_authority, observed.authority); + } catch (error) { + const detail = error instanceof Error ? error.message : "policy authority is invalid"; + throw new Error(`Cannot reconcile the blueprint policy transition: ${detail}.`); } + assertBlueprintPolicyRequirements(observed, additions); + + persistRunReceipt(planFile, { + ...plan, + policy_transition: { ...transition, status: "complete" }, + }); + log(`Policy transition for run ${rid} is complete.`); } export async function actionRollback(rid: string): Promise { @@ -1379,6 +1862,7 @@ export async function actionRollback(rid: string): Promise { let inferenceProviderCreatedByApply = false; let inferenceProviderName: string | undefined; let runtimeIdentityReceipt: RuntimeIdentityReceipt | undefined; + let policyTransition: BlueprintPolicyTransitionReceipt | undefined; try { const planData = readFileSync(planFile, "utf-8"); const parsedPlan: unknown = JSON.parse(planData); @@ -1398,11 +1882,23 @@ export async function actionRollback(rid: string): Promise { } runtimeIdentityReceipt = rollbackPlan.identity; } + if (rollbackPlan?.policy_transition !== undefined) { + if (!isBlueprintPolicyTransitionReceipt(rollbackPlan.policy_transition)) { + throw new Error("policy transition receipt is invalid"); + } + policyTransition = rollbackPlan.policy_transition; + } } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Cannot read rollback plan for run ${rid}: ${detail}`); } + if (!sandboxCreatedByApply && policyTransition && policyTransition.status !== "complete") { + throw new Error( + `Cannot roll back run ${rid}: the policy transition for reused sandbox ${JSON.stringify(policyTransition.sandbox_name)} through gateway ${JSON.stringify(policyTransition.gateway)} is ${policyTransition.status}. ${POLICY_TRANSITION_RECONCILIATION_ACTION}`, + ); + } + if (runtimeIdentityReceipt) { progress(20, `Removing runtime identity provider ${runtimeIdentityReceipt.provider_name}`); await removeRuntimeIdentity(runtimeIdentityReceipt, sandboxName, runtimeIdentityCommandDeps()); @@ -1481,7 +1977,7 @@ export async function main( return; } throw new Error( - `Unknown action '${rawAction ?? "(missing)"}'. Use: plan, apply, status, rollback, snapshots`, + `Unknown action '${rawAction ?? "(missing)"}'. Use: plan, apply, status, reconcile, rollback, snapshots`, ); } @@ -1523,6 +2019,12 @@ export async function main( case "status": actionStatus(runId); break; + case "reconcile": + if (!runId) { + throw new Error("--run-id is required for reconcile"); + } + await actionReconcile(runId); + break; case "rollback": if (!runId) { throw new Error("--run-id is required for rollback"); diff --git a/nemoclaw/src/shared/openshell-policy-boundary.cts b/nemoclaw/src/shared/openshell-policy-boundary.cts index eb2f99e9e88..3b49f2f6cd5 100644 --- a/nemoclaw/src/shared/openshell-policy-boundary.cts +++ b/nemoclaw/src/shared/openshell-policy-boundary.cts @@ -15,6 +15,13 @@ export interface ParsedOpenShellPolicy { readonly policy: ValidatedOpenShellPolicyMapping; } +export type OpenShellPolicyAuthority = "nemoclaw-managed" | "externally-managed"; + +export interface SandboxPolicyAuthorityInspection { + readonly authority: OpenShellPolicyAuthority; + readonly effectivePolicy: OpenShellPolicyMapping; +} + const MISSING_POLICY_DOCUMENT = "Current policy from openshell policy get --base does not contain a policy YAML document"; @@ -22,14 +29,189 @@ function isMapping(value: unknown): value is OpenShellPolicyMapping { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isPolicyAuthority(value: unknown): value is OpenShellPolicyAuthority { + return value === "nemoclaw-managed" || value === "externally-managed"; +} + +function parseJsonMapping(source: string, invalidMessage: string): OpenShellPolicyMapping { + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + throw new Error(invalidMessage); + } + if (!isMapping(parsed)) { + throw new Error(invalidMessage); + } + return parsed; +} + +/** Parse machine-readable effective policy metadata for one sandbox. */ +export function parseSandboxPolicyAuthorityMetadata( + raw: string, + sandboxName: string, +): SandboxPolicyAuthorityInspection { + if (raw.trim().length === 0) { + throw new Error("OpenShell returned empty sandbox policy authority metadata"); + } + const metadata = parseJsonMapping( + raw, + "OpenShell returned malformed sandbox policy authority metadata", + ); + if ( + metadata.scope !== "sandbox" || + metadata.sandbox !== sandboxName || + metadata.status !== "effective" || + (metadata.policy_source !== "sandbox" && metadata.policy_source !== "global") || + !isMapping(metadata.policy) + ) { + throw new Error("OpenShell returned invalid sandbox policy authority metadata"); + } + return { + authority: metadata.policy_source === "sandbox" ? "nemoclaw-managed" : "externally-managed", + effectivePolicy: metadata.policy, + }; +} + +/** Require durable and observed policy authority to describe the same owner. */ +export function assertMatchingPolicyAuthority(recorded: unknown, observed: unknown): void { + if (!isPolicyAuthority(recorded)) { + throw new Error("the recorded policy authority is unavailable or invalid"); + } + if (!isPolicyAuthority(observed)) { + throw new Error("the observed OpenShell policy authority is unavailable or invalid"); + } + if (recorded !== observed) { + throw new Error(`OpenShell policy authority changed from ${recorded} to ${observed}`); + } +} + +function policyMapping(value: unknown, invalidMessage: string): OpenShellPolicyMapping { + if (!isMapping(value)) throw new Error(invalidMessage); + return value; +} + +function policyValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => policyValuesEqual(value, right[index])) + ); + } + if (!isMapping(left) || !isMapping(right)) return false; + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => + key === rightKeys[index] && + Object.hasOwn(right, key) && + policyValuesEqual(left[key], right[key]), + ) + ); +} + +function formatPolicyKeys(keys: readonly string[]): string { + return keys.map((key) => JSON.stringify(key)).join(", "); +} + +function assertPolicyRequirementContainmentForOwner( + inspection: SandboxPolicyAuthorityInspection, + requiredPolicy: OpenShellPolicyMapping, + owner: string, +): void { + if (!isPolicyAuthority(inspection.authority)) { + throw new Error("the observed OpenShell policy authority is invalid"); + } + const effectivePolicy = policyMapping( + inspection.effectivePolicy, + "the observed effective policy is invalid", + ); + const required = policyMapping(requiredPolicy, "the required policy input is invalid"); + const requiredNetwork = + required.network_policies === undefined + ? {} + : policyMapping(required.network_policies, "the required network policy input is invalid"); + const observedNetwork = isMapping(effectivePolicy.network_policies) + ? effectivePolicy.network_policies + : null; + const missing: string[] = []; + const drifted: string[] = []; + for (const key of Object.keys(requiredNetwork).sort()) { + if (!observedNetwork || !Object.hasOwn(observedNetwork, key)) { + missing.push(key); + } else if (!policyValuesEqual(observedNetwork[key], requiredNetwork[key])) { + drifted.push(key); + } + } + const requiredSections = Object.keys(required) + .filter((key) => key !== "network_policies" && key !== "version") + .sort(); + const missingSections: string[] = []; + const driftedSections: string[] = []; + for (const key of requiredSections) { + if (!Object.hasOwn(effectivePolicy, key)) { + missingSections.push(key); + } else if (!policyValuesEqual(effectivePolicy[key], required[key])) { + driftedSections.push(key); + } + } + if ( + missing.length === 0 && + drifted.length === 0 && + missingSections.length === 0 && + driftedSections.length === 0 + ) { + return; + } + const differences = [ + ...(missing.length > 0 ? [`missing entries ${formatPolicyKeys(missing)}`] : []), + ...(drifted.length > 0 ? [`drifted entries ${formatPolicyKeys(drifted)}`] : []), + ...(missingSections.length > 0 + ? [`missing sections ${formatPolicyKeys(missingSections)}`] + : []), + ...(driftedSections.length > 0 + ? [`drifted sections ${formatPolicyKeys(driftedSections)}`] + : []), + ].join("; "); + throw new Error(`the ${owner} has ${differences}`); +} + +/** Require a policy to contain the requested entries and sections. */ +export function assertPolicyRequirementContainment( + inspection: SandboxPolicyAuthorityInspection, + requiredPolicy: OpenShellPolicyMapping, +): void { + assertPolicyRequirementContainmentForOwner(inspection, requiredPolicy, "observed policy"); +} + +/** + * Require an external policy to contain the requested entries and sections. + * Additional externally managed content is allowed. + */ +export function assertExternalPolicyRequirementContainment( + inspection: SandboxPolicyAuthorityInspection, + requiredPolicy: OpenShellPolicyMapping, +): void { + if (!isPolicyAuthority(inspection.authority)) { + throw new Error("the observed OpenShell policy authority is invalid"); + } + if (inspection.authority === "nemoclaw-managed") return; + assertPolicyRequirementContainmentForOwner( + inspection, + requiredPolicy, + "externally managed policy", + ); +} + function assertValidatedPolicyFields( policy: OpenShellPolicyMapping, ): asserts policy is ValidatedOpenShellPolicyMapping { if ( policy.version !== undefined && - (typeof policy.version !== "number" || - !Number.isInteger(policy.version) || - policy.version < 1) + (typeof policy.version !== "number" || !Number.isInteger(policy.version) || policy.version < 1) ) { throw new Error( "Current policy from openshell policy get --base version must be a positive integer", diff --git a/nemoclaw/src/shared/openshell-policy-boundary.test.ts b/nemoclaw/src/shared/openshell-policy-boundary.test.ts index 448bf306664..c4e69328ad8 100644 --- a/nemoclaw/src/shared/openshell-policy-boundary.test.ts +++ b/nemoclaw/src/shared/openshell-policy-boundary.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it } from "vitest"; import YAML from "yaml"; import { + assertExternalPolicyRequirementContainment, + assertMatchingPolicyAuthority, + assertPolicyRequirementContainment, parseOpenShellPolicy, + parseSandboxPolicyAuthorityMetadata, stripProviderComposedPolicies, withoutProviderComposedPolicies, } from "./openshell-policy-boundary.cjs"; @@ -81,6 +85,118 @@ const POLICY_CASES = [ }, ] as const; +describe("sandbox policy authority boundary", () => { + const policy = { version: 1, network_policies: { required: { allow: true } } }; + + it.each([ + ["sandbox", "nemoclaw-managed"], + ["global", "externally-managed"], + ] as const)("classifies the %s policy source as %s", (policySource, authority) => { + expect( + parseSandboxPolicyAuthorityMetadata( + JSON.stringify({ + scope: "sandbox", + sandbox: "alpha", + status: "effective", + policy_source: policySource, + policy, + }), + "alpha", + ), + ).toEqual({ authority, effectivePolicy: policy }); + }); + + it.each([ + ["empty", " \n\t", /empty sandbox policy authority metadata/u], + ["malformed", "{", /malformed sandbox policy authority metadata/u], + ["non-object", "[]", /malformed sandbox policy authority metadata/u], + [ + "mismatched", + JSON.stringify({ + scope: "sandbox", + sandbox: "beta", + status: "effective", + policy_source: "sandbox", + policy, + }), + /invalid sandbox policy authority metadata/u, + ], + ])("rejects %s sandbox authority metadata", (_caseName, raw, expected) => { + expect(() => parseSandboxPolicyAuthorityMetadata(raw, "alpha")).toThrow(expected); + }); + + it("accepts matching authority and rejects invalid or changed authority", () => { + expect(() => + assertMatchingPolicyAuthority("externally-managed", "externally-managed"), + ).not.toThrow(); + expect(() => assertMatchingPolicyAuthority(undefined, "externally-managed")).toThrow( + /recorded policy authority is unavailable/u, + ); + expect(() => assertMatchingPolicyAuthority("externally-managed", "unknown")).toThrow( + /observed OpenShell policy authority is unavailable/u, + ); + expect(() => assertMatchingPolicyAuthority("nemoclaw-managed", "externally-managed")).toThrow( + /changed from nemoclaw-managed to externally-managed/u, + ); + }); + + it("requires external entries and sections while allowing unrelated content", () => { + const inspection = { + authority: "externally-managed" as const, + effectivePolicy: { + version: 9, + filesystem_policy: { read_only: true }, + extra_section: { keep: true }, + network_policies: { required: { allow: true }, extra: { allow: true } }, + }, + }; + expect(() => + assertExternalPolicyRequirementContainment(inspection, { + version: 1, + filesystem_policy: { read_only: true }, + network_policies: { required: { allow: true } }, + }), + ).not.toThrow(); + expect(() => + assertExternalPolicyRequirementContainment(inspection, { + filesystem_policy: { read_only: false }, + process: { user: 1000 }, + network_policies: { required: { allow: false }, missing: {} }, + }), + ).toThrow( + /missing entries "missing"; drifted entries "required"; missing sections "process"; drifted sections "filesystem_policy"/u, + ); + expect(() => + assertExternalPolicyRequirementContainment( + { authority: "unknown" as never, effectivePolicy: {} }, + {}, + ), + ).toThrow(/observed OpenShell policy authority is invalid/u); + expect(() => + assertExternalPolicyRequirementContainment(inspection, { + network_policies: [] as never, + }), + ).toThrow(/required network policy input is invalid/u); + }); + + it("requires recorded entries in a NemoClaw-managed policy", () => { + const inspection = { + authority: "nemoclaw-managed" as const, + effectivePolicy: { network_policies: { required: { allow: true } } }, + }; + expect(() => + assertPolicyRequirementContainment(inspection, { + network_policies: { required: { allow: true } }, + }), + ).not.toThrow(); + expect(() => + assertPolicyRequirementContainment(inspection, { + network_policies: { missing: { allow: true } }, + }), + ).toThrow(/missing entries "missing"/u); + }); +}); + describe("canonical OpenShell policy boundary", () => { it("parses marked output and versionless network policies", () => { const body = "version: 1\nnetwork_policies:\n safe: {}"; diff --git a/scripts/checks/openshell-policy-mutation-read.mts b/scripts/checks/openshell-policy-mutation-read.mts index 2a5309f3247..8a9d3bc3337 100644 --- a/scripts/checks/openshell-policy-mutation-read.mts +++ b/scripts/checks/openshell-policy-mutation-read.mts @@ -52,6 +52,10 @@ function unclassifiedBase(site: string): DiscoveredPolicyRead { return { site, view: "base", failureHandling: "unclassified" }; } +function unclassifiedFull(site: string): DiscoveredPolicyRead { + return { site, view: "full", failureHandling: "unclassified" }; +} + function ignoredFull(site: string): DiscoveredPolicyRead { return { site, view: "full", failureHandling: "ignore-error" }; } @@ -75,7 +79,11 @@ export const MUTATION_READS: readonly AuditedPolicyReadFile[] = [ }, { relativePath: "nemoclaw/src/blueprint/runner.ts", - expectedReads: [unclassifiedBase("actionApply")], + expectedReads: [ + unclassifiedBase("actionApply"), + unclassifiedFull("inspectBlueprintPolicyAuthority"), + unclassifiedFull("inspectBlueprintPolicyAuthority"), + ], }, { relativePath: "src/lib/shields/index.ts", @@ -88,6 +96,10 @@ export const MUTATION_READS: readonly AuditedPolicyReadFile[] = [ ]; const NON_MUTATION_POLICY_READS: readonly AuditedPolicyReadFile[] = [ + { + relativePath: "src/lib/adapters/openshell/policy-authority.ts", + expectedReads: [unclassifiedFull("inspectSandboxPolicyAuthority")], + }, { relativePath: "src/lib/actions/sandbox/gateway-state.ts", expectedReads: [ @@ -95,6 +107,10 @@ const NON_MUTATION_POLICY_READS: readonly AuditedPolicyReadFile[] = [ ignoredFull("getSandboxGatewayStateForStatus"), ], }, + { + relativePath: "src/lib/actions/sandbox/launch-readiness.ts", + expectedReads: [unclassifiedFull("captureLivePolicy")], + }, { relativePath: "src/lib/policy/commands.ts", expectedReads: [ @@ -108,6 +124,7 @@ const NON_MUTATION_POLICY_READS: readonly AuditedPolicyReadFile[] = [ view: "full", failureHandling: "unclassified", }, + unclassifiedFull("buildPolicyGetFullJsonCommand"), ], }, ] as const; @@ -121,6 +138,7 @@ export interface DiscoveredPolicyReadSite { const POLICY_GET_BUILDERS = new Map([ ["buildPolicyGetCommand", "base"], ["buildPolicyGetFullCommand", "full"], + ["buildPolicyGetFullJsonCommand", "full"], ]); interface PolicyBuilderBindings { @@ -418,8 +436,12 @@ function directPolicyReadView( ts.isExpression(element) ? literalText(element) : null, ); if (values[offset] !== "policy" || values[offset + 1] !== "get") return null; - if (values[offset + 2] === "--base") return "base"; - if (values[offset + 2] === "--full") return "full"; + const readArguments = values.slice(offset + 2); + const hasBase = readArguments.includes("--base"); + const hasFull = readArguments.includes("--full"); + if (hasBase === hasFull) return null; + if (hasBase) return "base"; + if (hasFull) return "full"; return null; } diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts index 55fc50ee037..0748dea7987 100644 --- a/src/lib/actions/sandbox/destroy.ts +++ b/src/lib/actions/sandbox/destroy.ts @@ -277,11 +277,10 @@ export function cleanupSandboxServices( } /** - * Remove host-side shields state files for a sandbox. + * Remove host-side Shields state and recovery artifacts for a sandbox. * - * Without this cleanup a stale shields-.json from a previous - * `shields up` survives destroy → re-onboard and causes - * `deriveShieldsMode` to report "locked" on a fresh sandbox. + * Without this cleanup, stale state or an external policy handoff from a + * previous sandbox can survive destroy → re-onboard under the same name. * * See: https://github.com/NVIDIA/NemoClaw/issues/3114 */ @@ -293,8 +292,14 @@ export function removeShieldsState( const rmSync = deps.rmSync ?? fs.rmSync; const warn = deps.warn ?? ((message: string) => console.warn(` ${YW}⚠${R} ${message}`)); const resolvedStateDir = path.resolve(stateDir); - for (const prefix of ["shields-", "shields-timer-"]) { - const filePath = path.resolve(resolvedStateDir, `${prefix}${sandboxName}.json`); + const recoveryArtifactName = `shields-external-policy-${sandboxName}.yaml`; + const artifactNames = [ + recoveryArtifactName, + `shields-${sandboxName}.json`, + `shields-timer-${sandboxName}.json`, + ]; + for (const artifactName of artifactNames) { + const filePath = path.resolve(resolvedStateDir, artifactName); if (!filePath.startsWith(`${resolvedStateDir}${path.sep}`)) { // Defense-in-depth: sandbox names are validated to [a-z0-9-] at // all entry points, but reject traversal attempts just in case. @@ -303,12 +308,18 @@ export function removeShieldsState( try { rmSync(filePath, { force: true }); } catch (error) { - // force: true already suppresses ENOENT; warn on real failures - // (e.g. EPERM) so stale state doesn't silently survive. - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - const message = error instanceof Error ? error.message : String(error); - warn(`Failed to remove shields cleanup artifact '${filePath}': ${message}`); + // force: true already suppresses ENOENT. A recovery handoff must not + // become unbound under a reusable sandbox name, so preserve Shields + // state and stop cleanup when that artifact cannot be removed. + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + const message = error instanceof Error ? error.message : String(error); + if (artifactName === recoveryArtifactName) { + throw new Error( + `Could not remove external Shields policy recovery artifact '${filePath}': ${message}. Shields state was preserved for retry.`, + { cause: error }, + ); } + warn(`Failed to remove Shields cleanup artifact '${filePath}': ${message}`); } } } diff --git a/src/lib/actions/sandbox/mcp-bridge-policy.ts b/src/lib/actions/sandbox/mcp-bridge-policy.ts index b6424d61c6c..c3805905258 100644 --- a/src/lib/actions/sandbox/mcp-bridge-policy.ts +++ b/src/lib/actions/sandbox/mcp-bridge-policy.ts @@ -178,7 +178,7 @@ function resolveCanonicalManagedMcpAdapter( function requireCanonicalManagedPolicy( sandbox: registry.SandboxEntry, server: string, - livePolicies: Record, + livePolicies?: Record, ): ExactManagedMcpPolicy { const bridge = sandbox.mcp?.bridges[server]; if (!bridge || bridge.addState || bridge.server !== server) { @@ -252,10 +252,10 @@ function requireCanonicalManagedPolicy( throw new Error(`Managed MCP policy '${policyName}' has non-canonical generated content`); } - if (!Object.hasOwn(livePolicies, policyKey)) { + if (livePolicies && !Object.hasOwn(livePolicies, policyKey)) { throw new Error(`Managed MCP policy '${policyName}' is absent from the live gateway policy`); } - if (!isDeepStrictEqual(livePolicies[policyKey], registeredNetworkPolicy)) { + if (livePolicies && !isDeepStrictEqual(livePolicies[policyKey], registeredNetworkPolicy)) { throw new Error(`Managed MCP policy '${policyName}' has drifted from its ownership record`); } @@ -275,16 +275,16 @@ function requireCanonicalManagedPolicy( * committed custom-policy record whose sole network entry exactly matches the * live base policy. */ -export function inspectExactManagedMcpPolicies( +function inspectCanonicalManagedMcpPolicies( sandboxName: string, - livePolicyYaml: string, + livePolicies: Record | undefined, 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_")); + const unclassifiedKey = Object.keys(livePolicies ?? {}).find((key) => + key.startsWith("mcp_bridge_"), + ); if (unclassifiedKey) { throw new Error( `Reserved MCP policy key ${diagnosticPreview(unclassifiedKey)} has no committed managed bridge ownership`, @@ -302,7 +302,9 @@ export function inspectExactManagedMcpPolicies( `Generated MCP policy ${diagnosticPreview(orphaned.name)} has no committed managed bridge ownership`, ); } - const unclassifiedKey = Object.keys(livePolicies).find((key) => key.startsWith("mcp_bridge_")); + const unclassifiedKey = Object.keys(livePolicies ?? {}).find((key) => + key.startsWith("mcp_bridge_"), + ); if (unclassifiedKey) { throw new Error( `Reserved MCP policy key ${diagnosticPreview(unclassifiedKey)} has no committed managed bridge ownership`, @@ -339,7 +341,7 @@ export function inspectExactManagedMcpPolicies( } keys.add(entry.key); } - const unclassifiedKey = Object.keys(livePolicies).find( + const unclassifiedKey = Object.keys(livePolicies ?? {}).find( (key) => key.startsWith("mcp_bridge_") && !keys.has(key), ); if (unclassifiedKey) { @@ -350,6 +352,26 @@ export function inspectExactManagedMcpPolicies( return exact.sort((left, right) => left.key.localeCompare(right.key)); } +export function inspectExactManagedMcpPolicies( + sandboxName: string, + livePolicyYaml: string, + deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, +): ExactManagedMcpPolicy[] { + const liveDocument = parseManagedPolicyDocument(livePolicyYaml, "Live gateway policy"); + return inspectCanonicalManagedMcpPolicies( + sandboxName, + readManagedNetworkPolicies(liveDocument, "Live gateway policy"), + deps, + ); +} + +export function inspectRecordedManagedMcpPolicies( + sandboxName: string, + deps: ManagedMcpPolicyInspectionDeps = managedMcpPolicyInspectionDeps, +): ExactManagedMcpPolicy[] { + return inspectCanonicalManagedMcpPolicies(sandboxName, undefined, deps); +} + /** * Deadline-only inspection for automatic Shields restoration. * @@ -522,10 +544,10 @@ export function hasManagedMcpPolicyClaims( return ( Boolean( sandbox.mcp && - (Object.keys(sandbox.mcp.bridges).length > 0 || - (sandbox.mcp.managedServerNames?.length ?? 0) > 0 || - sandbox.mcp.destroyPreparedAt || - sandbox.mcp.destroyPendingAt), + (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) ); diff --git a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts index 8a7b2c54391..5125f7e7bf6 100644 --- a/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow-lifecycle.test.ts @@ -451,17 +451,22 @@ network_policies: ); }); - it("relocks as present when shields postwork throws after successful onboard", async () => { + it("relocks the recreated sandbox when recovery artifact cleanup fails (#9833)", async () => { + const recoveryArtifactPath = "/tmp/shields-external-policy-alpha.yaml"; const harness = createRebuildFlowHarness({ staleRecovery: true, clearShieldsState: () => { - throw new Error("post-onboard shields cleanup failed"); + throw new Error( + `Could not remove external Shields policy recovery artifact '${recoveryArtifactPath}': permission denied`, + ); }, }); await expect( harness.rebuildSandbox("alpha", ["--yes"], { throwOnError: true }), - ).rejects.toThrow("post-onboard shields cleanup failed"); + ).rejects.toThrow( + `Could not remove external Shields policy recovery artifact '${recoveryArtifactPath}': permission denied`, + ); expect(harness.onboardSpy).toHaveBeenCalledOnce(); expect(harness.relockSpy).toHaveBeenLastCalledWith( diff --git a/src/lib/adapters/openshell/policy-authority.test.ts b/src/lib/adapters/openshell/policy-authority.test.ts new file mode 100644 index 00000000000..6b7bc59b9db --- /dev/null +++ b/src/lib/adapters/openshell/policy-authority.test.ts @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as openshellResolveModule from "./resolve"; +import { + assertExternalPolicyRequirements, + assertRecordedPolicyAuthority, + inspectSandboxPolicyAuthority, + isExternalPolicyAuthorityRefusalError, + type PolicyAuthorityCapture, + policyAuthorityInternals, + type SandboxPolicyAuthorityInspection, +} from "./policy-authority"; + +function captureResult( + stdout: string, + overrides: Partial<{ + stderr: string; + exitCode: number | null; + timedOut: boolean; + }> = {}, +) { + return { + stdout, + stderr: overrides.stderr ?? "", + exitCode: overrides.exitCode ?? 0, + timedOut: overrides.timedOut ?? false, + }; +} + +function sandboxMetadata(overrides: Record = {}): Record { + return { + scope: "sandbox", + sandbox: "alpha", + status: "effective", + policy_source: "sandbox", + policy: { version: 1, network_policies: { baseline: { endpoints: ["base.test"] } } }, + ...overrides, + }; +} + +function errorFrom(action: () => unknown): Error { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + return error as Error; + } + throw new Error("expected the action to throw"); +} + +describe("OpenShell policy authority inspection", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(openshellResolveModule, "resolveOpenshell").mockReturnValue(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("recognizes a sandbox-scoped effective policy as NemoClaw-managed (#9833)", () => { + const runCaptureEx = vi.fn(() => + captureResult(JSON.stringify(sandboxMetadata())), + ); + + expect(inspectSandboxPolicyAuthority({ sandboxName: "alpha", runCaptureEx })).toEqual({ + authority: "nemoclaw-managed", + effectivePolicy: { + version: 1, + network_policies: { baseline: { endpoints: ["base.test"] } }, + }, + }); + expect(runCaptureEx).toHaveBeenCalledWith( + ["openshell", "policy", "get", "--full", "--output", "json", "alpha"], + { + maxBuffer: policyAuthorityInternals.captureMaxBytes, + timeout: policyAuthorityInternals.captureTimeoutMs, + }, + ); + }); + + it("recognizes a global policy source as externally managed on the recorded gateway (#9833)", () => { + const policy = { version: 1, network_policies: { required: { endpoints: ["api.test"] } } }; + const runCaptureEx = vi.fn(() => + captureResult(JSON.stringify(sandboxMetadata({ policy_source: "global", policy }))), + ); + + expect( + inspectSandboxPolicyAuthority({ + sandboxName: "alpha", + gatewayName: "nemoclaw-18080", + runCaptureEx, + }), + ).toEqual({ authority: "externally-managed", effectivePolicy: policy }); + expect(runCaptureEx.mock.calls[0]?.[0]).toEqual([ + "openshell", + "policy", + "get", + "-g", + "nemoclaw-18080", + "--full", + "--output", + "json", + "alpha", + ]); + }); + + it("rejects invalid sandbox and gateway identities before querying policy (#9833)", () => { + const runCaptureEx = vi.fn(() => + captureResult(JSON.stringify(sandboxMetadata())), + ); + + expect(() => inspectSandboxPolicyAuthority({ sandboxName: "--global", runCaptureEx })).toThrow( + /Invalid sandbox name/, + ); + expect(() => + inspectSandboxPolicyAuthority({ + sandboxName: "alpha", + gatewayName: "invalid gateway", + runCaptureEx, + }), + ).toThrow(/Invalid gateway name/); + expect(() => + inspectSandboxPolicyAuthority({ sandboxName: "alpha", gatewayName: "", runCaptureEx }), + ).toThrow(/gateway name is required/); + expect(runCaptureEx).not.toHaveBeenCalled(); + }); + + it.each([ + ["another scope", sandboxMetadata({ scope: "global" })], + ["another sandbox", sandboxMetadata({ sandbox: "beta" })], + ["an unknown source", sandboxMetadata({ policy_source: "unknown" })], + ])("rejects sandbox metadata with %s (#9833)", (_caseName, metadata) => { + const secret = "captured-policy-secret"; + const runCaptureEx = vi.fn(() => + captureResult(JSON.stringify({ ...metadata, diagnostic: secret })), + ); + + const error = errorFrom(() => + inspectSandboxPolicyAuthority({ sandboxName: "alpha", runCaptureEx }), + ); + expect(error.message).toContain("inspection failed"); + expect(error.message).not.toContain(secret); + }); + + it.each(["", " \n\t"])("fails closed when sandbox policy output is empty (%j) (#9833)", (raw) => { + const runCaptureEx = vi.fn(() => captureResult(raw)); + + expect(() => inspectSandboxPolicyAuthority({ sandboxName: "alpha", runCaptureEx })).toThrow( + /empty sandbox policy authority metadata/u, + ); + }); + + it.each([ + ["a nonzero exit", { exitCode: 7 }], + ["a timeout", { timedOut: true }], + ["malformed JSON", {}], + ])("fails closed without exposing output after %s (#9833)", (_caseName, overrides) => { + const runCaptureEx = vi.fn(() => + captureResult('{"secret":"captured-stdout-secret"', { + ...overrides, + stderr: "captured-stderr-secret", + }), + ); + + const error = errorFrom(() => + inspectSandboxPolicyAuthority({ sandboxName: "alpha", runCaptureEx }), + ); + expect(error.message).not.toContain("captured-stdout-secret"); + expect(error.message).not.toContain("captured-stderr-secret"); + }); + + it("replaces a thrown capture diagnostic instead of exposing command output (#9833)", () => { + const runCaptureEx = vi.fn(() => { + throw new Error("captured-policy-secret"); + }); + + const error = errorFrom(() => + inspectSandboxPolicyAuthority({ sandboxName: "alpha", runCaptureEx }), + ); + expect(error.message).toContain("could not run"); + expect(error.message).not.toContain("captured-policy-secret"); + }); + + it("rejects a captured policy response that exceeds the byte limit (#9833)", () => { + const oversized = "x".repeat(policyAuthorityInternals.captureMaxBytes + 1); + const runCaptureEx = vi.fn(() => captureResult(oversized)); + + const error = errorFrom(() => + inspectSandboxPolicyAuthority({ sandboxName: "alpha", runCaptureEx }), + ); + expect(error.message).toContain("capture limit"); + expect(error.message).not.toContain(oversized.slice(0, 32)); + }); +}); + +describe("recorded policy authority", () => { + it("accepts unchanged authority and refuses missing or changed authority (#9833)", () => { + expect(() => + assertRecordedPolicyAuthority("externally-managed", "externally-managed", "rebuild"), + ).not.toThrow(); + expect(() => + assertRecordedPolicyAuthority(undefined, "externally-managed", "restore the snapshot"), + ).toThrow(/recorded policy authority is unavailable or invalid/); + expect(() => + assertRecordedPolicyAuthority( + "nemoclaw-managed", + "externally-managed", + "restore the snapshot", + ), + ).toThrow(/changed from nemoclaw-managed to externally-managed/); + expect(() => + assertRecordedPolicyAuthority("externally-managed", "unknown", "restore the snapshot"), + ).toThrow(/observed OpenShell policy authority is unavailable or invalid/); + }); + + it("classifies an observed external authority without parsing diagnostics (#9833)", () => { + const externalError = errorFrom(() => + assertRecordedPolicyAuthority( + "nemoclaw-managed", + "externally-managed", + "restore the snapshot", + ), + ); + const managedError = errorFrom(() => + assertRecordedPolicyAuthority( + "externally-managed", + "nemoclaw-managed", + "restore the snapshot", + ), + ); + + expect(isExternalPolicyAuthorityRefusalError(externalError)).toBe(true); + expect(isExternalPolicyAuthorityRefusalError(managedError)).toBe(false); + }); +}); + +describe("externally managed policy requirements", () => { + it("compares exact requirements and redacts missing or drifted contents (#9833)", () => { + const requiredPolicy = { + version: 1, + filesystem_policy: { read_only: ["/required-secret"] }, + process: { run_as_user: 1000 }, + network_policies: { + exact: { endpoints: [{ host: "api.test", port: 443 }], mode: "allow" }, + missing: { endpoints: [{ host: "missing-secret.test", port: 443 }] }, + drifted: { endpoints: [{ host: "required-secret.test", port: 443 }] }, + }, + }; + const inspection: SandboxPolicyAuthorityInspection = { + authority: "externally-managed", + effectivePolicy: { + version: 9, + filesystem_policy: { read_only: ["/observed-secret"] }, + network_policies: { + exact: { mode: "allow", endpoints: [{ port: 443, host: "api.test" }] }, + drifted: { endpoints: [{ host: "observed-secret.test", port: 443 }] }, + }, + }, + }; + + const error = errorFrom(() => + assertExternalPolicyRequirements({ + inspection, + requiredPolicy, + operation: "enable messaging", + sandboxName: "alpha", + }), + ); + expect(error.message).toContain('missing sections "process"'); + expect(error.message).toContain('drifted sections "filesystem_policy"'); + expect(error.message).toContain('missing entries "missing"'); + expect(error.message).toContain('drifted entries "drifted"'); + expect(error.message).not.toMatch( + /required-secret|observed-secret|missing-secret\.test|observed-secret\.test/u, + ); + }); + + it("leaves NemoClaw-managed requirements to the mutation path (#9833)", () => { + expect(() => + assertExternalPolicyRequirements({ + inspection: { authority: "nemoclaw-managed", effectivePolicy: {} }, + requiredPolicy: { network_policies: { required: { endpoints: ["api.test"] } } }, + operation: "apply a preset", + }), + ).not.toThrow(); + }); +}); diff --git a/src/lib/adapters/openshell/policy-authority.ts b/src/lib/adapters/openshell/policy-authority.ts new file mode 100644 index 00000000000..0793d4693db --- /dev/null +++ b/src/lib/adapters/openshell/policy-authority.ts @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + diagnosticPreview, + isValidName, + NAME_ALLOWED_FORMAT, + NAME_MAX_LENGTH, +} from "../../sandbox-name-contract"; +import { buildPolicyGetFullJsonCommand } from "../../policy/commands"; +import { + assertExternalPolicyRequirementContainment, + assertMatchingPolicyAuthority, + type OpenShellPolicyAuthority, + parseSandboxPolicyAuthorityMetadata, + type SandboxPolicyAuthorityInspection as CanonicalSandboxPolicyAuthorityInspection, +} from "../../policy/merge"; +const POLICY_AUTHORITY_CAPTURE_MAX_BYTES = 1024 * 1024; +const POLICY_AUTHORITY_CAPTURE_TIMEOUT_MS = 30_000; + +type JsonObject = Record; + +export type SandboxPolicyAuthority = OpenShellPolicyAuthority; +export type SandboxPolicyAuthorityInspection = CanonicalSandboxPolicyAuthorityInspection; + +const POLICY_AUTHORITY_REFUSAL_CODE = "NEMOCLAW_POLICY_AUTHORITY_REFUSAL"; + +/** A final refusal at the OpenShell policy authority boundary. */ +export class PolicyAuthorityRefusalError extends Error { + readonly code = POLICY_AUTHORITY_REFUSAL_CODE; + readonly observedAuthority?: SandboxPolicyAuthority; + + constructor(message: string, observedAuthority?: SandboxPolicyAuthority) { + super(message); + this.name = "PolicyAuthorityRefusalError"; + this.observedAuthority = observedAuthority; + } +} + +/** Recognize policy-authority refusals across CommonJS and ESM module boundaries. */ +export function isPolicyAuthorityRefusalError(error: unknown): boolean { + return ( + error instanceof PolicyAuthorityRefusalError || + (isObject(error) && error.code === POLICY_AUTHORITY_REFUSAL_CODE) + ); +} + +/** Recognize a refusal caused by an externally managed observed policy. */ +export function isExternalPolicyAuthorityRefusalError(error: unknown): boolean { + return ( + isPolicyAuthorityRefusalError(error) && + isObject(error) && + error.observedAuthority === "externally-managed" + ); +} + +interface PolicyAuthorityCaptureResult { + readonly stdout: string; + readonly stderr?: string; + readonly exitCode: number | null; + readonly timedOut: boolean; +} + +export type PolicyAuthorityCapture = ( + command: readonly string[], + options?: { readonly maxBuffer?: number; readonly timeout?: number }, +) => PolicyAuthorityCaptureResult; + +interface SandboxPolicyAuthorityInspectionOptions { + readonly sandboxName: string; + readonly gatewayName?: string; + readonly runCaptureEx: PolicyAuthorityCapture; +} + +function validatePolicyAuthorityName(name: string, label: string): string { + if (!name || typeof name !== "string") { + throw new PolicyAuthorityRefusalError( + `${label} is required. Allowed format: ${NAME_ALLOWED_FORMAT}.`, + ); + } + if (name.length > NAME_MAX_LENGTH) { + throw new PolicyAuthorityRefusalError( + `${label} too long (max ${NAME_MAX_LENGTH} chars): ${diagnosticPreview(name)}. Allowed format: ${NAME_ALLOWED_FORMAT}.`, + ); + } + if (isValidName(name)) return name; + throw new PolicyAuthorityRefusalError( + `Invalid ${label}: ${diagnosticPreview(name)}. Allowed format: ${NAME_ALLOWED_FORMAT}.`, + ); +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function failInspection(subject: "sandbox" | "global", reason: string): never { + throw new PolicyAuthorityRefusalError( + `OpenShell ${subject} policy authority inspection failed: ${reason}. Policy-dependent operations must stop.`, + ); +} + +function capturePolicyQuery( + command: readonly string[], + capture: PolicyAuthorityCapture, + subject: "sandbox" | "global", + queryKind: "machine-readable policy" | "policy history", +): { readonly stdout: string; readonly stderr: string } { + let result: PolicyAuthorityCaptureResult; + try { + result = capture(command, { + maxBuffer: POLICY_AUTHORITY_CAPTURE_MAX_BYTES, + timeout: POLICY_AUTHORITY_CAPTURE_TIMEOUT_MS, + }); + } catch { + failInspection(subject, `the ${queryKind} query could not run`); + } + + if ( + !isObject(result) || + typeof result.stdout !== "string" || + (result.stderr !== undefined && typeof result.stderr !== "string") + ) { + failInspection(subject, `the ${queryKind} query returned an invalid capture result`); + } + const stderr = result.stderr ?? ""; + if ( + Buffer.byteLength(result.stdout, "utf8") + Buffer.byteLength(stderr, "utf8") > + POLICY_AUTHORITY_CAPTURE_MAX_BYTES + ) { + failInspection(subject, `the ${queryKind} response exceeded the capture limit`); + } + if (result.timedOut === true) { + failInspection(subject, `the ${queryKind} query timed out`); + } + if (result.timedOut !== false || result.exitCode !== 0) { + failInspection(subject, `the ${queryKind} query did not complete successfully`); + } + return { stdout: result.stdout, stderr }; +} + +/** Inspect the effective policy source for one live sandbox. */ +export function inspectSandboxPolicyAuthority({ + sandboxName, + gatewayName, + runCaptureEx, +}: SandboxPolicyAuthorityInspectionOptions): SandboxPolicyAuthorityInspection { + const validatedSandboxName = validatePolicyAuthorityName(sandboxName, "sandbox name"); + const validatedGatewayName = + gatewayName === undefined + ? undefined + : validatePolicyAuthorityName(gatewayName, "gateway name"); + const { stdout: raw } = capturePolicyQuery( + buildPolicyGetFullJsonCommand(validatedSandboxName, validatedGatewayName), + runCaptureEx, + "sandbox", + "machine-readable policy", + ); + try { + return parseSandboxPolicyAuthorityMetadata(raw, validatedSandboxName); + } catch (error) { + failInspection( + "sandbox", + error instanceof Error ? error.message : "OpenShell returned invalid policy metadata", + ); + } +} + +function operationLabel(operation: string): string { + return typeof operation === "string" && operation.trim().length > 0 + ? operation.trim() + : "continue the policy-dependent operation"; +} + +/** Refuse a lifecycle operation when its durable and observed authority disagree. */ +export function assertRecordedPolicyAuthority( + recorded: unknown, + observed: unknown, + operation: string, +): void { + const label = operationLabel(operation); + try { + assertMatchingPolicyAuthority(recorded, observed); + } catch (error) { + const detail = error instanceof Error ? error.message : "policy authority is invalid"; + const observedAuthority = + observed === "nemoclaw-managed" || observed === "externally-managed" ? observed : undefined; + throw new PolicyAuthorityRefusalError(`Refusing to ${label}: ${detail}.`, observedAuthority); + } +} + +/** + * Verify that an externally supplied policy contains each required entry and + * section without claiming ownership. Unrelated external entries are allowed. + */ +export function assertExternalPolicyRequirements({ + inspection, + requiredPolicy, + operation, + sandboxName, +}: { + readonly inspection: SandboxPolicyAuthorityInspection; + readonly requiredPolicy: JsonObject; + readonly operation: string; + readonly sandboxName?: string; +}): void { + const label = operationLabel(operation); + const target = sandboxName ? ` for sandbox ${JSON.stringify(sandboxName)}` : ""; + try { + assertExternalPolicyRequirementContainment(inspection, requiredPolicy); + } catch (error) { + const detail = error instanceof Error ? error.message : "the policy requirement is invalid"; + throw new PolicyAuthorityRefusalError( + `Refusing to ${label}${target}: ${detail}. Ask the external policy authority to supply the exact required entries.`, + ); + } +} + +export const policyAuthorityInternals = { + captureMaxBytes: POLICY_AUTHORITY_CAPTURE_MAX_BYTES, + captureTimeoutMs: POLICY_AUTHORITY_CAPTURE_TIMEOUT_MS, +}; diff --git a/src/lib/policy/README.md b/src/lib/policy/README.md index ab44d0ef086..ee2442ebc6b 100644 --- a/src/lib/policy/README.md +++ b/src/lib/policy/README.md @@ -7,3 +7,40 @@ Policy modules own sandbox network-policy preset loading, tier resolution, and policy application helpers. They may orchestrate OpenShell policy commands while legacy flows are being migrated, but pure selection/planning helpers should move under `src/lib/domain/**` when they can be isolated. + +## Policy authority + +The policy module reads the effective OpenShell policy through the sandbox's recorded gateway. +NemoClaw records the first qualified authority before another policy read or set. +NemoClaw refuses the operation when it cannot write that record. + +Immediately before each policy set, NemoClaw reads authority again and compares it with the record. +NemoClaw refuses the policy set when: + +- NemoClaw cannot determine authority. +- Recorded and observed authority differ. +- An external authority owns the policy. + +For external authority, preset requests only verify the effective policy. +NemoClaw requires the exact preset entries before it reports success. +NemoClaw does not set policy or record preset or custom-policy attribution. +The external authority must supply a missing or changed entry. + +If policy authority becomes external while Shields is down, NemoClaw keeps the +saved restrictive policy snapshot and refuses to set policy. The external +policy authority must make the effective policy for the named sandbox match the +saved restrictive snapshot and current managed MCP entries without changing +policy authority. `shields status` identifies that required policy by its +canonical JSON SHA-256 digest and network policy keys. The first status can +report no artifact. Run `nemoclaw shields up` once to create and +report the complete recovery artifact. The artifact contains no credential +values. It contains the saved restrictive policy and current managed MCP policy +entries, which may include credential bindings. Apply the artifact as the exact +policy through the external authority; do not reconstruct it from the digest +or key list. Then rerun `nemoclaw shields up`. NemoClaw verifies the +exact effective policy and locks configuration. If policy changes during the +lock, NemoClaw records the verified config lock and keeps Shields down until +policy recovery succeeds. + +A legacy sandbox record retains the first qualified `policyAuthority` after a later operation fails. +An inspection that cannot determine authority does not change the record. diff --git a/src/lib/policy/baseline-exclusion-journal-integration.test.ts b/src/lib/policy/baseline-exclusion-journal-integration.test.ts index d92c36c1455..cfbea706568 100644 --- a/src/lib/policy/baseline-exclusion-journal-integration.test.ts +++ b/src/lib/policy/baseline-exclusion-journal-integration.test.ts @@ -8,11 +8,20 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; const harness = vi.hoisted(() => ({ + inspectSandboxPolicyAuthority: vi.fn(() => ({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + })), livePolicy: "", run: vi.fn(), runCapture: vi.fn(), })); +vi.mock("../adapters/openshell/policy-authority", async (importOriginal) => ({ + ...(await importOriginal()), + inspectSandboxPolicyAuthority: harness.inspectSandboxPolicyAuthority, +})); + vi.mock("../runner", async (importOriginal) => ({ ...(await importOriginal()), run: harness.run, @@ -83,6 +92,7 @@ network_policies: }), ); expect(registry.getBaselineExclusions("alpha")).toEqual([]); + expect(registry.getSandbox("alpha")?.policyAuthority).toBe("nemoclaw-managed"); interruptedCommit.mockRestore(); // Simulate a new CLI process: reload both the registry and policy modules @@ -95,5 +105,6 @@ network_policies: expect(reloadedRegistry.getBaselineExclusions("alpha")).toEqual([ expect.objectContaining({ key: "nous_research", digest }), ]); + expect(reloadedRegistry.getSandbox("alpha")?.policyAuthority).toBe("nemoclaw-managed"); }); }); diff --git a/src/lib/policy/baseline-exclusion-persistence.test.ts b/src/lib/policy/baseline-exclusion-persistence.test.ts index 2ce24a84f7d..431e79caa22 100644 --- a/src/lib/policy/baseline-exclusion-persistence.test.ts +++ b/src/lib/policy/baseline-exclusion-persistence.test.ts @@ -14,11 +14,17 @@ const mocks = vi.hoisted(() => ({ getBaselineExclusions: vi.fn(), getBaselineExclusionTransition: vi.fn(), getSandbox: vi.fn(), + inspectSandboxPolicyAuthority: vi.fn(), removeBaselineExclusion: vi.fn(), run: vi.fn(), runCapture: vi.fn(), })); +vi.mock("../adapters/openshell/policy-authority", async (importOriginal) => ({ + ...(await importOriginal()), + inspectSandboxPolicyAuthority: mocks.inspectSandboxPolicyAuthority, +})); + vi.mock("../runner", async (importOriginal) => ({ ...(await importOriginal()), run: mocks.run, @@ -76,16 +82,30 @@ const OPENCLAW_RESTORED_POLICY = YAML.stringify({ network_policies: { managed_inference: OPENCLAW_BASELINE_ENTRY }, }); +function expectNoBaselineMutation(): void { + expect(mocks.addBaselineExclusion).not.toHaveBeenCalled(); + expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled(); + expect(mocks.run).not.toHaveBeenCalled(); +} + describe("excludeBaselineEntry persistence boundary (#7178)", () => { beforeEach(() => { vi.spyOn(openshellResolveModule, "resolveOpenshell").mockReturnValue("/usr/bin/openshell"); vi.spyOn(console, "error").mockImplementation(() => undefined); mocks.runCapture.mockReturnValue(LIVE_POLICY); mocks.run.mockReturnValue({ status: 0 }); + mocks.inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + }); mocks.getSandbox.mockReturnValue({ name: "alpha", agent: "hermes", agentVersion: "1.2.3", + policyAuthority: "nemoclaw-managed", }); mocks.getBaselineExclusions.mockReturnValue([]); mocks.getBaselineExclusionTransition.mockReturnValue(null); @@ -125,6 +145,37 @@ describe("excludeBaselineEntry persistence boundary (#7178)", () => { expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no live policy changes")); }); + it("refuses exclusion journal changes when authority changes during the live read (#9833)", () => { + mocks.inspectSandboxPolicyAuthority + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "externally-managed", effectivePolicy: {} }); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + false, + ); + + expectNoBaselineMutation(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("policy authority changed")); + }); + + it("preserves a pending exclusion when authority changes at the policy-set edge (#9833)", () => { + mocks.beginBaselineExclusionTransition.mockReturnValue(true); + mocks.inspectSandboxPolicyAuthority + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValue({ authority: "externally-managed", effectivePolicy: {} }); + + expect(excludeBaselineEntry("alpha", "nous_research", LIVE_DIGEST, { nonFatal: true })).toBe( + false, + ); + + expect(mocks.beginBaselineExclusionTransition).toHaveBeenCalledOnce(); + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("policy authority changed")); + }); + it("clears a fresh transaction when live narrowing fails", () => { mocks.beginBaselineExclusionTransition.mockReturnValue(true); mocks.run.mockReturnValue({ status: 19 }); @@ -217,6 +268,7 @@ describe("excludeBaselineEntry persistence boundary (#7178)", () => { agentVersion: "1.2.3", gatewayName: "nemoclaw-18080", gatewayPort: 18080, + policyAuthority: "nemoclaw-managed", }); mocks.beginBaselineExclusionTransition.mockReturnValue(true); mocks.runCapture @@ -360,10 +412,15 @@ describe("restoreBaselineEntry persistence boundary (#7178)", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); mocks.runCapture.mockReturnValue("version: 1\nnetwork_policies: {}\n"); mocks.run.mockReturnValue({ status: 0 }); + mocks.inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + }); mocks.getSandbox.mockReturnValue({ name: "alpha", agent: "hermes", agentVersion: "1.2.3", + policyAuthority: "nemoclaw-managed", }); mocks.getBaselineExclusions.mockReturnValue([RECORDED]); mocks.getBaselineExclusionTransition.mockReturnValue(null); @@ -384,21 +441,24 @@ describe("restoreBaselineEntry persistence boundary (#7178)", () => { ["changes", "nous_research", "stale-preview-digest", [RECORDED]], ["appears", "nous_research", null, [RECORDED]], ["disappears", "legacy_entry", LIVE_DIGEST, [{ ...RECORDED, key: "legacy_entry" }]], - ] as const)("does not mutate when the baseline entry %s after the operator preview", (_change, key, expectedTargetDigest, exclusions) => { - mocks.getBaselineExclusions.mockReturnValue([...exclusions]); - - expect(restoreBaselineEntry("alpha", key, { nonFatal: true, expectedTargetDigest })).toBe( - false, - ); - - expect(mocks.runCapture).not.toHaveBeenCalled(); - expect(mocks.run).not.toHaveBeenCalled(); - expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); - expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("changed after preview")); - }); + ] as const)( + "does not mutate when the baseline entry %s after the operator preview", + (_change, key, expectedTargetDigest, exclusions) => { + mocks.getBaselineExclusions.mockReturnValue([...exclusions]); + + expect(restoreBaselineEntry("alpha", key, { nonFatal: true, expectedTargetDigest })).toBe( + false, + ); + + expect(mocks.runCapture).not.toHaveBeenCalled(); + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.clearBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.commitBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.removeBaselineExclusion).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("changed after preview")); + }, + ); it("does not widen live egress when its durable transaction cannot be recorded", () => { mocks.beginBaselineExclusionTransition.mockReturnValue(false); @@ -409,6 +469,17 @@ describe("restoreBaselineEntry persistence boundary (#7178)", () => { expect(console.error).toHaveBeenCalledWith(expect.stringContaining("no live policy changes")); }); + it("refuses restore journal changes when authority changes during the live read (#9833)", () => { + mocks.inspectSandboxPolicyAuthority + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "externally-managed", effectivePolicy: {} }); + + expect(restoreBaselineEntry("alpha", "nous_research", { nonFatal: true })).toBe(false); + + expectNoBaselineMutation(); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("policy authority changed")); + }); + it("clears the restore transaction when live policy restoration fails", () => { mocks.run.mockReturnValue({ status: 19 }); @@ -443,6 +514,7 @@ describe("restoreBaselineEntry persistence boundary (#7178)", () => { name: "alpha", agent: "openclaw", agentVersion: "2.0.0", + policyAuthority: "nemoclaw-managed", }); mocks.getBaselineExclusions.mockReturnValue([staleExclusion]); mocks.runCapture @@ -556,6 +628,7 @@ describe("restoreBaselineEntry persistence boundary (#7178)", () => { name: "alpha", agent: "agent-without-a-readable-baseline", agentVersion: "1.2.3", + policyAuthority: "nemoclaw-managed", }); mocks.runCapture.mockReturnValue(LIVE_POLICY); mocks.getBaselineExclusionTransition.mockReturnValue({ diff --git a/src/lib/policy/commands.ts b/src/lib/policy/commands.ts index bcad4ef7eee..a042bbecb24 100644 --- a/src/lib/policy/commands.ts +++ b/src/lib/policy/commands.ts @@ -3,16 +3,57 @@ import { buildOpenshellCommand } from "../adapters/openshell/command-argv"; -export function buildPolicySetCommand(policyFile: string, sandboxName: string): string[] { - return buildOpenshellCommand(["policy", "set", "--policy", policyFile, "--wait", sandboxName]); +export function buildPolicySetCommand( + policyFile: string, + sandboxName: string, + gatewayName?: string, +): string[] { + return buildOpenshellCommand([ + "policy", + "set", + ...policyGatewayArgs(gatewayName), + "--policy", + policyFile, + "--wait", + sandboxName, + ]); } /** Read the round-trippable base policy before a mutation. */ -export function buildPolicyGetCommand(sandboxName: string): string[] { - return buildOpenshellCommand(["policy", "get", "--base", sandboxName]); +export function buildPolicyGetCommand(sandboxName: string, gatewayName?: string): string[] { + return buildOpenshellCommand([ + "policy", + "get", + ...policyGatewayArgs(gatewayName), + "--base", + sandboxName, + ]); } /** Read the effective policy for status and other diagnostics. */ -export function buildPolicyGetFullCommand(sandboxName: string): string[] { - return buildOpenshellCommand(["policy", "get", "--full", sandboxName]); +export function buildPolicyGetFullCommand(sandboxName: string, gatewayName?: string): string[] { + return buildOpenshellCommand([ + "policy", + "get", + ...policyGatewayArgs(gatewayName), + "--full", + sandboxName, + ]); +} + +function policyGatewayArgs(gatewayName?: string): string[] { + return gatewayName ? ["-g", gatewayName] : []; +} + +/** Read effective sandbox policy and its authority metadata as JSON. */ +export function buildPolicyGetFullJsonCommand(sandboxName: string, gatewayName?: string): string[] { + return buildOpenshellCommand([ + "policy", + "get", + ...policyGatewayArgs(gatewayName), + "--full", + "--output", + "json", + sandboxName, + ]); } diff --git a/src/lib/policy/context-builder.ts b/src/lib/policy/context-builder.ts index 7c2ba5e0013..b07894602ff 100644 --- a/src/lib/policy/context-builder.ts +++ b/src/lib/policy/context-builder.ts @@ -111,6 +111,14 @@ export interface PolicyContext { } const POLICY_DOC_URL = "docs/network-policy/customize-network-policy.mdx"; +const EXTERNAL_POLICY_ADD_PATH = + "Ask the external policy authority to add or replace the policy entries required by ``."; +const EXTERNAL_POLICY_REMOVE_PATH = + "Ask the external policy authority to remove the policy entries supplied by ``."; +const EXTERNAL_POLICY_RESTORE_PATH = + "Ask the external policy authority to restore baseline policy entry ``."; +const EXTERNAL_POLICY_EXCLUDE_PATH = + "Run `nemoclaw policy exclude --dry-run`, then ask the external policy authority to remove baseline policy entry ``."; function hostStemsFromContent(content: string | null | undefined): { public: string[]; @@ -191,7 +199,11 @@ function partitionPresets( // would record the preset as operator-applied (#9079). Sibling base additions // with no catalog entry are never iterated here, so this only corrects the // incidental name-collision case. - if (!isApplied && verification === "gateway-only" && isAgentBasePreset(sandboxName, info.name)) { + if ( + !isApplied && + verification === "gateway-only" && + isAgentBasePreset(sandboxName, info.name) + ) { verification = "agent-base"; } const enforcedNotApplied = @@ -255,34 +267,67 @@ function buildBaselineExclusions( return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key)); } -function buildApprovalPath(sandboxName: string): PolicyContextApprovalPath { +function buildApprovalPath( + sandboxName: string, + externallyManaged: boolean, +): PolicyContextApprovalPath { return { inspect: `nemoclaw ${sandboxName} policy list`, - add: `nemoclaw ${sandboxName} policy add `, - remove: `nemoclaw ${sandboxName} policy remove `, - excludeBaseline: `nemoclaw ${sandboxName} policy exclude --dry-run`, - restoreBaseline: `nemoclaw ${sandboxName} policy restore `, + add: externallyManaged + ? EXTERNAL_POLICY_ADD_PATH + : `nemoclaw ${sandboxName} policy add `, + remove: externallyManaged + ? EXTERNAL_POLICY_REMOVE_PATH + : `nemoclaw ${sandboxName} policy remove `, + excludeBaseline: externallyManaged + ? EXTERNAL_POLICY_EXCLUDE_PATH.replace("", sandboxName) + : `nemoclaw ${sandboxName} policy exclude --dry-run`, + restoreBaseline: externallyManaged + ? EXTERNAL_POLICY_RESTORE_PATH + : `nemoclaw ${sandboxName} policy restore `, documentation: POLICY_DOC_URL, }; } -function buildSupportBoundaries(tier: PolicyContextTier | null): PolicyContextSupportBoundary[] { +function buildSupportBoundaries( + tier: PolicyContextTier | null, + externallyManaged: boolean, +): PolicyContextSupportBoundary[] { return [ { - capability: "preset selection", + capability: "policy requirement selection and verification", owner: "nemoclaw", - note: tier ? `tier: ${tier.label}` : "no tier recorded", + note: externallyManaged + ? "NemoClaw selects preset and baseline requirements and verifies the live policy" + : tier + ? `tier: ${tier.label}` + : "no tier recorded", }, { capability: "host allowlist enforcement", owner: "openshell", note: "policy is enforced by the OpenShell gateway", }, - { - capability: "shields toggle", - owner: "nemoclaw", - note: "shields up locks down mutable config", - }, + ...(externallyManaged + ? [ + { + capability: "policy mutation", + owner: "external" as const, + note: "the external policy authority applies each required add, remove, restore, or baseline exclusion to the live policy", + }, + { + capability: "Shields state and configuration lock", + owner: "nemoclaw" as const, + note: "NemoClaw retains Shields state and locks configuration after it verifies restrictive policy", + }, + ] + : [ + { + capability: "Shields transition", + owner: "nemoclaw" as const, + note: "Shields up locks down mutable configuration", + }, + ]), { capability: "credential storage", owner: "nemoclaw", @@ -363,7 +408,8 @@ export function buildPolicyContext( options: BuildPolicyContextOptions = {}, ): PolicyContext { const sandbox = registry.getSandbox(sandboxName); - const tierName = sandbox?.policyTier ?? null; + const externallyManaged = sandbox?.policyAuthority === "externally-managed"; + const tierName = externallyManaged ? null : (sandbox?.policyTier ?? null); const tierDef = tierName ? getTier(tierName) : null; const tier: PolicyContextTier | null = tierDef ? { name: tierDef.name, label: tierDef.label, description: tierDef.description } @@ -386,8 +432,8 @@ export function buildPolicyContext( sandboxName, sandbox?.baselineExclusionTransition ?? null, ), - approvalPath: buildApprovalPath(sandboxName), - supportBoundaries: buildSupportBoundaries(tier), + approvalPath: buildApprovalPath(sandboxName, externallyManaged), + supportBoundaries: buildSupportBoundaries(tier, externallyManaged), generatedAt: new Date().toISOString(), }; } @@ -430,12 +476,19 @@ function exclusionStatusTag(status: PolicyContextExclusionStatus): string { } } -function formatExclusionLine(exclusion: PolicyContextExclusion, sandboxName: string): string { +function formatExclusionLine( + exclusion: PolicyContextExclusion, + sandboxName: string, + restoreAction: string, +): string { + const restore = restoreAction.startsWith("nemoclaw ") + ? `\`nemoclaw ${sandboxName} policy restore ${exclusion.key}\`` + : restoreAction; return [ `- \`${exclusion.key}\` — status: ${exclusionStatusTag(exclusion.status)}`, ` acknowledged: ${exclusion.acknowledgedAt ?? "(unknown)"}`, ` impact: ${exclusion.supportImpact}`, - ` restore: \`nemoclaw ${sandboxName} policy restore ${exclusion.key}\``, + ` restore: ${restore}`, ].join("\n"); } @@ -456,6 +509,10 @@ function formatPresetLine(preset: PolicyContextPreset): string { ].join("\n"); } +function formatApprovalAction(action: string): string { + return action.startsWith("nemoclaw ") ? `\`${action}\`` : action; +} + export function renderPolicyContextMarkdown(ctx: PolicyContext): string { const lines: string[] = []; lines.push(`# Sandbox policy context: ${ctx.sandboxName}`); @@ -497,16 +554,20 @@ export function renderPolicyContextMarkdown(ctx: PolicyContext): string { lines.push("- none"); } else { for (const exclusion of ctx.baselineExclusions) { - lines.push(formatExclusionLine(exclusion, ctx.sandboxName)); + lines.push(formatExclusionLine(exclusion, ctx.sandboxName, ctx.approvalPath.restoreBaseline)); } } lines.push(""); lines.push("## Approval and remediation"); lines.push(`- inspect: \`${ctx.approvalPath.inspect}\``); - lines.push(`- add a preset: \`${ctx.approvalPath.add}\``); - lines.push(`- remove a preset: \`${ctx.approvalPath.remove}\``); - lines.push(`- preview a baseline exclusion: \`${ctx.approvalPath.excludeBaseline}\``); - lines.push(`- restore a baseline entry: \`${ctx.approvalPath.restoreBaseline}\``); + lines.push(`- add a preset: ${formatApprovalAction(ctx.approvalPath.add)}`); + lines.push(`- remove a preset: ${formatApprovalAction(ctx.approvalPath.remove)}`); + lines.push( + `- preview a baseline exclusion: ${formatApprovalAction(ctx.approvalPath.excludeBaseline)}`, + ); + lines.push( + `- restore a baseline entry: ${formatApprovalAction(ctx.approvalPath.restoreBaseline)}`, + ); lines.push(`- documentation: ${ctx.approvalPath.documentation}`); lines.push(""); lines.push("## Support boundaries"); diff --git a/src/lib/policy/context.test.ts b/src/lib/policy/context.test.ts index 02120c14319..96a067fff42 100644 --- a/src/lib/policy/context.test.ts +++ b/src/lib/policy/context.test.ts @@ -76,11 +76,18 @@ function mockBuiltinPresets() { }); } -function stubRegistry(entry: Partial<{ policies: string[]; policyTier: string }>) { +function stubRegistry( + entry: Partial<{ + policies: string[]; + policyTier: string; + policyAuthority: "nemoclaw-managed" | "externally-managed"; + }>, +) { vi.mocked(registry.getSandbox).mockReturnValue({ name: SANDBOX, policies: entry.policies, policyTier: entry.policyTier ?? null, + policyAuthority: entry.policyAuthority, } as ReturnType); } @@ -144,6 +151,83 @@ describe("buildPolicyContext", () => { expect(ctx.supportBoundaries.some((b) => b.capability === "host allowlist enforcement")).toBe( true, ); + expect(ctx.supportBoundaries).toContainEqual({ + capability: "Shields transition", + owner: "nemoclaw", + note: "Shields up locks down mutable configuration", + }); + }); + + it("attributes externally managed policy changes only to the external authority (#9833)", () => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ + policies: [], + policyTier: "balanced", + policyAuthority: "externally-managed", + }); + vi.mocked(registry.getBaselineExclusions).mockReturnValue([ + { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "digest-1", + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + ]); + vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("excluded"); + + const context = buildPolicyContext(SANDBOX); + const markdown = renderPolicyContextMarkdown(context); + + expect(context.tier).toBeNull(); + expect(getTier).not.toHaveBeenCalled(); + expect(context.supportBoundaries).toContainEqual({ + capability: "policy requirement selection and verification", + owner: "nemoclaw", + note: "NemoClaw selects preset and baseline requirements and verifies the live policy", + }); + expect(context.supportBoundaries).toContainEqual({ + capability: "policy mutation", + owner: "external", + note: "the external policy authority applies each required add, remove, restore, or baseline exclusion to the live policy", + }); + expect(context.supportBoundaries).toContainEqual({ + capability: "Shields state and configuration lock", + owner: "nemoclaw", + note: "NemoClaw retains Shields state and locks configuration after it verifies restrictive policy", + }); + expect(context.approvalPath).toEqual({ + inspect: "nemoclaw alpha policy list", + add: "Ask the external policy authority to add or replace the policy entries required by ``.", + remove: + "Ask the external policy authority to remove the policy entries supplied by ``.", + excludeBaseline: + "Run `nemoclaw alpha policy exclude --dry-run`, then ask the external policy authority to remove baseline policy entry ``.", + restoreBaseline: + "Ask the external policy authority to restore baseline policy entry ``.", + documentation: "docs/network-policy/customize-network-policy.mdx", + }); + expect(markdown).toContain( + "restore: Ask the external policy authority to restore baseline policy entry ``.", + ); + expect(markdown).toContain( + "- restore a baseline entry: Ask the external policy authority to restore baseline policy entry ``.", + ); + expect(markdown).toContain( + "- policy mutation (owner: external) — the external policy authority applies each required add, remove, restore, or baseline exclusion to the live policy", + ); + expect(markdown).toContain( + "- Shields state and configuration lock (owner: nemoclaw) — NemoClaw retains Shields state and locks configuration after it verifies restrictive policy", + ); + expect(markdown).toContain( + "- preview a baseline exclusion: Run `nemoclaw alpha policy exclude --dry-run`, then ask the external policy authority to remove baseline policy entry ``.", + ); + expect(markdown).not.toContain( + "- preview a baseline exclusion: `Run `nemoclaw alpha policy exclude --dry-run`", + ); + expect(markdown).not.toMatch(/nemoclaw alpha policy (?:add|remove|restore)(?:\s|`)/u); }); it("marks active presets as `verified` when the gateway agrees and `registry-only` when it disagrees", () => { @@ -396,66 +480,66 @@ describe("buildPolicyContext", () => { expect(markdown).toContain("rebuild blocked"); }); - it.each([ - "exclude", - "restore", - ] as const)("surfaces pending %s repair even when the release baseline is unreadable (#7194)", (operation) => { - resetMocks(); - mockBuiltinPresets(); - vi.mocked(getTier).mockReturnValue(null); - vi.mocked(registry.getBaselineExclusions).mockReturnValue([ - { - version: 1, - agent: "openclaw", - key: "another_entry", - digest: "c".repeat(64), - acknowledgedAt: "2026-07-18T00:00:00.000Z", - }, - { - version: 1, - agent: "openclaw", - key: "nous_research", - digest: "a".repeat(64), - acknowledgedAt: "2026-07-19T00:00:00.000Z", - }, - ]); - vi.mocked(registry.getSandbox).mockReturnValue({ - name: SANDBOX, - policies: [], - baselineExclusionTransition: { - id: "00000000-0000-4000-8000-000000000001", - operation, - exclusion: { + it.each(["exclude", "restore"] as const)( + "surfaces pending %s repair even when the release baseline is unreadable (#7194)", + (operation) => { + resetMocks(); + mockBuiltinPresets(); + vi.mocked(getTier).mockReturnValue(null); + vi.mocked(registry.getBaselineExclusions).mockReturnValue([ + { + version: 1, + agent: "openclaw", + key: "another_entry", + digest: "c".repeat(64), + acknowledgedAt: "2026-07-18T00:00:00.000Z", + }, + { version: 1, agent: "openclaw", key: "nous_research", digest: "a".repeat(64), acknowledgedAt: "2026-07-19T00:00:00.000Z", }, - targetLiveDigest: operation === "restore" ? "b".repeat(64) : null, - startedAt: "2026-07-19T00:00:00.000Z", - }, - }); - vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("baseline-unreadable"); - - const ctx = buildPolicyContext(SANDBOX); - - expect(ctx.baselineExclusions).toEqual( - expect.arrayContaining([ - expect.objectContaining({ key: "another_entry", status: "baseline-unreadable" }), - expect.objectContaining({ - key: "nous_research", - status: operation === "exclude" ? "pending-exclude-repair" : "pending-restore-repair", - }), - ]), - ); - expect(ctx.baselineExclusions).toHaveLength(2); - expect(policies.getBaselineExclusionRuntimeStatus).toHaveBeenCalledOnce(); - expect(policies.getBaselineExclusionRuntimeStatus).toHaveBeenCalledWith( - SANDBOX, - expect.objectContaining({ key: "another_entry" }), - ); - }); + ]); + vi.mocked(registry.getSandbox).mockReturnValue({ + name: SANDBOX, + policies: [], + baselineExclusionTransition: { + id: "00000000-0000-4000-8000-000000000001", + operation, + exclusion: { + version: 1, + agent: "openclaw", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-07-19T00:00:00.000Z", + }, + targetLiveDigest: operation === "restore" ? "b".repeat(64) : null, + startedAt: "2026-07-19T00:00:00.000Z", + }, + }); + vi.mocked(policies.getBaselineExclusionRuntimeStatus).mockReturnValue("baseline-unreadable"); + + const ctx = buildPolicyContext(SANDBOX); + + expect(ctx.baselineExclusions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "another_entry", status: "baseline-unreadable" }), + expect.objectContaining({ + key: "nous_research", + status: operation === "exclude" ? "pending-exclude-repair" : "pending-restore-repair", + }), + ]), + ); + expect(ctx.baselineExclusions).toHaveLength(2); + expect(policies.getBaselineExclusionRuntimeStatus).toHaveBeenCalledOnce(); + expect(policies.getBaselineExclusionRuntimeStatus).toHaveBeenCalledWith( + SANDBOX, + expect.objectContaining({ key: "another_entry" }), + ); + }, + ); }); describe("renderPolicyContextMarkdown", () => { @@ -508,24 +592,20 @@ describe("renderPolicyContextMarkdown", () => { gatewayPresets: null, agentBase: false, }, - ])("renders the $status verification status (#9079)", ({ - status, - applied, - gatewayPresets, - agentBase, - }) => { - resetMocks(); - mockBuiltinPresets(); - stubTier(); - stubRegistry({ policies: applied, policyTier: "balanced" }); - vi.mocked(policies.isAgentBasePreset).mockReturnValue(agentBase); - - const md = renderPolicyContextMarkdown( - buildPolicyContext(SANDBOX, { gatewayPresets }), - ); - - expect(md).toContain(`status: ${status}`); - }); + ])( + "renders the $status verification status (#9079)", + ({ status, applied, gatewayPresets, agentBase }) => { + resetMocks(); + mockBuiltinPresets(); + stubTier(); + stubRegistry({ policies: applied, policyTier: "balanced" }); + vi.mocked(policies.isAgentBasePreset).mockReturnValue(agentBase); + + const md = renderPolicyContextMarkdown(buildPolicyContext(SANDBOX, { gatewayPresets })); + + expect(md).toContain(`status: ${status}`); + }, + ); it("states which verification statuses confirm gateway enforcement (#9079)", () => { resetMocks(); diff --git a/src/lib/policy/gateway-state.ts b/src/lib/policy/gateway-state.ts index 202d54ffbe5..79c23ddf64e 100644 --- a/src/lib/policy/gateway-state.ts +++ b/src/lib/policy/gateway-state.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { isDeepStrictEqual } from "node:util"; - import YAML from "yaml"; export type PresetContentSource = { name: string; content: string | null }; diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index e9894381b4c..7e6165cea31 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -12,6 +12,16 @@ import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; // Namespace access keeps resolveOpenshell spyable in focused policy tests. +import { + assertExternalPolicyRequirements, + assertRecordedPolicyAuthority, + inspectSandboxPolicyAuthority, + isExternalPolicyAuthorityRefusalError as isExternalAuthorityRefusalError, + isPolicyAuthorityRefusalError as isAuthorityRefusalError, + PolicyAuthorityRefusalError, + type SandboxPolicyAuthority, + type SandboxPolicyAuthorityInspection, +} from "../adapters/openshell/policy-authority"; import * as openshellResolveModule from "../adapters/openshell/resolve"; import { loadAgent, requireAgentPolicyAdditionsPath } from "../agent/defs"; import { CLI_NAME } from "../cli/branding"; @@ -28,7 +38,7 @@ import { import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { assertNoOpenShellGatewayEndpointOverride } from "../openshell-gateway-endpoint-guard"; import { OPENSHELL_SANDBOX_HOST_BRIDGE } from "../private-networks"; -import { ROOT, run, runCapture } from "../runner"; +import { ROOT, run, runCapture, runCaptureEx } from "../runner"; import { diagnosticPreview, isValidName, NAME_ALLOWED_FORMAT } from "../sandbox-name-contract"; import { redact } from "../security/redact"; import * as registry from "../state/registry"; @@ -407,7 +417,7 @@ function getPresetValidationWarning(presetName: string): string | null { if (!label) return null; const lines = [ `Note: the '${presetName}' preset only opens network egress to the ${label} API.`, - `To actually enable ${label} messaging, re-run 'nemoclaw onboard' and select ${label}`, + `To actually enable ${label} messaging, re-run '${CLI_NAME} onboard' and select ${label}`, "in the messaging channels step. Channel setup, pairing, and runtime", "configuration are wired up at onboard time and are not added by applying", "this preset alone.", @@ -570,6 +580,239 @@ interface PolicySetSubmission { readonly status: number | null; } +function policyAuthorityError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export interface PolicyMutationAuthority { + readonly authority: SandboxPolicyAuthority; + readonly authorityRecordedNow: boolean; + readonly gatewayName: string; + readonly inspection: SandboxPolicyAuthorityInspection; +} + +export const isPolicyAuthorityRefusalError = isAuthorityRefusalError; +export const isExternalPolicyAuthorityRefusalError = isExternalAuthorityRefusalError; + +function inspectLivePolicyAuthority( + sandboxName: string, + operation: string, + requestedGatewayName?: string, +): { + sandbox: ReturnType; + authority: PolicyMutationAuthority; +} { + let sandbox: ReturnType; + try { + sandbox = registry.getSandbox(sandboxName); + } catch { + throw new PolicyAuthorityRefusalError( + `Refusing to ${operation}: sandbox policy authority is unavailable.`, + ); + } + let recordedGatewayName: string | null; + try { + recordedGatewayName = sandbox ? resolveSandboxGatewayName(sandbox) : null; + } catch { + throw new PolicyAuthorityRefusalError( + `Refusing to ${operation}: the recorded sandbox gateway is unavailable or invalid.`, + ); + } + if (recordedGatewayName && requestedGatewayName && requestedGatewayName !== recordedGatewayName) { + throw new Error( + `Refusing to ${operation}: sandbox '${sandboxName}' is recorded on gateway ` + + `'${recordedGatewayName}', not '${requestedGatewayName}'.`, + ); + } + let gatewayName: string; + try { + gatewayName = + recordedGatewayName ?? requestedGatewayName ?? resolveSandboxGatewayName(undefined); + } catch { + throw new PolicyAuthorityRefusalError( + `Refusing to ${operation}: the sandbox gateway is unavailable or invalid.`, + ); + } + const inspection = inspectSandboxPolicyAuthority({ + sandboxName, + gatewayName, + runCaptureEx, + }); + return { + sandbox, + authority: { + authority: inspection.authority, + authorityRecordedNow: false, + gatewayName, + inspection, + }, + }; +} + +/** Read live authority for Shields recovery without changing its durable owner. */ +export function inspectPolicyRecoveryAuthority( + sandboxName: string, + operation: string, + requestedGatewayName?: string, +): PolicyMutationAuthority { + const live = inspectLivePolicyAuthority(sandboxName, operation, requestedGatewayName); + if (!live.sandbox) { + throw new PolicyAuthorityRefusalError( + `Refusing to ${operation}: sandbox policy authority is unavailable.`, + ); + } + return live.authority; +} + +/** Inspect and, when needed, persist the authority that owns one sandbox policy. */ +export function inspectPolicyMutationAuthority( + sandboxName: string, + operation: string, + requestedGatewayName?: string, + requireRecordedAuthority = false, +): PolicyMutationAuthority { + const live = inspectLivePolicyAuthority(sandboxName, operation, requestedGatewayName); + const { sandbox } = live; + const { gatewayName, inspection } = live.authority; + if (sandbox?.policyAuthority !== undefined) { + try { + assertRecordedPolicyAuthority(sandbox.policyAuthority, inspection.authority, operation); + } catch (error) { + if ( + sandbox.policyAuthority === "externally-managed" || + inspection.authority === "externally-managed" + ) { + throw new PolicyAuthorityRefusalError( + `${policyAuthorityError(error)} The external policy authority must perform the requested policy mutation.`, + inspection.authority, + ); + } + throw error; + } + return { + authority: inspection.authority, + authorityRecordedNow: false, + gatewayName, + inspection, + }; + } + + if (requireRecordedAuthority) { + throw new Error( + `Refusing to ${operation}: policy authority is not recorded for sandbox '${sandboxName}'.`, + ); + } + let authorityRecorded: boolean; + try { + authorityRecorded = Boolean( + sandbox && + registry.updateSandbox(sandboxName, { + policyAuthority: inspection.authority, + }), + ); + } catch { + authorityRecorded = false; + } + if (!authorityRecorded) { + throw new Error( + `Refusing to ${operation}: NemoClaw could not record policy authority for sandbox '${sandboxName}'.`, + ); + } + return { + authority: inspection.authority, + authorityRecordedNow: true, + gatewayName, + inspection, + }; +} + +/** Require NemoClaw ownership before a local policy mutation. */ +export function assertNemoClawManagedPolicy( + authority: PolicyMutationAuthority, + operation: string, +): void { + if (authority.authority === "nemoclaw-managed") return; + throw new PolicyAuthorityRefusalError( + `Refusing to ${operation}: this sandbox policy is externally managed. ` + + "The external policy authority must perform the requested policy mutation.", + authority.authority, + ); +} + +/** Recheck one recorded receipt immediately before a policy mutation. */ +export function recheckPolicyMutationAuthority( + sandboxName: string, + operation: string, + recorded: PolicyMutationAuthority, +): PolicyMutationAuthority { + const observed = inspectPolicyMutationAuthority( + sandboxName, + operation, + recorded.gatewayName, + true, + ); + assertRecordedPolicyAuthority(recorded.authority, observed.authority, operation); + assertNemoClawManagedPolicy(observed, operation); + return observed; +} + +/** Reject a final OpenShell policy refusal without exposing raw diagnostics. */ +export function rejectFinalPolicySetResult( + result: ReturnType, + operation: string, +): void { + const captured = result as ReturnType & { + error?: Error; + stderr?: string | Buffer | null; + }; + const outcome = classifyPolicySetResult({ + status: typeof captured.status === "number" ? captured.status : null, + ...(captured.error ? { error: captured.error } : {}), + stderr: Buffer.isBuffer(captured.stderr) + ? captured.stderr.toString("utf8") + : (captured.stderr ?? null), + }); + if (outcome.kind === "rejected") { + throw new PolicyAuthorityRefusalError( + `Refusing to ${operation}: OpenShell rejected the policy change: ${redact(outcome.message)}`, + ); + } +} + +function reportPolicyAuthorityFailure(error: unknown): false { + console.error(` ${policyAuthorityError(error)}`); + return false; +} + +function inspectNemoClawManagedPolicy( + sandboxName: string, + operation: string, + gatewayName?: string, +): PolicyMutationAuthority | null { + try { + const context = inspectPolicyMutationAuthority(sandboxName, operation, gatewayName); + assertNemoClawManagedPolicy(context, operation); + return context; + } catch (error) { + reportPolicyAuthorityFailure(error); + return null; + } +} + +/** Recheck the original managed receipt immediately before a local state mutation. */ +function recheckNemoClawManagedPolicy( + sandboxName: string, + operation: string, + authority: PolicyMutationAuthority, +): boolean { + try { + recheckPolicyMutationAuthority(sandboxName, operation, authority); + return true; + } catch (error) { + return reportPolicyAuthorityFailure(error); + } +} + /** * Submit a composed policy document through a private temp file and classify * what OpenShell did with it. @@ -652,12 +895,38 @@ function policySetFailure( function setPolicyDocument( sandboxName: string, policyDocument: string, - options: { nonFatal?: boolean; gatewayName?: string } = {}, + options: { + nonFatal?: boolean; + gatewayName?: string; + } = {}, ): boolean { + let authority: PolicyMutationAuthority; + try { + authority = inspectPolicyMutationAuthority( + sandboxName, + "set the sandbox policy", + options.gatewayName, + ); + assertNemoClawManagedPolicy(authority, "set the sandbox policy"); + if (authority.authorityRecordedNow) { + authority = inspectPolicyMutationAuthority( + sandboxName, + "set the sandbox policy", + authority.gatewayName, + true, + ); + assertNemoClawManagedPolicy(authority, "set the sandbox policy"); + } + } catch (error) { + console.error(` ${policyAuthorityError(error)}`); + if (options.nonFatal) return false; + process.exit(1); + } + const { outcome, status } = submitComposedPolicy( sandboxName, policyDocument, - options.gatewayName, + authority.gatewayName, ); if (outcome.kind === "applied") return true; @@ -1339,11 +1608,17 @@ function removePreset( return false; } + const operation = `remove policy preset '${presetName}'`; + const authority = inspectNemoClawManagedPolicy(sandboxName, operation); + if (!authority) return false; + // Get current policy YAML from sandbox let rawPolicy = ""; try { // Mutations start from round-trippable --base, never provider-composed --full. - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { + env: { OPENSHELL_GATEWAY: authority.gatewayName }, + }); } catch { /* ignored */ } @@ -1385,6 +1660,7 @@ function removePreset( return false; } if (sandbox) { + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; const attributionRemoved = isCustom ? registry.removeCustomPolicyByName(sandboxName, presetName) : registry.updateSandbox(sandboxName, { @@ -1426,12 +1702,21 @@ function removePreset( // Run before submitting so a missing-binary exit doesn't orphan files in // $TMPDIR (the cleanup doesn't run on process.exit). if (!assertOpenshellResolvable(options)) return false; + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; - if (!setPolicyDocument(sandboxName, updated, options)) return false; - console.log(` Removed preset: ${presetName}`); + if ( + !setPolicyDocument(sandboxName, updated, { + nonFatal: options.nonFatal, + gatewayName: authority.gatewayName, + }) + ) { + return false; + } + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; const sandbox = options.skipRegistryUpdate ? undefined : registry.getSandbox(sandboxName); if (sandbox) { + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; if (isCustom) { registry.removeCustomPolicyByName(sandboxName, presetName); } else { @@ -1440,6 +1725,7 @@ function removePreset( } } + console.log(` Removed preset: ${presetName}`); return true; } @@ -1595,6 +1881,17 @@ type LiveBaselineEntryState = | { state: "present"; digest: string } | { state: "invalid"; digest: null }; +type RecheckManagedPolicyAuthority = () => boolean; + +function authorityBoundRegistryStep( + recheckAuthority: RecheckManagedPolicyAuthority, + operation: () => boolean, + failureMessage: string, +): boolean { + if (!recheckAuthority()) return false; + return registryTransitionStep(operation, failureMessage); +} + function inspectLiveBaselineEntry(policy: string, key: string): LiveBaselineEntryState { try { const document = YAML.parse(policy); @@ -1626,6 +1923,7 @@ function reconcileBaselineExclusionTransition( sandboxName: string, requestedKey: string, gatewayName: string, + recheckAuthority: RecheckManagedPolicyAuthority, ): BaselineTransitionReconciliation | null { const transition = registry.getBaselineExclusionTransition(sandboxName); if (!transition) return { state: "none" }; @@ -1660,7 +1958,9 @@ function reconcileBaselineExclusionTransition( ? live.state === "absent" : live.state === "present" && live.digest === transition.targetLiveDigest; if (atTarget) { - if (!finalizeBaselineExclusionTransition(sandboxName, transition)) return null; + if (!finalizeBaselineExclusionTransition(sandboxName, transition, recheckAuthority)) { + return null; + } return { state: transition.operation === "exclude" ? "excluded" : "restored" }; } @@ -1679,7 +1979,8 @@ function reconcileBaselineExclusionTransition( return { state: "resume", transition }; } if ( - !registryTransitionStep( + !authorityBoundRegistryStep( + recheckAuthority, () => registry.clearBaselineExclusionTransition(sandboxName, transition.id), `The live policy remains at the pre-${transition.operation} state for '${key}', but the durable journal could not be rolled back. Re-run the same command; rebuild remains blocked.`, ) @@ -1700,6 +2001,7 @@ function beginBaselineExclusionTransition( operation: registry.BaselineExclusionTransitionOperation, exclusion: registry.BaselineExclusionEntry, targetLiveDigest: string | null, + recheckAuthority: RecheckManagedPolicyAuthority, ): registry.BaselineExclusionTransition | null { const transition: registry.BaselineExclusionTransition = { id: randomUUID(), @@ -1708,7 +2010,8 @@ function beginBaselineExclusionTransition( targetLiveDigest, startedAt: new Date().toISOString(), }; - return registryTransitionStep( + return authorityBoundRegistryStep( + recheckAuthority, () => registry.beginBaselineExclusionTransition(sandboxName, transition), `Could not record the pending baseline '${operation}' for '${sandboxName}'; no live policy changes were made.`, ) @@ -1751,9 +2054,11 @@ function restoreTransitionCanFinalize( function finalizeBaselineExclusionTransition( sandboxName: string, transition: registry.BaselineExclusionTransition, + recheckAuthority: RecheckManagedPolicyAuthority, ): boolean { if (!restoreTransitionCanFinalize(sandboxName, transition)) return false; - return registryTransitionStep( + return authorityBoundRegistryStep( + recheckAuthority, () => registry.commitBaselineExclusionTransition(sandboxName, transition.id), `The live policy was updated for '${transition.exclusion.key}', but the durable journal could not be finalized. Re-run 'policy ${transition.operation} ${transition.exclusion.key}' to reconcile it; rebuild remains blocked.`, ); @@ -1762,8 +2067,10 @@ function finalizeBaselineExclusionTransition( function compensateBaselineExclusionTransition( sandboxName: string, transition: registry.BaselineExclusionTransition, + recheckAuthority: RecheckManagedPolicyAuthority, ): boolean { - return registryTransitionStep( + return authorityBoundRegistryStep( + recheckAuthority, () => registry.clearBaselineExclusionTransition(sandboxName, transition.id), `Failed to roll back the pending baseline '${transition.operation}' for '${transition.exclusion.key}'. The durable journal was preserved; re-run the same command before rebuilding '${sandboxName}'.`, ); @@ -1775,6 +2082,7 @@ function settleBaselineExclusionTransitionAfterPush( pushSucceeded: boolean, canRollbackAtSource: boolean, gatewayName: string, + recheckAuthority: RecheckManagedPolicyAuthority, ): boolean { const currentPolicy = readCurrentSandboxPolicy(sandboxName, gatewayName); if (!currentPolicy) { @@ -1789,14 +2097,14 @@ function settleBaselineExclusionTransitionAfterPush( ? live.state === "absent" : live.state === "present" && live.digest === transition.targetLiveDigest; if (atTarget) { - return finalizeBaselineExclusionTransition(sandboxName, transition); + return finalizeBaselineExclusionTransition(sandboxName, transition, recheckAuthority); } const atSource = transition.operation === "exclude" ? live.state === "present" && live.digest === transition.exclusion.digest : live.state === "absent"; if (!pushSucceeded && atSource && canRollbackAtSource) { - compensateBaselineExclusionTransition(sandboxName, transition); + compensateBaselineExclusionTransition(sandboxName, transition, recheckAuthority); return false; } const state = atSource ? "the pre-mutation state" : "an unexpected third state"; @@ -1811,8 +2119,10 @@ function attemptBaselineTransitionPolicyPush( updatedPolicy: string, options: { nonFatal?: boolean }, gatewayName: string, + recheckAuthority: RecheckManagedPolicyAuthority, ): boolean { try { + if (!recheckAuthority()) return false; return pushPolicyYaml(sandboxName, updatedPolicy, { ...options, nonFatal: true, @@ -1836,9 +2146,20 @@ function excludeBaselineEntry( digest: string, options: { nonFatal?: boolean } = {}, ): boolean { - return withRecordedSandboxGateway(sandboxName, (gatewayName) => - excludeBaselineEntryOnGateway(sandboxName, key, digest, options, gatewayName), - ); + return withRecordedSandboxGateway(sandboxName, (gatewayName) => { + const operation = `exclude baseline policy entry '${key}'`; + const authority = inspectNemoClawManagedPolicy(sandboxName, operation, gatewayName); + if (!authority) return false; + const recheckAuthority = () => recheckNemoClawManagedPolicy(sandboxName, operation, authority); + return excludeBaselineEntryOnGateway( + sandboxName, + key, + digest, + options, + gatewayName, + recheckAuthority, + ); + }); } function excludeBaselineEntryOnGateway( @@ -1847,8 +2168,14 @@ function excludeBaselineEntryOnGateway( digest: string, options: { nonFatal?: boolean }, gatewayName: string, + recheckAuthority: RecheckManagedPolicyAuthority, ): boolean { - const reconciled = reconcileBaselineExclusionTransition(sandboxName, key, gatewayName); + const reconciled = reconcileBaselineExclusionTransition( + sandboxName, + key, + gatewayName, + recheckAuthority, + ); if (!reconciled) return false; if (reconciled.state === "excluded") return true; if (reconciled.state === "resume" && reconciled.transition.operation !== "exclude") { @@ -1896,9 +2223,14 @@ function excludeBaselineEntryOnGateway( }; if (!removed) { if (reconciled.state === "resume") { - return finalizeBaselineExclusionTransition(sandboxName, reconciled.transition); + return finalizeBaselineExclusionTransition( + sandboxName, + reconciled.transition, + recheckAuthority, + ); } - return registryTransitionStep( + return authorityBoundRegistryStep( + recheckAuthority, () => registry.addBaselineExclusion(sandboxName, exclusion), `The already-narrow live policy could not be recorded for '${sandboxName}'.`, ); @@ -1906,13 +2238,14 @@ function excludeBaselineEntryOnGateway( const transition = reconciled.state === "resume" ? reconciled.transition - : beginBaselineExclusionTransition(sandboxName, "exclude", exclusion, null); + : beginBaselineExclusionTransition(sandboxName, "exclude", exclusion, null, recheckAuthority); if (!transition) return false; const pushSucceeded = attemptBaselineTransitionPolicyPush( sandboxName, updated, options, gatewayName, + recheckAuthority, ); // When this was a fresh exclusion, a failed push that verifies at the exact // source can clear the journal. A re-exclude that began with committed/live @@ -1923,6 +2256,7 @@ function excludeBaselineEntryOnGateway( pushSucceeded, !previousExclusion, gatewayName, + recheckAuthority, ); } @@ -1941,9 +2275,13 @@ function restoreBaselineEntry( key: string, options: RestoreBaselineEntryOptions = {}, ): boolean { - return withRecordedSandboxGateway(sandboxName, (gatewayName) => - restoreBaselineEntryOnGateway(sandboxName, key, options, gatewayName), - ); + return withRecordedSandboxGateway(sandboxName, (gatewayName) => { + const operation = `restore baseline policy entry '${key}'`; + const authority = inspectNemoClawManagedPolicy(sandboxName, operation, gatewayName); + if (!authority) return false; + const recheckAuthority = () => recheckNemoClawManagedPolicy(sandboxName, operation, authority); + return restoreBaselineEntryOnGateway(sandboxName, key, options, gatewayName, recheckAuthority); + }); } function restoreBaselineEntryOnGateway( @@ -1951,6 +2289,7 @@ function restoreBaselineEntryOnGateway( key: string, options: RestoreBaselineEntryOptions, gatewayName: string, + recheckAuthority: RecheckManagedPolicyAuthority, ): boolean { // Resolve the current agent baseline before changing either durable or live // state. A missing non-OpenClaw baseline must not be mistaken for a release @@ -1979,7 +2318,12 @@ function restoreBaselineEntryOnGateway( return false; } - const reconciled = reconcileBaselineExclusionTransition(sandboxName, key, gatewayName); + const reconciled = reconcileBaselineExclusionTransition( + sandboxName, + key, + gatewayName, + recheckAuthority, + ); if (!reconciled) return false; if (reconciled.state === "restored") return true; if (reconciled.state === "resume" && reconciled.transition.operation !== "restore") { @@ -2001,7 +2345,8 @@ function restoreBaselineEntryOnGateway( return false; } if (!target) { - return registryTransitionStep( + return authorityBoundRegistryStep( + recheckAuthority, () => registry.removeBaselineExclusion(sandboxName, key), `The obsolete exclusion for '${key}' could not be cleared; no live policy changes were made.`, ); @@ -2021,9 +2366,14 @@ function restoreBaselineEntryOnGateway( } if (live.state === "present" && live.digest === targetDigest) { if (reconciled.state === "resume") { - return finalizeBaselineExclusionTransition(sandboxName, reconciled.transition); + return finalizeBaselineExclusionTransition( + sandboxName, + reconciled.transition, + recheckAuthority, + ); } - return registryTransitionStep( + return authorityBoundRegistryStep( + recheckAuthority, () => registry.removeBaselineExclusion(sandboxName, key), `The restored live policy could not be recorded for '${sandboxName}'.`, ); @@ -2031,7 +2381,13 @@ function restoreBaselineEntryOnGateway( const transition = reconciled.state === "resume" ? reconciled.transition - : beginBaselineExclusionTransition(sandboxName, "restore", recordedExclusion, targetDigest); + : beginBaselineExclusionTransition( + sandboxName, + "restore", + recordedExclusion, + targetDigest, + recheckAuthority, + ); if (!transition) return false; const updated = mergeBaselineEntryIntoPolicy(currentPolicy, key, target.entry); const pushSucceeded = attemptBaselineTransitionPolicyPush( @@ -2039,6 +2395,7 @@ function restoreBaselineEntryOnGateway( updated, options, gatewayName, + recheckAuthority, ); return settleBaselineExclusionTransitionAfterPush( sandboxName, @@ -2046,6 +2403,7 @@ function restoreBaselineEntryOnGateway( pushSucceeded, true, gatewayName, + recheckAuthority, ); } @@ -2232,11 +2590,35 @@ function applyPresetContent( return false; } + const requiredNetworkPolicies = parseNetworkPolicies(presetContent); + if (!requiredNetworkPolicies) { + console.error(` Preset ${presetName} has invalid network_policies.`); + return false; + } + const operation = `apply policy preset '${presetName}'`; + let authority: PolicyMutationAuthority; + try { + authority = inspectPolicyMutationAuthority(sandboxName, operation); + if (authority.authority === "externally-managed") { + assertExternalPolicyRequirements({ + inspection: authority.inspection, + requiredPolicy: { network_policies: requiredNetworkPolicies }, + operation, + sandboxName, + }); + return true; + } + } catch (error) { + return reportPolicyAuthorityFailure(error); + } + // Get current policy YAML from sandbox let rawPolicy: string | null = null; try { // Mutations start from round-trippable --base, never provider-composed --full. - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { + env: { OPENSHELL_GATEWAY: authority.gatewayName }, + }); } catch { /* Refused below. */ } @@ -2343,15 +2725,27 @@ function applyPresetContent( if (policyChanged && !assertOpenshellResolvable(options)) return false; if (policyChanged) { - if (!setPolicyDocument(sandboxName, merged, options)) return false; - console.log(` Applied preset: ${presetName}`); + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; + if ( + !setPolicyDocument(sandboxName, merged, { + nonFatal: options.nonFatal, + gatewayName: authority.gatewayName, + }) + ) { + return false; + } + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; } // Some multi-resource lifecycle callers reserve ownership in the registry // before mutating the live gateway. That ordering prevents a successful // policy set followed by a registry-write failure from leaving an unowned // live key. They explicitly request no second registry write here. - if (options.skipRegistryUpdate) return true; + if (options.skipRegistryUpdate) { + if (policyChanged) console.log(` Applied preset: ${presetName}`); + return true; + } + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; const sandbox = registry.getSandbox(sandboxName); if (sandbox) { @@ -2399,6 +2793,7 @@ function applyPresetContent( ); } + if (policyChanged) console.log(` Applied preset: ${presetName}`); return true; } @@ -2438,29 +2833,12 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { const uniquePresetNames = [...new Set(presetNames)].filter(Boolean); if (uniquePresetNames.length === 0) return true; - let rawPolicy: string | null = null; - try { - // Mutations start from round-trippable --base, never provider-composed --full. - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); - } catch { - /* Refused below. */ - } - - let merged = parseCurrentPolicyOrEmpty(rawPolicy); - // Keep the batch entrypoint on the same fail-closed source boundary as - // applyPresetContent: an unusable successful read is still a failed read. - if (!merged) { - console.error( - ` Could not read the current policy for sandbox '${sandboxName}'; refusing to apply presets to avoid overwriting it.`, - ); - return false; - } - const presetContents: Array<{ + const preparedPresets: Array<{ content: string; + entries: string; name: string; - state: PresetPolicyState; }> = []; - const originalPolicy = merged; + const requiredNetworkPolicies: PolicyObject = {}; for (const presetName of uniquePresetNames) { const presetContent = loadPresetForSandbox(sandboxName, presetName); @@ -2481,10 +2859,68 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { ); return false; } + const networkPolicies = parseNetworkPolicies(presetContent); + if (!networkPolicies) { + console.error(` Preset ${presetName} has invalid network_policies.`); + return false; + } + for (const [key, value] of Object.entries(networkPolicies)) { + requiredNetworkPolicies[key] = value; + } + preparedPresets.push({ + content: presetContent, + entries: presetEntries, + name: presetName, + }); + } - const state = classifyPresetEntries(merged, presetEntries); - presetContents.push({ content: presetContent, name: presetName, state }); - merged = mergePresetIntoPolicy(merged, presetEntries); + const operation = "apply policy presets"; + let authority: PolicyMutationAuthority; + try { + authority = inspectPolicyMutationAuthority(sandboxName, operation); + if (authority.authority === "externally-managed") { + assertExternalPolicyRequirements({ + inspection: authority.inspection, + requiredPolicy: { network_policies: requiredNetworkPolicies }, + operation, + sandboxName, + }); + return true; + } + } catch (error) { + return reportPolicyAuthorityFailure(error); + } + + let rawPolicy: string | null = null; + try { + // Mutations start from round-trippable --base, never provider-composed --full. + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName), { + env: { OPENSHELL_GATEWAY: authority.gatewayName }, + }); + } catch { + /* Refused below. */ + } + + let merged = parseCurrentPolicyOrEmpty(rawPolicy); + // Keep the batch entrypoint on the same fail-closed source boundary as + // applyPresetContent: an unusable successful read is still a failed read. + if (!merged) { + console.error( + ` Could not read the current policy for sandbox '${sandboxName}'; refusing to apply presets to avoid overwriting it.`, + ); + return false; + } + const presetContents: Array<{ + content: string; + name: string; + state: PresetPolicyState; + }> = []; + const originalPolicy = merged; + + for (const preset of preparedPresets) { + const state = classifyPresetEntries(merged, preset.entries); + presetContents.push({ content: preset.content, name: preset.name, state }); + merged = mergePresetIntoPolicy(merged, preset.entries); } let npmBaselineWidened = false; @@ -2534,13 +2970,13 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { // The shared fatal path preserves OpenShell's status after it removes the // temporary policy. Onboarding defers that exit until its recovery state // and outer cleanup have finished. - setPolicyDocument(sandboxName, merged); - - for (const preset of presetContents.filter((entry) => entry.state !== "match")) { - console.log(` Applied preset: ${preset.name}`); - } + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; + setPolicyDocument(sandboxName, merged, { gatewayName: authority.gatewayName }); + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; } + if (!recheckNemoClawManagedPolicy(sandboxName, operation, authority)) return false; + const sandbox = registry.getSandbox(sandboxName); if (sandbox) { const pols = sandbox.policies || []; @@ -2552,6 +2988,12 @@ function applyPresets(sandboxName: string, presetNames: string[]): boolean { registry.updateSandbox(sandboxName, { policies: pols }); } + if (policyChanged) { + for (const preset of presetContents.filter((entry) => entry.state !== "match")) { + console.log(` Applied preset: ${preset.name}`); + } + } + return true; } @@ -2916,6 +3358,10 @@ function applyPermissivePolicy(sandboxName: string): void { ); } + const operation = "apply the permissive sandbox policy"; + const authority = inspectPolicyMutationAuthority(sandboxName, operation); + assertNemoClawManagedPolicy(authority, operation); + const policyPath = resolvePermissivePolicyPath(sandboxName); if (!fs.existsSync(policyPath)) { throw new Error(`Permissive policy not found: ${policyPath}`); @@ -2928,11 +3374,16 @@ function applyPermissivePolicy(sandboxName: string): void { console.log(" Applying permissive policy..."); assertOpenshellResolvable(); - if (materializedPolicy === policyDocument) { - run(buildPolicySetCommand(policyPath, sandboxName)); - } else { - setPolicyDocument(sandboxName, materializedPolicy); - } + recheckPolicyMutationAuthority(sandboxName, operation, authority); + setPolicyDocument(sandboxName, materializedPolicy, { gatewayName: authority.gatewayName }); + const observed = inspectPolicyMutationAuthority( + sandboxName, + operation, + authority.gatewayName, + true, + ); + assertRecordedPolicyAuthority(authority.authority, observed.authority, operation); + assertNemoClawManagedPolicy(observed, operation); console.log(" Applied permissive policy."); } diff --git a/src/lib/policy/merge.ts b/src/lib/policy/merge.ts index 97de8eeb29c..9ad0d375f54 100644 --- a/src/lib/policy/merge.ts +++ b/src/lib/policy/merge.ts @@ -2,8 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { + assertExternalPolicyRequirementContainment as assertCanonicalExternalPolicyRequirementContainment, + assertMatchingPolicyAuthority as assertCanonicalMatchingPolicyAuthority, parseOpenShellPolicy as parseCanonicalOpenShellPolicy, + parseSandboxPolicyAuthorityMetadata as parseCanonicalSandboxPolicyAuthorityMetadata, stripProviderComposedPolicies as stripCanonicalProviderComposedPolicies, + type OpenShellPolicyAuthority, + type SandboxPolicyAuthorityInspection, withoutProviderComposedPolicies as withoutCanonicalProviderComposedPolicies, } from "../../../nemoclaw/dist/shared/openshell-policy-boundary.cjs"; @@ -14,6 +19,12 @@ import type { JsonObject } from "../core/json-types"; // CommonJS wrapper is compiled. Keep this file implementation-free. export const parseOpenShellPolicy = parseCanonicalOpenShellPolicy; export const stripProviderComposedPolicies = stripCanonicalProviderComposedPolicies; +export const parseSandboxPolicyAuthorityMetadata = + parseCanonicalSandboxPolicyAuthorityMetadata; +export const assertMatchingPolicyAuthority = assertCanonicalMatchingPolicyAuthority; +export const assertExternalPolicyRequirementContainment = + assertCanonicalExternalPolicyRequirementContainment; +export type { OpenShellPolicyAuthority, SandboxPolicyAuthorityInspection }; export function withoutProviderComposedPolicies(policies: JsonObject): JsonObject { return withoutCanonicalProviderComposedPolicies(policies) as JsonObject; diff --git a/src/lib/policy/policy-apply-finality.test.ts b/src/lib/policy/policy-apply-finality.test.ts index 687d7231799..60a5207530b 100644 --- a/src/lib/policy/policy-apply-finality.test.ts +++ b/src/lib/policy/policy-apply-finality.test.ts @@ -7,15 +7,28 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { addCustomPolicy, getSandbox, resolveOpenshell, run, runCapture, updateSandbox } = - vi.hoisted(() => ({ - addCustomPolicy: vi.fn(), - getSandbox: vi.fn(), - resolveOpenshell: vi.fn(), - run: vi.fn(), - runCapture: vi.fn(), - updateSandbox: vi.fn(), - })); +const { + addCustomPolicy, + getSandbox, + inspectSandboxPolicyAuthority, + resolveOpenshell, + run, + runCapture, + updateSandbox, +} = vi.hoisted(() => ({ + addCustomPolicy: vi.fn(), + getSandbox: vi.fn(), + inspectSandboxPolicyAuthority: vi.fn(), + resolveOpenshell: vi.fn(), + run: vi.fn(), + runCapture: vi.fn(), + updateSandbox: vi.fn(), +})); + +vi.mock("../adapters/openshell/policy-authority", async (importOriginal) => ({ + ...(await importOriginal()), + inspectSandboxPolicyAuthority, +})); vi.mock("../runner", async (importOriginal) => ({ ...(await importOriginal()), @@ -139,13 +152,23 @@ describe("applyPresets finality when openshell rejects the composed policy", () run.mockReset(); runCapture.mockReset(); getSandbox.mockReset(); + inspectSandboxPolicyAuthority.mockReset(); updateSandbox.mockReset(); addCustomPolicy.mockReset(); resolveOpenshell.mockReset(); resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); + inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + }); runCapture.mockReturnValue(BASE_POLICY); - getSandbox.mockReturnValue({ name: SANDBOX, agent: "openclaw", policies: [] }); + getSandbox.mockReturnValue({ + name: SANDBOX, + agent: "openclaw", + policies: [], + policyAuthority: "nemoclaw-managed", + }); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); }); @@ -212,12 +235,22 @@ describe("single-preset mutations when openshell rejects the composed policy", ( run.mockReset(); runCapture.mockReset(); getSandbox.mockReset(); + inspectSandboxPolicyAuthority.mockReset(); updateSandbox.mockReset(); addCustomPolicy.mockReset(); resolveOpenshell.mockReset(); resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); - getSandbox.mockReturnValue({ name: SANDBOX, agent: "openclaw", policies: ["weather"] }); + inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + }); + getSandbox.mockReturnValue({ + name: SANDBOX, + agent: "openclaw", + policies: ["weather"], + policyAuthority: "nemoclaw-managed", + }); run.mockReturnValue(policySetResult(openshellRejection(REJECTION_MESSAGE))); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); @@ -261,12 +294,22 @@ describe("applyPresets temporary policy material under local I/O failure", () => run.mockReset(); runCapture.mockReset(); getSandbox.mockReset(); + inspectSandboxPolicyAuthority.mockReset(); updateSandbox.mockReset(); resolveOpenshell.mockReset(); resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); + inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + }); runCapture.mockReturnValue(BASE_POLICY); - getSandbox.mockReturnValue({ name: SANDBOX, agent: "openclaw", policies: [] }); + getSandbox.mockReturnValue({ + name: SANDBOX, + agent: "openclaw", + policies: [], + policyAuthority: "nemoclaw-managed", + }); run.mockReturnValue(policySetResult(openshellRejection("refused"))); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/lib/policy/policy-mutation-authority.test.ts b/src/lib/policy/policy-mutation-authority.test.ts new file mode 100644 index 00000000000..276d3fe7692 --- /dev/null +++ b/src/lib/policy/policy-mutation-authority.test.ts @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; + +const mocks = vi.hoisted(() => ({ + addCustomPolicy: vi.fn(), + beginBaselineExclusionTransition: vi.fn(), + getBaselineExclusions: vi.fn(), + getBaselineExclusionTransition: vi.fn(), + getSandbox: vi.fn(), + inspectSandboxPolicyAuthority: vi.fn(), + resolveOpenshell: vi.fn(), + run: vi.fn(), + runCapture: vi.fn(), + updateSandbox: vi.fn(), +})); + +vi.mock("../adapters/openshell/policy-authority", async (importOriginal) => ({ + ...(await importOriginal()), + inspectSandboxPolicyAuthority: mocks.inspectSandboxPolicyAuthority, +})); + +vi.mock("../adapters/openshell/resolve", async (importOriginal) => ({ + ...(await importOriginal()), + resolveOpenshell: mocks.resolveOpenshell, +})); + +vi.mock("../runner", async (importOriginal) => ({ + ...(await importOriginal()), + run: mocks.run, + runCapture: mocks.runCapture, +})); + +vi.mock("../state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + addCustomPolicy: mocks.addCustomPolicy, + beginBaselineExclusionTransition: mocks.beginBaselineExclusionTransition, + getBaselineExclusions: mocks.getBaselineExclusions, + getBaselineExclusionTransition: mocks.getBaselineExclusionTransition, + getSandbox: mocks.getSandbox, + updateSandbox: mocks.updateSandbox, +})); + +import { + applyPermissivePolicy, + applyPresetContent, + excludeBaselineEntry, + inspectPolicyRecoveryAuthority, + removePreset, + restoreBaselineEntry, +} from "./index"; +import { PolicyAuthorityRefusalError } from "../adapters/openshell/policy-authority"; + +const SANDBOX = "authority-9833"; +const BASE_POLICY = `version: 1 +network_policies: + existing: + endpoints: + - host: existing.example.com + port: 443 +`; +const WEATHER_PRESET = `preset: + name: weather + description: Read-only weather +network_policies: + weather: + name: weather + endpoints: + - host: wttr.in + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } +`; +const WEATHER_POLICY = YAML.parse(WEATHER_PRESET).network_policies.weather; + +function reportedErrors(): string { + return vi + .mocked(console.error) + .mock.calls.flat() + .map((entry) => String(entry)) + .join("\n"); +} + +describe("PolicyMutationAuthority", () => { + let sandbox: Record; + + beforeEach(() => { + for (const mock of Object.values(mocks)) mock.mockReset(); + sandbox = { + name: SANDBOX, + gatewayName: "nemoclaw", + policies: [], + }; + mocks.getSandbox.mockImplementation(() => sandbox); + mocks.getBaselineExclusions.mockReturnValue([]); + mocks.getBaselineExclusionTransition.mockReturnValue(null); + mocks.inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + }); + mocks.resolveOpenshell.mockReturnValue("/usr/local/bin/openshell"); + mocks.runCapture.mockReturnValue(BASE_POLICY); + mocks.run.mockReturnValue({ status: 0 }); + mocks.updateSandbox.mockImplementation((_name, updates) => { + sandbox = { ...sandbox, ...updates }; + return true; + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + it("accepts an externally supplied custom preset without setting or attributing it (#9833)", () => { + mocks.inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "externally-managed", + effectivePolicy: { network_policies: { weather: WEATHER_POLICY } }, + }); + + expect( + applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { + custom: { sourcePath: "/tmp/weather.yaml" }, + }), + ).toBe(true); + + expect(mocks.runCapture).not.toHaveBeenCalled(); + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.addCustomPolicy).not.toHaveBeenCalled(); + expect(mocks.updateSandbox).toHaveBeenCalledTimes(1); + expect(mocks.updateSandbox).toHaveBeenCalledWith(SANDBOX, { + policyAuthority: "externally-managed", + }); + }); + + it("records external authority before refusing a missing preset requirement (#9833)", () => { + mocks.inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "externally-managed", + effectivePolicy: { network_policies: {} }, + }); + + expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET)).toBe(false); + + expect(reportedErrors()).toContain("external policy authority"); + expect(reportedErrors()).toContain('"weather"'); + expect(reportedErrors()).not.toContain("wttr.in"); + expect(reportedErrors()).not.toContain("network_policies:"); + expect(mocks.runCapture).not.toHaveBeenCalled(); + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.updateSandbox).toHaveBeenCalledOnce(); + expect(sandbox).toEqual( + expect.objectContaining({ policyAuthority: "externally-managed", policies: [] }), + ); + }); + + it("reads external recovery authority without replacing the durable owner (#9833)", () => { + sandbox = { ...sandbox, policyAuthority: "nemoclaw-managed" }; + mocks.inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "externally-managed", + effectivePolicy: { network_policies: { weather: WEATHER_POLICY } }, + }); + + expect(inspectPolicyRecoveryAuthority(SANDBOX, "verify Shields recovery")).toMatchObject({ + authority: "externally-managed", + authorityRecordedNow: false, + gatewayName: "nemoclaw", + }); + expect(mocks.updateSandbox).not.toHaveBeenCalled(); + }); + + it("rechecks authority before policy set and refuses an ownership change (#9833)", () => { + mocks.inspectSandboxPolicyAuthority + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "externally-managed", effectivePolicy: {} }); + + expect(applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { nonFatal: true })).toBe(false); + + expect(mocks.inspectSandboxPolicyAuthority).toHaveBeenCalledTimes(2); + expect(mocks.runCapture).toHaveBeenCalledTimes(1); + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.updateSandbox).toHaveBeenCalledOnce(); + expect(sandbox).toEqual( + expect.objectContaining({ policyAuthority: "nemoclaw-managed", policies: [] }), + ); + expect(reportedErrors()).toContain("policy authority changed"); + expect(reportedErrors()).toContain("external policy authority"); + }); + + it("withholds single-preset success when the final registry check changes authority (#9833)", () => { + sandbox = { ...sandbox, policyAuthority: "nemoclaw-managed" }; + mocks.inspectSandboxPolicyAuthority + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "externally-managed", effectivePolicy: {} }); + + expect( + applyPresetContent(SANDBOX, "weather", WEATHER_PRESET, { + custom: { sourcePath: "/tmp/weather.yaml" }, + }), + ).toBe(false); + + expect(mocks.run).toHaveBeenCalledOnce(); + expect(mocks.addCustomPolicy).not.toHaveBeenCalled(); + expect(mocks.updateSandbox).not.toHaveBeenCalled(); + expect(console.log).not.toHaveBeenCalledWith(" Applied preset: weather"); + expect(reportedErrors()).toContain("policy authority changed"); + }); + + it("refuses external removal before reading or changing policy state (#9833)", () => { + sandbox = { ...sandbox, policyAuthority: "externally-managed", policies: ["weather"] }; + mocks.inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "externally-managed", + effectivePolicy: { network_policies: { weather: WEATHER_POLICY } }, + }); + + expect(removePreset(SANDBOX, "weather", { nonFatal: true })).toBe(false); + + expect(mocks.runCapture).not.toHaveBeenCalled(); + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.updateSandbox).not.toHaveBeenCalled(); + expect(reportedErrors()).toContain("external policy authority"); + }); + + it("refuses external baseline and permissive changes before side effects (#9833)", () => { + sandbox = { ...sandbox, policyAuthority: "externally-managed" }; + mocks.inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "externally-managed", + effectivePolicy: {}, + }); + + expect(excludeBaselineEntry(SANDBOX, "existing", "reviewed-digest", { nonFatal: true })).toBe( + false, + ); + expect(restoreBaselineEntry(SANDBOX, "existing", { nonFatal: true })).toBe(false); + expect(() => applyPermissivePolicy(SANDBOX)).toThrow(/external policy authority/); + + expect(mocks.getBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.beginBaselineExclusionTransition).not.toHaveBeenCalled(); + expect(mocks.runCapture).not.toHaveBeenCalled(); + expect(mocks.run).not.toHaveBeenCalled(); + expect(mocks.updateSandbox).not.toHaveBeenCalled(); + }); + + it("throws when permissive policy authority changes after the policy set (#9833)", () => { + sandbox = { ...sandbox, policyAuthority: "nemoclaw-managed" }; + mocks.inspectSandboxPolicyAuthority + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "externally-managed", effectivePolicy: {} }); + + expect(() => applyPermissivePolicy(SANDBOX)).toThrow(/policy authority changed/u); + + expect(mocks.run).toHaveBeenCalledOnce(); + expect(console.log).not.toHaveBeenCalledWith(" Applied permissive policy."); + }); + + it("throws a typed refusal when permissive policy authority changes before submission (#9833)", () => { + sandbox = { ...sandbox, policyAuthority: "nemoclaw-managed" }; + mocks.inspectSandboxPolicyAuthority + .mockReturnValueOnce({ authority: "nemoclaw-managed", effectivePolicy: {} }) + .mockReturnValueOnce({ authority: "externally-managed", effectivePolicy: {} }); + + expect(() => applyPermissivePolicy(SANDBOX)).toThrow(PolicyAuthorityRefusalError); + + expect(mocks.inspectSandboxPolicyAuthority).toHaveBeenCalledTimes(2); + expect(mocks.run).not.toHaveBeenCalled(); + expect(console.log).not.toHaveBeenCalledWith(" Applied permissive policy."); + expect(reportedErrors()).toBe(""); + }); +}); diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index 50743597c1b..33923017e3b 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -686,7 +686,7 @@ describe("shields command flow", () => { expect(Date.now() - startedAt).toBeGreaterThanOrEqual(100); expect(fs.existsSync(transitionPath)).toBe(true); expect(harness.runSpy).toHaveBeenCalledWith( - ["openshell", "policy", "set"], + ["openshell", "policy", "set", "-g", "nemoclaw"], expect.objectContaining({ ignoreError: true }), ); }); @@ -946,7 +946,7 @@ describe("shields command flow", () => { }); expect(fs.existsSync(markerPath)).toBe(false); expect(harness.runSpy).toHaveBeenCalledWith( - ["openshell", "policy", "set"], + ["openshell", "policy", "set", "-g", "nemoclaw"], expect.objectContaining({ ignoreError: true }), ); expect(harness.logSpy).toHaveBeenCalledWith(" Shields: UP (lockdown active)"); @@ -1211,7 +1211,7 @@ describe("shields command flow", () => { ).toMatchObject({ shieldsDown: false, shieldsDownAt: null }); expect(fs.existsSync(markerPath)).toBe(false); expect(harness.runSpy).toHaveBeenCalledWith( - ["openshell", "policy", "set"], + ["openshell", "policy", "set", "-g", "nemoclaw"], expect.objectContaining({ ignoreError: true }), ); expect(harness.logSpy).toHaveBeenCalledWith(" Shields: UP (lockdown active)"); @@ -1246,7 +1246,7 @@ describe("shields command flow", () => { ).toMatchObject({ shieldsDown: false, shieldsDownAt: null }); expect(fs.existsSync(markerPath)).toBe(false); expect(harness.runSpy).toHaveBeenCalledWith( - ["openshell", "policy", "set"], + ["openshell", "policy", "set", "-g", "nemoclaw"], expect.objectContaining({ ignoreError: true }), ); }); @@ -1332,7 +1332,7 @@ describe("shields command flow", () => { expect(fs.existsSync(containmentPath)).toBe(true); expect(fs.existsSync(sandboxMutationLockPath)).toBe(false); expect(harness.runSpy).not.toHaveBeenCalledWith( - ["openshell", "policy", "set"], + ["openshell", "policy", "set", "-g", "nemoclaw"], expect.anything(), ); }); @@ -1385,7 +1385,7 @@ describe("shields command flow", () => { expect(fs.existsSync(timerMarkerPath)).toBe(true); expect(fs.existsSync(transitionLockPath)).toBe(true); expect(harness.runSpy).not.toHaveBeenCalledWith( - ["openshell", "policy", "set"], + ["openshell", "policy", "set", "-g", "nemoclaw"], expect.anything(), ); }); diff --git a/src/lib/shields/index.test.ts b/src/lib/shields/index.test.ts index df79f091573..e652ca86629 100644 --- a/src/lib/shields/index.test.ts +++ b/src/lib/shields/index.test.ts @@ -406,6 +406,12 @@ describe("shields — unit logic", () => { expect(deriveShieldsMode({}, false)).toBe("mutable_default"); expect(deriveShieldsMode({ shieldsDown: true }, true)).toBe("temporarily_unlocked"); + expect( + deriveShieldsMode( + { shieldsDown: true, policyRecoveryConfigLocked: true }, + true, + ), + ).toBe("locked_recovery"); expect(deriveShieldsMode({ shieldsDown: false }, true)).toBe("locked"); expect(deriveShieldsMode({}, true)).toBe("mutable_default"); }, @@ -459,6 +465,8 @@ describe("shields — unit logic", () => { }); describe("NC-3112: status self-heals stale expired auto-restore markers", () => { + const readyPolicyRecovery = () => ({ status: "ready" as const }); + async function loadShieldsModule() { const sourceModulePath = path.join(process.cwd(), "src", "lib", "shields", "index.ts"); return import(sourceModulePath); @@ -546,7 +554,7 @@ describe("shields — unit logic", () => { const { shieldsStatus } = await loadShieldsModule(); - shieldsStatus(sandboxName); + shieldsStatus(sandboxName, true, { inspectPolicyRecovery: readyPolicyRecovery }); expect(processKillSpy).not.toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalledWith( @@ -565,7 +573,7 @@ describe("shields — unit logic", () => { expect(composition.yaml).not.toContain("mcp_bridge_beta"); }); - it("deadline restore removes saved MCP keys when the registry cannot be read", async () => { + it("deadline restore refuses policy mutation when authority cannot be read (#9833)", async () => { const sandboxName = "openclaw"; const processToken = "b".repeat(32); const snapshotPath = path.join(stateDir(), "policy-snapshot-unreadable-registry.yaml"); @@ -600,25 +608,21 @@ describe("shields — unit logic", () => { originalRmSync(target, options); }); const { applyShieldsPolicySnapshot } = await loadShieldsModule(); + const { run } = await import("../runner"); - const result = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { - transitionProcessToken: processToken, - deadlineAuthoritative: true, - expiredTimerRecovery: true, - }); - - expect(result.managedMcpOmissions).toEqual([ - expect.objectContaining({ - reason: expect.stringMatching( - /Managed MCP registry inspection failed at the auto-restore deadline/, - ), + expect(() => + applyShieldsPolicySnapshot(sandboxName, snapshotPath, { + transitionProcessToken: processToken, + deadlineAuthoritative: true, + expiredTimerRecovery: true, }), - ]); - expect(appliedPolicy).toContain("restrictive_baseline"); - expect(appliedPolicy).not.toContain("mcp_bridge_alpha"); + ).toThrow(/policy authority/i); + + expect(run).not.toHaveBeenCalled(); + expect(appliedPolicy).toBe(""); }); - it("auto-restore applies a snapshot with no managed MCP entries when policy staging is unavailable (#7952)", async () => { + it("auto-restore refuses before policy staging when authority is unavailable (#9833)", async () => { const sandboxName = "openclaw"; const processToken = "d".repeat(32); const snapshotPath = path.join(stateDir(), "policy-snapshot-no-managed-mcp.yaml"); @@ -645,26 +649,16 @@ describe("shields — unit logic", () => { }); }); - const result = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { - transitionProcessToken: processToken, - deadlineAuthoritative: true, - expiredTimerRecovery: true, - }); + expect(() => + applyShieldsPolicySnapshot(sandboxName, snapshotPath, { + transitionProcessToken: processToken, + deadlineAuthoritative: true, + expiredTimerRecovery: true, + }), + ).toThrow(/policy authority inspection failed/i); - expect(result.status).toBe(0); expect(createTempDirectory).not.toHaveBeenCalled(); - expect(run).toHaveBeenCalledWith( - [ - expect.stringMatching(/(?:^|\/)openshell$/), - "policy", - "set", - "--policy", - snapshotPath, - "--wait", - sandboxName, - ], - { ignoreError: true }, - ); + expect(run).not.toHaveBeenCalled(); }); it("reuses the snapshot without staging when the snapshot and current policy have no managed MCP entries (#7952)", async () => { @@ -739,7 +733,7 @@ describe("shields — unit logic", () => { const { shieldsStatus } = await loadShieldsModule(); - shieldsStatus(sandboxName); + shieldsStatus(sandboxName, true, { inspectPolicyRecovery: readyPolicyRecovery }); expect(logSpy).toHaveBeenCalledWith(" Shields: DOWN (temporarily unlocked)"); expect(errorSpy).toHaveBeenCalledWith( @@ -854,7 +848,7 @@ describe("shields — unit logic", () => { ); const { shieldsStatus } = await loadShieldsModule(); - shieldsStatus(sandboxName); + shieldsStatus(sandboxName, true, { inspectPolicyRecovery: readyPolicyRecovery }); expect(errorSpy).toHaveBeenCalledWith( " Warning: auto-restore timer authority is expired, invalid, or no longer live; attempting inline restore.", diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 7ce863ee190..f4031777dc4 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -25,6 +25,8 @@ const fs = require("fs"); const path = require("path"); const { fork } = require("child_process"); const { createHash, randomBytes } = require("crypto"); +const { isDeepStrictEqual } = require("util"); +const YAML: typeof import("yaml") = require("yaml"); const { CLI_NAME }: typeof import("../cli/branding") = require("../cli/branding"); const { isObjectRecord }: typeof import("../core/json-types") = require("../core/json-types"); const { @@ -41,6 +43,13 @@ const { buildPolicySetCommand, parseCurrentPolicy, resolvePermissivePolicyPath, + assertNemoClawManagedPolicy, + inspectPolicyMutationAuthority, + inspectPolicyRecoveryAuthority, + isExternalPolicyAuthorityRefusalError, + isPolicyAuthorityRefusalError, + recheckPolicyMutationAuthority, + rejectFinalPolicySetResult: rejectFinalShieldsPolicySetResult, } = require("../policy"); const { parseDuration, MAX_SECONDS, DEFAULT_SECONDS } = require("../domain/duration"); const { @@ -72,9 +81,12 @@ const { buildDeadlineRuntimeManagedMcpPolicy, buildRuntimeManagedMcpPolicy, buildRuntimePermissivePolicy, + describeCanonicalPolicyReference, hasManagedMcpPolicyClaims, inspectExactManagedMcpPolicies, inspectProvableManagedMcpPoliciesForDeadline, + inspectRecordedManagedMcpPolicies, + serializeCanonicalPolicy, }: typeof import("./permissive-runtime") = require("./permissive-runtime"); const { cleanupTempDir } = require("../onboard/temp-files"); const { verifyShieldsLockState }: typeof import("./verify-lock") = require("./verify-lock"); @@ -135,9 +147,275 @@ const { }: typeof import("./hermes-runtime-state-mutation") = require("./hermes-runtime-state-mutation"); type MutableConfigPermsInspection = import("./mutable-config-perms").MutableConfigPermsInspection; type MutableConfigRepairResult = import("./mutable-config-perms").MutableConfigRepairResult; +type MutableConfigPostureMode = import("./mutable-config-perms").MutableConfigPostureMode; type AgentStateLockPlan = import("../agent/definition-types").AgentStateLockPlan; type ManagedMcpPolicyOmission = import("./permissive-runtime").ManagedMcpPolicyOmission; type TimerMarker = import("./timer-control").TimerMarker; +type PolicyMutationAuthority = ReturnType; + +/** Require the registry-bound live authority before a Shields-owned policy mutation. */ +function assertShieldsPolicyMutationAuthority( + sandboxName: string, + operation: string, + recorded?: PolicyMutationAuthority, +): PolicyMutationAuthority { + const authority = recorded + ? recheckPolicyMutationAuthority(sandboxName, operation, recorded) + : inspectPolicyMutationAuthority(sandboxName, operation); + assertNemoClawManagedPolicy(authority, operation); + return authority; +} + +function readShieldsPolicySnapshot(snapshotPath: string): Record { + let parsed: unknown; + try { + parsed = YAML.parse(fs.readFileSync(snapshotPath, "utf-8")); + } catch (error) { + throw new Error("The saved restrictive Shields policy snapshot is unreadable or invalid", { + cause: error, + }); + } + if (!isObjectRecord(parsed)) { + throw new Error("The saved restrictive Shields policy snapshot is not a policy mapping"); + } + return parsed; +} + +function externalPolicyMatchesShieldsPolicy( + authority: PolicyMutationAuthority, + requiredPolicy: Record, +): boolean { + return isDeepStrictEqual(authority.inspection.effectivePolicy, requiredPolicy); +} + +function requiredPolicyReference(requiredPolicy: Record): string { + return describeCanonicalPolicyReference(requiredPolicy); +} + +type ExternalPolicyRecoveryReason = "authority-drift" | "policy-mismatch"; + +class ExternalShieldsPolicyRecoveryError extends Error { + constructor( + readonly reason: ExternalPolicyRecoveryReason, + message: string, + readonly recoveryArtifact?: BoundShieldsPolicyArtifact, + ) { + super(message); + this.name = "ExternalShieldsPolicyRecoveryError"; + } +} + +function externalPolicyRecoveryArtifactText( + recoveryArtifact: BoundShieldsPolicyArtifact | undefined, +): string { + return recoveryArtifact + ? ` Complete required policy: ${recoveryArtifact.path}.` + : " The complete policy handoff is unavailable; run Shields up to regenerate it only after the recorded external authority is restored."; +} + +function externalPolicyRecoveryHandoff( + sandboxName: string, + requiredPolicy: Record, + reason: ExternalPolicyRecoveryReason, + recoveryArtifact?: BoundShieldsPolicyArtifact, +): string { + const reference = `Required effective policy reference: ${requiredPolicyReference(requiredPolicy)}.`; + const artifact = externalPolicyRecoveryArtifactText(recoveryArtifact); + if (reason === "authority-drift") { + return ( + `Policy authority changed while NemoClaw verified recovery for sandbox '${sandboxName}'. ${reference}${artifact} ` + + "Stop without applying the handoff or retrying Shields. Restore the recorded externally managed authority through its owning OpenShell configuration, or ask a NemoClaw maintainer for recovery direction if the authority change was intentional. NemoClaw will not change policy authority." + ); + } + return ( + `The effective policy for sandbox '${sandboxName}' does not match the required restrictive policy. ${reference}${artifact} ` + + "The external policy authority must make the effective policy for this named sandbox match the complete handoff, including current managed MCP entries. " + + `Then run \`${CLI_NAME} ${sandboxName} shields up\`. NemoClaw will verify the exact effective policy without changing policy authority before it completes Shields recovery.` + ); +} + +function externalPolicyVerifiedHandoff( + sandboxName: string, + requiredPolicy: Record, + configAlreadyLocked: boolean, + recoveryArtifact?: BoundShieldsPolicyArtifact, +): string { + const completion = configAlreadyLocked + ? `Run \`${CLI_NAME} ${sandboxName} shields up\` to commit Shields UP. Configuration is already locked; NemoClaw will reverify policy without changing policy authority.` + : `Run \`${CLI_NAME} ${sandboxName} shields up\` to lock configuration and commit Shields UP. NemoClaw will reverify policy without changing policy authority.`; + return ( + `NemoClaw verified the required effective policy for sandbox '${sandboxName}' (${requiredPolicyReference(requiredPolicy)}). ` + + `${externalPolicyRecoveryArtifactText(recoveryArtifact).trimStart()} ` + + completion + ); +} + +type ShieldsPolicySnapshotRestoreAuthority = { + authority: PolicyMutationAuthority; + policyMutationAllowed: boolean; +}; + +function inspectShieldsPolicySnapshotRestoreAuthority( + sandboxName: string, + recorded?: PolicyMutationAuthority, +): PolicyMutationAuthority { + if (recorded?.authority === "externally-managed") { + return inspectPolicyRecoveryAuthority( + sandboxName, + "verify the externally restored Shields policy snapshot", + recorded.gatewayName, + ); + } + let authority: PolicyMutationAuthority; + try { + authority = recorded + ? recheckPolicyMutationAuthority(sandboxName, "restore the Shields policy snapshot", recorded) + : inspectPolicyMutationAuthority(sandboxName, "restore the Shields policy snapshot"); + } catch (error) { + if (!isExternalPolicyAuthorityRefusalError(error)) throw error; + authority = inspectPolicyRecoveryAuthority( + sandboxName, + "verify the externally restored Shields policy snapshot", + recorded?.gatewayName, + ); + } + return authority; +} + +function resolveShieldsPolicySnapshotRestoreAuthority( + sandboxName: string, + requiredPolicy: Record, + recorded?: PolicyMutationAuthority, + recoveryArtifact?: BoundShieldsPolicyArtifact, +): ShieldsPolicySnapshotRestoreAuthority { + if ( + recorded?.authority === "externally-managed" && + !externalPolicyMatchesShieldsPolicy(recorded, requiredPolicy) + ) { + throw new ExternalShieldsPolicyRecoveryError( + "policy-mismatch", + externalPolicyRecoveryHandoff( + sandboxName, + requiredPolicy, + "policy-mismatch", + recoveryArtifact, + ), + recoveryArtifact, + ); + } + const authority = inspectShieldsPolicySnapshotRestoreAuthority(sandboxName, recorded); + if (recorded && authority.authority !== recorded.authority) { + throw new ExternalShieldsPolicyRecoveryError( + "authority-drift", + externalPolicyRecoveryHandoff( + sandboxName, + requiredPolicy, + "authority-drift", + recoveryArtifact, + ), + recoveryArtifact, + ); + } + if (authority.authority === "nemoclaw-managed") { + return { authority, policyMutationAllowed: true }; + } + if (!externalPolicyMatchesShieldsPolicy(authority, requiredPolicy)) { + throw new ExternalShieldsPolicyRecoveryError( + "policy-mismatch", + externalPolicyRecoveryHandoff( + sandboxName, + requiredPolicy, + "policy-mismatch", + recoveryArtifact, + ), + recoveryArtifact, + ); + } + const revalidated = inspectPolicyRecoveryAuthority( + sandboxName, + "finish the externally restored Shields policy snapshot verification", + authority.gatewayName, + ); + if (revalidated.authority !== "externally-managed") { + throw new ExternalShieldsPolicyRecoveryError( + "authority-drift", + externalPolicyRecoveryHandoff( + sandboxName, + requiredPolicy, + "authority-drift", + recoveryArtifact, + ), + recoveryArtifact, + ); + } + if (!externalPolicyMatchesShieldsPolicy(revalidated, requiredPolicy)) { + throw new ExternalShieldsPolicyRecoveryError( + "policy-mismatch", + externalPolicyRecoveryHandoff( + sandboxName, + requiredPolicy, + "policy-mismatch", + recoveryArtifact, + ), + recoveryArtifact, + ); + } + return { authority: revalidated, policyMutationAllowed: false }; +} + +type ShieldsPolicyRecoveryInspection = + | { status: "ready" } + | { status: "external"; handoff: string } + | { status: "unavailable"; detail: string }; + +function inspectShieldsPolicyRecovery(sandboxName: string): ShieldsPolicyRecoveryInspection { + const state = loadShieldsState(sandboxName); + let authority: PolicyMutationAuthority; + try { + authority = inspectShieldsPolicySnapshotRestoreAuthority(sandboxName); + } catch (error) { + return { + status: "unavailable", + detail: error instanceof Error ? error.message : String(error), + }; + } + if (authority.authority === "nemoclaw-managed" && !state.policyRecoveryConfigLocked) { + return { status: "ready" }; + } + const snapshotPath = state.shieldsPolicySnapshotPath; + if (!snapshotPath || !fs.existsSync(snapshotPath)) { + return { + status: "external", + handoff: `The saved restrictive policy snapshot for sandbox '${sandboxName}' is unavailable. Rebuild the sandbox before finishing the Shields transition.`, + }; + } + try { + const result = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { + externalVerificationOnly: true, + }); + if (!result.externalRequiredPolicy) { + throw new Error("External Shields policy verification did not return its required policy"); + } + return { + status: "external", + handoff: externalPolicyVerifiedHandoff( + sandboxName, + result.externalRequiredPolicy, + state.policyRecoveryConfigLocked === true, + result.externalPolicyRecoveryArtifact, + ), + }; + } catch (error) { + if (error instanceof ExternalShieldsPolicyRecoveryError) { + return { status: "external", handoff: error.message }; + } + return { + status: "unavailable", + detail: error instanceof Error ? error.message : String(error), + }; + } +} + const STATE_DIR = resolveShieldsStateDir(); const SHIELDS_TRANSITION_POLL_MS = 50; const SHIELDS_TRANSITION_HANDOFF_GRACE_MS = 500; @@ -491,11 +769,11 @@ function publishShieldsDownForwardPolicy( } } -function requireBoundShieldsPolicyArtifact( +function readBoundShieldsPolicyArtifact( binding: BoundShieldsPolicyArtifact, expectedPath: string, label: string, -): string { +): Buffer { if (binding.path !== expectedPath || path.normalize(binding.path) !== binding.path) { throw new Error(`${label} path no longer matches its authority`); } @@ -529,12 +807,21 @@ function requireBoundShieldsPolicyArtifact( ) { throw new Error(`${label} no longer matches its binding`); } - return binding.path; + return content; } finally { if (fd !== undefined) fs.closeSync(fd); } } +function requireBoundShieldsPolicyArtifact( + binding: BoundShieldsPolicyArtifact, + expectedPath: string, + label: string, +): string { + readBoundShieldsPolicyArtifact(binding, expectedPath, label); + return binding.path; +} + function requireShieldsDownForwardPolicy(transition: ShieldsDownTransition): string { const binding = transition.forwardPolicy; const expectedPath = shieldsDownForwardPolicyPath( @@ -1417,11 +1704,151 @@ function stateFilePath(sandboxName: string): string { return path.join(STATE_DIR, `shields-${sandboxName}.json`); } -// Three-state shields model: +function externalPolicyRecoveryArtifactPath(sandboxName: string): string { + return path.join(STATE_DIR, `shields-external-policy-${sandboxName}.yaml`); +} + +function publishExternalPolicyRecoveryArtifact( + sandboxName: string, + requiredPolicy: Record, +): BoundShieldsPolicyArtifact { + const artifactPath = externalPolicyRecoveryArtifactPath(sandboxName); + const content = serializeCanonicalPolicy(requiredPolicy); + writeShieldsFileAtomicDurable(artifactPath, content); + return describeBoundShieldsPolicyArtifact( + artifactPath, + content, + fs.lstatSync(artifactPath), + "External Shields policy recovery artifact", + ); +} + +function validatedExternalPolicyRecoveryArtifact( + sandboxName: string, + requiredPolicy: Record, + binding: BoundShieldsPolicyArtifact | undefined, +): BoundShieldsPolicyArtifact | undefined { + if (!binding) return undefined; + try { + requireBoundShieldsPolicyArtifact( + binding, + externalPolicyRecoveryArtifactPath(sandboxName), + "External Shields policy recovery artifact", + ); + return isDeepStrictEqual(readShieldsPolicySnapshot(binding.path), requiredPolicy) + ? binding + : undefined; + } catch { + return undefined; + } +} + +function restoreExternalPolicyRecoveryArtifact( + binding: BoundShieldsPolicyArtifact, + content: Buffer, +): void { + writeShieldsFileAtomicDurable(binding.path, content); + requireBoundShieldsPolicyArtifact( + binding, + binding.path, + "External Shields policy recovery artifact", + ); +} + +function commitExternalPolicyRecoveryArtifactRetirement( + sandboxName: string, + commitState: () => void, +): void { + const artifactPath = externalPolicyRecoveryArtifactPath(sandboxName); + const originalState = loadShieldsState(sandboxName); + const binding = originalState.externalPolicyRecoveryArtifact; + let content: Buffer | undefined; + if (binding) { + try { + content = readBoundShieldsPolicyArtifact( + binding, + artifactPath, + "External Shields policy recovery artifact", + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + try { + fs.rmSync(artifactPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + commitState(); + return; + } + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not remove external Shields policy recovery artifact '${artifactPath}': ${detail}`, + { cause: error }, + ); + } + try { + fsyncShieldsStateDirectory(); + } catch (error) { + let rollbackDetail = "the artifact had no durable state binding to restore"; + if (binding && content) { + try { + restoreExternalPolicyRecoveryArtifact(binding, content); + rollbackDetail = "restored its bound content"; + } catch (rollbackError) { + rollbackDetail = `could not restore its bound content: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`; + } + } + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not make removal of external Shields policy recovery artifact '${artifactPath}' durable; ${rollbackDetail}: ${detail}`, + { cause: error }, + ); + } + + try { + commitState(); + } catch (error) { + const rollbackErrors: string[] = []; + let artifactRestored = false; + if (binding && content) { + try { + restoreExternalPolicyRecoveryArtifact(binding, content); + artifactRestored = true; + } catch (rollbackError) { + rollbackErrors.push( + `artifact restore failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + } + try { + restoreShieldsStateSnapshot(sandboxName, originalState); + } catch (rollbackError) { + rollbackErrors.push( + `state restore failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ); + } + const detail = error instanceof Error ? error.message : String(error); + const rollbackDetail = + rollbackErrors.length === 0 + ? artifactRestored + ? "restored the bound artifact and Shields state" + : "restored Shields state; no bound artifact was available to restore" + : `rollback incomplete (${rollbackErrors.join("; ")})`; + throw new Error( + `Could not commit Shields state after removing external policy recovery artifact '${artifactPath}'; ${rollbackDetail}: ${detail}`, + { cause: error }, + ); + } +} + +// Shields posture model: // "mutable_default" — fresh sandbox, shields never configured (the default) // "locked" — shields up has been run and verified // "temporarily_unlocked" — shields down after a prior shields up -type ShieldsMode = "mutable_default" | "locked" | "temporarily_unlocked"; +// "locked_recovery" — config is locked while policy recovery remains incomplete +type ShieldsMode = "locked" | "locked_recovery" | "mutable_default" | "temporarily_unlocked"; type ShieldsPostureMode = ShieldsMode | "error"; interface ShieldsState { @@ -1433,6 +1860,8 @@ interface ShieldsState { shieldsPolicySnapshotPath?: string | null; /** Exact generated MCP keys owned in the restrictive snapshot. */ shieldsManagedMcpPolicyKeys?: string[]; + policyRecoveryConfigLocked?: boolean; + externalPolicyRecoveryArtifact?: BoundShieldsPolicyArtifact; 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 @@ -1620,6 +2049,9 @@ function completeDeferredShieldsExit(error: unknown, shouldThrow = false): never */ function deriveShieldsMode(state: ShieldsState, hasStateFile: boolean): ShieldsMode { if (!hasStateFile) return "mutable_default"; + if (state.shieldsDown === true && state.policyRecoveryConfigLocked === true) { + return "locked_recovery"; + } if (state.shieldsDown === true) return "temporarily_unlocked"; if (state.shieldsDown === false) return "locked"; // State file exists but shieldsDown is undefined — treat as mutable default @@ -1668,6 +2100,14 @@ function describeShieldsMode(mode: ShieldsPostureMode): Omit typeof key === "string"); } +function isOptionalBoundShieldsPolicyArtifact( + value: unknown, +): value is BoundShieldsPolicyArtifact | undefined { + return value === undefined || isBoundShieldsPolicyArtifact(value); +} + function isShieldsState(value: unknown): value is ShieldsState { return ( isObjectRecord(value) && @@ -2198,6 +2644,8 @@ function isShieldsState(value: unknown): value is ShieldsState { isOptionalNullableString(value.shieldsDownPolicy) && isOptionalNullableString(value.shieldsPolicySnapshotPath) && isOptionalManagedMcpPolicyKeys(value.shieldsManagedMcpPolicyKeys) && + isOptionalBoolean(value.policyRecoveryConfigLocked) && + isOptionalBoundShieldsPolicyArtifact(value.externalPolicyRecoveryArtifact) && isOptionalBoolean(value.chattrApplied) && isOptionalHashMap(value.fileHashes) && isOptionalString(value.updatedAt) @@ -3415,6 +3863,10 @@ function unlockAgentConfig( // the contract without weakening an active shields-up lock. // --------------------------------------------------------------------------- +function mutableConfigPostureMode(mode: ShieldsPostureMode): MutableConfigPostureMode { + return mode === "locked_recovery" ? "locked" : mode; +} + function inspectMutableConfigPerms(sandboxName: string): MutableConfigPermsInspection { validateName(sandboxName, "sandbox name"); return withExpiredAutoRestoreDeadlineFence( @@ -3424,7 +3876,9 @@ function inspectMutableConfigPerms(sandboxName: string): MutableConfigPermsInspe const target = ensureConfigHashSensitiveFile(resolveAgentConfig(sandboxName)); return inspectMutableConfigPermsCore( target, - getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery).mode, + mutableConfigPostureMode( + getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery).mode, + ), (p) => privilegedSandboxExecCapture(sandboxName, ["stat", "-c", "%a %U:%G", p]), ); }, @@ -3440,7 +3894,9 @@ function repairMutableConfigPerms(sandboxName: string): MutableConfigRepairResul const target = ensureConfigHashSensitiveFile(resolveAgentConfig(sandboxName)); return repairMutableConfigPermsCore( target, - getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery).mode, + mutableConfigPostureMode( + getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery).mode, + ), () => normalizeMutableOpenClawConfig(sandboxName, target.configDir), ); }, @@ -4039,12 +4495,13 @@ function describeRollbackTimerAuthority( function resolveExactManagedMcpPolicies( sandboxName: string, livePolicyYaml?: string, + gatewayName?: string, ): ReturnType { let effectiveLivePolicy = livePolicyYaml; if (!effectiveLivePolicy) { let rawPolicy: string; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName, gatewayName)); } catch (error) { throw new Error("Cannot read the live gateway policy for managed MCP reconciliation", { cause: error, @@ -4060,11 +4517,14 @@ function resolveExactManagedMcpPolicies( function resolveProvableManagedMcpPoliciesForDeadline( sandboxName: string, + gatewayName?: string, ): ReturnType { try { let effectiveLivePolicy = ""; try { - effectiveLivePolicy = parseCurrentPolicy(runCapture(buildPolicyGetCommand(sandboxName))); + effectiveLivePolicy = parseCurrentPolicy( + runCapture(buildPolicyGetCommand(sandboxName, gatewayName)), + ); } catch { // The tolerant deadline inspector records exact omissions for every claim // when the live policy cannot be parsed or read. @@ -4093,12 +4553,17 @@ interface ShieldsPolicySnapshotRestoreOptions { transitionProcessToken?: string; deadlineAuthoritative?: boolean; expiredTimerRecovery?: boolean; + externalVerificationOnly?: boolean; + persistExternalRecoveryArtifact?: boolean; buildPolicySet?: typeof buildPolicySetCommand; runPolicySet?: typeof run; } type ShieldsPolicySnapshotRestoreResult = ReturnType & { managedMcpOmissions?: ManagedMcpPolicyOmission[]; + externalPolicyVerified?: true; + externalRequiredPolicy?: Record; + externalPolicyRecoveryArtifact?: BoundShieldsPolicyArtifact; }; function applyShieldsPolicySnapshot( @@ -4158,6 +4623,8 @@ function applyShieldsPolicySnapshot( throw new Error("Shields state does not match the policy snapshot being restored"); } const persistedSnapshotMatches = state.shieldsPolicySnapshotPath === snapshotPath; + const policyAuthority = inspectShieldsPolicySnapshotRestoreAuthority(sandboxName); + const policyMutationAllowed = policyAuthority.authority === "nemoclaw-managed"; const ownershipOmissions: ManagedMcpPolicyOmission[] = []; if ( transition?.managedMcpPolicyKeys !== undefined && @@ -4188,20 +4655,39 @@ function applyShieldsPolicySnapshot( reason: "Legacy Shields state had no managed MCP ownership manifest at the auto-restore deadline", }); + } else if (policyMutationAllowed && !options.externalVerificationOnly) { + assertLegacyMcpPolicyRestoreSafe( + fs.readFileSync(snapshotPath, "utf-8"), + hasManagedMcpPolicyClaims(sandboxName), + ); + assertShieldsPolicyMutationAuthority( + sandboxName, + "restore the Shields policy snapshot", + policyAuthority, + ); + const result = runPolicySet( + buildPolicySet(snapshotPath, sandboxName, policyAuthority.gatewayName), + { + ignoreError: true, + }, + ); + rejectFinalShieldsPolicySetResult(result, "restore the Shields policy snapshot"); + return result; } else { assertLegacyMcpPolicyRestoreSafe( fs.readFileSync(snapshotPath, "utf-8"), hasManagedMcpPolicyClaims(sandboxName), ); - return runPolicySet(buildPolicySet(snapshotPath, sandboxName), { - ignoreError: true, - }); + snapshotManagedPolicyKeys = []; } } let managedMcpOmissions: ManagedMcpPolicyOmission[] = []; let runtimePolicyPath: string; if (options.deadlineAuthoritative) { - const inspection = resolveProvableManagedMcpPoliciesForDeadline(sandboxName); + const inspection = resolveProvableManagedMcpPoliciesForDeadline( + sandboxName, + policyAuthority.gatewayName, + ); const runtime = buildDeadlineRuntimeManagedMcpPolicy(snapshotPath, { managedMcpPolicies: inspection.policies, snapshotManagedPolicyKeys, @@ -4210,7 +4696,10 @@ function applyShieldsPolicySnapshot( runtimePolicyPath = runtime.path; managedMcpOmissions = [...ownershipOmissions, ...inspection.omissions, ...runtime.omissions]; } else { - const managedMcpPolicies = resolveExactManagedMcpPolicies(sandboxName); + const managedMcpPolicies = + policyMutationAllowed && !options.externalVerificationOnly + ? resolveExactManagedMcpPolicies(sandboxName, undefined, policyAuthority.gatewayName) + : inspectRecordedManagedMcpPolicies(sandboxName); runtimePolicyPath = buildRuntimeManagedMcpPolicy(snapshotPath, { managedMcpPolicies, snapshotManagedPolicyKeys, @@ -4219,9 +4708,64 @@ function applyShieldsPolicySnapshot( } const runtimePolicyIsTemp = runtimePolicyPath !== snapshotPath; try { - const result = runPolicySet(buildPolicySet(runtimePolicyPath, sandboxName), { - ignoreError: true, - }); + const externalRequiredPolicy = readShieldsPolicySnapshot(runtimePolicyPath); + let externalPolicyRecoveryArtifact = validatedExternalPolicyRecoveryArtifact( + sandboxName, + externalRequiredPolicy, + state.externalPolicyRecoveryArtifact, + ); + if ( + options.persistExternalRecoveryArtifact === true && + (!policyMutationAllowed || options.externalVerificationOnly === true) + ) { + externalPolicyRecoveryArtifact = publishExternalPolicyRecoveryArtifact( + sandboxName, + externalRequiredPolicy, + ); + } + if (options.externalVerificationOnly && policyMutationAllowed) { + throw new ExternalShieldsPolicyRecoveryError( + "authority-drift", + externalPolicyRecoveryHandoff( + sandboxName, + externalRequiredPolicy, + "authority-drift", + externalPolicyRecoveryArtifact, + ), + externalPolicyRecoveryArtifact, + ); + } + if (!policyMutationAllowed) { + resolveShieldsPolicySnapshotRestoreAuthority( + sandboxName, + externalRequiredPolicy, + policyAuthority, + externalPolicyRecoveryArtifact, + ); + return { + pid: process.pid, + output: [null, "", ""], + stdout: "", + stderr: "", + status: 0, + signal: null, + externalPolicyVerified: true, + externalRequiredPolicy: structuredClone(externalRequiredPolicy), + ...(externalPolicyRecoveryArtifact ? { externalPolicyRecoveryArtifact } : {}), + }; + } + assertShieldsPolicyMutationAuthority( + sandboxName, + "restore the Shields policy snapshot", + policyAuthority, + ); + const result = runPolicySet( + buildPolicySet(runtimePolicyPath, sandboxName, policyAuthority.gatewayName), + { + ignoreError: true, + }, + ); + rejectFinalShieldsPolicySetResult(result, "restore the Shields policy snapshot"); return managedMcpOmissions.length > 0 ? { ...result, managedMcpOmissions } : result; } finally { if (runtimePolicyIsTemp) { @@ -4332,6 +4876,7 @@ interface LockdownActivationResult { chattrApplied?: boolean; fileHashes?: { [path: string]: string }; managedMcpOmissions?: ManagedMcpPolicyOmission[]; + externalPolicyRecoveryArtifact?: BoundShieldsPolicyArtifact; } function activateLockdownFromSnapshot( @@ -4348,13 +4893,19 @@ function activateLockdownFromSnapshot( let restoreResult: ShieldsPolicySnapshotRestoreResult; try { - restoreResult = applyShieldsPolicySnapshot(sandboxName, snapshotPath, restoreOptions); + restoreResult = applyShieldsPolicySnapshot(sandboxName, snapshotPath, { + ...restoreOptions, + persistExternalRecoveryArtifact: true, + }); } catch (error) { return { ok: false, error: `policy restore preparation failed: ${ error instanceof Error ? error.message : String(error) }`, + ...(error instanceof ExternalShieldsPolicyRecoveryError && error.recoveryArtifact + ? { externalPolicyRecoveryArtifact: error.recoveryArtifact } + : {}), }; } const restoreStatus = typeof restoreResult.status === "number" ? restoreResult.status : 1; @@ -4362,6 +4913,9 @@ function activateLockdownFromSnapshot( return { ok: false, error: `policy restore exited with status ${String(restoreStatus)}`, + ...(restoreResult.externalPolicyRecoveryArtifact + ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } + : {}), }; } @@ -4378,6 +4932,9 @@ function activateLockdownFromSnapshot( return { ok: false, error: error instanceof Error ? error.message : String(error), + ...(restoreResult.externalPolicyRecoveryArtifact + ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } + : {}), }; } // Re-confirm the lock after a settle window. This restore feeds the @@ -4392,12 +4949,45 @@ function activateLockdownFromSnapshot( return { ok: false, error: relock.error ?? "config re-lock did not re-confirm after the settle window", + ...(restoreResult.externalPolicyRecoveryArtifact + ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } + : {}), + }; + } + try { + if (restoreResult.externalPolicyVerified) { + applyShieldsPolicySnapshot(sandboxName, snapshotPath, { + ...restoreOptions, + externalVerificationOnly: true, + }); + } else { + resolveShieldsPolicySnapshotRestoreAuthority( + sandboxName, + readShieldsPolicySnapshot(snapshotPath), + ); + } + } catch (error) { + return { + ok: false, + error: `policy verification after config lock failed: ${ + error instanceof Error ? error.message : String(error) + }`, + chattrApplied: relock.lastResult.chattrApplied, + fileHashes: relock.lastResult.fileHashes, + ...(error instanceof ExternalShieldsPolicyRecoveryError && error.recoveryArtifact + ? { externalPolicyRecoveryArtifact: error.recoveryArtifact } + : restoreResult.externalPolicyRecoveryArtifact + ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } + : {}), }; } return { ok: true, chattrApplied: relock.lastResult.chattrApplied, fileHashes: relock.lastResult.fileHashes, + ...(restoreResult.externalPolicyRecoveryArtifact + ? { externalPolicyRecoveryArtifact: restoreResult.externalPolicyRecoveryArtifact } + : {}), ...(restoreResult.managedMcpOmissions ? { managedMcpOmissions: restoreResult.managedMcpOmissions } : {}), @@ -4470,6 +5060,22 @@ function recoverExpiredAutoRestoreInline( ); const nowIso = new Date().toISOString(); if (!activation.ok) { + const configLocked = + activation.fileHashes !== undefined && typeof activation.chattrApplied === "boolean"; + if (configLocked || activation.externalPolicyRecoveryArtifact) { + saveShieldsState(sandboxName, { + ...(configLocked + ? { + policyRecoveryConfigLocked: true, + chattrApplied: activation.chattrApplied, + fileHashes: activation.fileHashes, + } + : {}), + ...(activation.externalPolicyRecoveryArtifact + ? { externalPolicyRecoveryArtifact: activation.externalPolicyRecoveryArtifact } + : {}), + }); + } appendAuditEntry({ action: "shields_up_failed", sandbox: sandboxName, @@ -4479,22 +5085,31 @@ function recoverExpiredAutoRestoreInline( error: `Inline auto-restore failed: ${activation.error ?? "unknown error"}`, }); console.error(" Recovery warning: inline auto-restore failed; shields remain DOWN."); + if (configLocked) { + console.error( + " Recovery warning: configuration remains locked while policy recovery waits.", + ); + } console.error(` Recovery warning: run \`nemoclaw ${sandboxName} shields up\` manually.`); return { attempted: true, restored: false }; } - saveShieldsState(sandboxName, { - shieldsDown: false, - shieldsDownAt: null, - shieldsDownTimeout: null, - shieldsDownReason: null, - shieldsDownPolicy: null, - ...(activation.fileHashes && typeof activation.chattrApplied === "boolean" - ? { - chattrApplied: activation.chattrApplied, - fileHashes: activation.fileHashes, - } - : {}), + commitExternalPolicyRecoveryArtifactRetirement(sandboxName, () => { + saveShieldsState(sandboxName, { + shieldsDown: false, + shieldsDownAt: null, + shieldsDownTimeout: null, + shieldsDownReason: null, + shieldsDownPolicy: null, + policyRecoveryConfigLocked: false, + externalPolicyRecoveryArtifact: undefined, + ...(activation.fileHashes && typeof activation.chattrApplied === "boolean" + ? { + chattrApplied: activation.chattrApplied, + fileHashes: activation.fileHashes, + } + : {}), + }); }); if (marker?.processToken && /^[0-9a-f]{32}$/.test(marker.processToken)) { clearShieldsDownTransition(sandboxName, marker.processToken); @@ -4636,9 +5251,21 @@ function applyRecoveredShieldsDownForwardPolicy( completion: RecoveredShieldsDownCompletion, ): void { if (!completion.authority) return; + const policyAuthority = assertShieldsPolicyMutationAuthority( + sandboxName, + "reapply the interrupted Shields down policy", + ); assertRecoveredShieldsDownAuthority(sandboxName, completion, completion.authority.phase); const policyPath = requireShieldsDownForwardPolicy(completion.authority); - const result = run(buildPolicySetCommand(policyPath, sandboxName), { ignoreError: true }); + assertShieldsPolicyMutationAuthority( + sandboxName, + "reapply the interrupted Shields down policy", + policyAuthority, + ); + const result = run(buildPolicySetCommand(policyPath, sandboxName, policyAuthority.gatewayName), { + ignoreError: true, + }); + rejectFinalShieldsPolicySetResult(result, "reapply the interrupted Shields down policy"); if (result.status !== 0) { throw new Error("Interrupted Shields down forward policy could not be reapplied"); } @@ -5016,6 +5643,14 @@ function shieldsDownWithoutHostLock( const reason = opts.reason || null; const policyName = opts.policy || "permissive"; if (state.shieldsDown) { + if (initialMode === "locked_recovery") { + console.error(` Config is locked for ${sandboxName} while policy recovery remains pending.`); + console.error(` Run \`${CLI_NAME} ${sandboxName} shields up\` to complete recovery first.`); + return failShieldsCommand( + `Policy recovery remains pending for ${sandboxName}`, + opts.throwOnError, + ); + } if (isEquivalentShieldsDownRequest(state, timeoutSeconds, reason, policyName)) { if (!hasEquivalentShieldsDownTimerAuthority(sandboxName, state)) { recoverExpiredAutoRestoreInline(sandboxName, state); @@ -5037,6 +5672,8 @@ function shieldsDownWithoutHostLock( return failShieldsCommand(`Config is already unlocked for ${sandboxName}`, opts.throwOnError); } + const policyAuthority = assertShieldsPolicyMutationAuthority(sandboxName, "lower Shields"); + // Resolve the old-image compatibility contract before touching timers, // host state, policy, or sandbox files. A transport failure or an // unsupported/incomplete guard must leave an ordinary shields command with @@ -5057,6 +5694,11 @@ function shieldsDownWithoutHostLock( // Kill stale auto-restore markers only when this command will actually // transition into shields-down. A repeated shields-down must not cancel the // active timer and leave the sandbox unlocked indefinitely. + assertShieldsPolicyMutationAuthority( + sandboxName, + "revoke stale Shields timer authority", + policyAuthority, + ); const timerCancellation = killTimer(sandboxName); if (!timerCancellation.authorityRevoked) { const detail = timerCancellation.warnings.join("; "); @@ -5076,10 +5718,11 @@ function shieldsDownWithoutHostLock( console.log(" Capturing current policy snapshot..."); let rawPolicy: string; try { - rawPolicy = runCapture(buildPolicyGetCommand(sandboxName)); + rawPolicy = runCapture(buildPolicyGetCommand(sandboxName, policyAuthority.gatewayName)); } catch { rawPolicy = ""; } + assertShieldsPolicyMutationAuthority(sandboxName, "continue lowering Shields", policyAuthority); const policyYaml = parseCurrentPolicy(rawPolicy); if (!policyYaml) { @@ -5089,7 +5732,11 @@ function shieldsDownWithoutHostLock( let managedMcpPolicies: ReturnType; try { - managedMcpPolicies = resolveExactManagedMcpPolicies(sandboxName, policyYaml); + managedMcpPolicies = resolveExactManagedMcpPolicies( + sandboxName, + policyYaml, + policyAuthority.gatewayName, + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(` Cannot preserve managed MCP policy state: ${message}`); @@ -5099,6 +5746,11 @@ function shieldsDownWithoutHostLock( ); } const snapshotManagedMcpPolicyKeys = managedMcpPolicies.map((policy) => policy.key); + assertShieldsPolicyMutationAuthority( + sandboxName, + "capture the Shields policy snapshot", + policyAuthority, + ); const snapshotPath = path.join( STATE_DIR, @@ -5171,6 +5823,11 @@ function shieldsDownWithoutHostLock( // down. A crash can therefore never leave an untracked mutable window. let timerStart: FreshShieldsDownTimerStart; try { + assertShieldsPolicyMutationAuthority( + sandboxName, + "start the Shields auto-restore timer", + policyAuthority, + ); timerStart = startFreshShieldsDownTimer({ sandboxName, timeoutSeconds, @@ -5195,6 +5852,11 @@ function shieldsDownWithoutHostLock( if (transition && timerAuthority) { assertFreshShieldsDownAuthority(sandboxName, timerAuthority, transition, "preparing"); } + assertShieldsPolicyMutationAuthority( + sandboxName, + "record the provisional Shields down state", + policyAuthority, + ); saveShieldsState(sandboxName, { shieldsDown: true, shieldsDownAt: now, @@ -5250,12 +5912,27 @@ function shieldsDownWithoutHostLock( console.log(` Applying ${policyName} policy...`); let policySetResult: ReturnType; try { - policySetResult = run(buildPolicySetCommand(policyPathForApply, sandboxName), { - ignoreError: true, - }); + assertShieldsPolicyMutationAuthority( + sandboxName, + "apply the Shields down policy", + policyAuthority, + ); + policySetResult = run( + buildPolicySetCommand(policyPathForApply, sandboxName, policyAuthority.gatewayName), + { + ignoreError: true, + }, + ); } finally { cleanupRuntimePolicyFile(); } + let policyAuthorityRefusal: unknown = null; + try { + rejectFinalShieldsPolicySetResult(policySetResult, "apply the Shields down policy"); + } catch (error) { + if (!isPolicyAuthorityRefusalError(error)) throw error; + policyAuthorityRefusal = error; + } if (policySetResult.status !== 0) { // The permissive policy was rejected before it applied — for example, // OpenShell refuses a live Landlock change on a sandbox whose policy is @@ -5298,6 +5975,7 @@ function shieldsDownWithoutHostLock( ` ERROR: Could not apply the ${policyName} policy, and clearing the provisional Shields down record failed: ${stateMessage}`, ); console.error(" The scheduled auto-restore remains authoritative."); + if (policyAuthorityRefusal !== null) throw policyAuthorityRefusal; return failShieldsCommand(`Could not apply ${policyName} policy`, opts.throwOnError); } const timerCancellation = killTimer(sandboxName); @@ -5308,8 +5986,10 @@ function shieldsDownWithoutHostLock( ` ERROR: Could not apply the ${policyName} policy; the sandbox remains in the Shields up state.`, ); console.error(" Shields down did not take effect. `shields status` continues to report `UP`."); + if (policyAuthorityRefusal !== null) throw policyAuthorityRefusal; return failShieldsCommand(`Could not apply ${policyName} policy`, opts.throwOnError); } + if (policyAuthorityRefusal !== null) throw policyAuthorityRefusal; // 2b. Return config to default mutable state. // OpenClaw uses sandbox:sandbox 0660/2770 here so the gateway UID, which @@ -5713,9 +6393,31 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) protocol, ); if (!activation.ok) { + const configLocked = + activation.fileHashes !== undefined && typeof activation.chattrApplied === "boolean"; + if (configLocked || activation.externalPolicyRecoveryArtifact) { + saveShieldsState(sandboxName, { + ...(configLocked + ? { + policyRecoveryConfigLocked: true, + chattrApplied: activation.chattrApplied, + fileHashes: activation.fileHashes, + } + : {}), + ...(activation.externalPolicyRecoveryArtifact + ? { externalPolicyRecoveryArtifact: activation.externalPolicyRecoveryArtifact } + : {}), + }); + } console.error(` ERROR: ${activation.error ?? "unknown restore error"}`); - console.error(" Config remains unlocked — manual intervention required."); - printManualRelockRecoveryHint(sandboxName); + if (configLocked) { + console.error( + " Config remains locked; Shields remain DOWN until policy verification succeeds.", + ); + } else { + console.error(" Config remains unlocked — manual intervention required."); + printManualRelockRecoveryHint(sandboxName); + } return failShieldsCommand(activation.error ?? "unknown restore error", opts.throwOnError); } if (activation.fileHashes && typeof activation.chattrApplied === "boolean") { @@ -5761,18 +6463,22 @@ function shieldsUpWithoutHostLock(sandboxName: string, opts: ShieldsUpOpts = {}) // captured chattrApplied + fileHashes into the persisted state so // drift detection on the next `shields status` has a seal to compare // against. The non-snapshot branch already persisted those above. - saveShieldsState(sandboxName, { - shieldsDown: false, - shieldsDownAt: null, - shieldsDownTimeout: null, - shieldsDownReason: null, - shieldsDownPolicy: null, - ...(snapshotLockResult - ? { - chattrApplied: snapshotLockResult.chattrApplied, - fileHashes: snapshotLockResult.fileHashes, - } - : {}), + commitExternalPolicyRecoveryArtifactRetirement(sandboxName, () => { + saveShieldsState(sandboxName, { + shieldsDown: false, + shieldsDownAt: null, + shieldsDownTimeout: null, + shieldsDownReason: null, + shieldsDownPolicy: null, + policyRecoveryConfigLocked: false, + externalPolicyRecoveryArtifact: undefined, + ...(snapshotLockResult + ? { + chattrApplied: snapshotLockResult.chattrApplied, + fileHashes: snapshotLockResult.fileHashes, + } + : {}), + }); }); killTimer(sandboxName); if (timerMarker?.processToken && /^[0-9a-f]{32}$/.test(timerMarker.processToken)) { @@ -5823,6 +6529,7 @@ type ShieldsStatusDeps = { resolveConfig?: typeof resolveAgentConfig; verifyStateLockPlan?: (sandboxName: string, target: AgentConfigTarget) => string[]; assertCommandAvailable?: () => void; + inspectPolicyRecovery?: typeof inspectShieldsPolicyRecovery; }; function verifyHermesProviderMutableStatus( @@ -5875,6 +6582,7 @@ function shieldsStatusWithoutHostLock( const verify = deps.verifyLockState ?? verifyShieldsLockState; const resolveConfig = deps.resolveConfig ?? resolveAgentConfig; + const inspectPolicyRecovery = deps.inspectPolicyRecovery ?? inspectShieldsPolicyRecovery; const posture = getShieldsPostureWithoutHostLock(sandboxName, allowInlineRecovery); const { state } = posture; @@ -5894,7 +6602,10 @@ function shieldsStatusWithoutHostLock( throw new DeferredShieldsExit("Shields down transition is incomplete", 1); } - const recoveredProviderTarget = recoverActiveHermesRuntimeProviderMutation(sandboxName); + const recoveredProviderTarget = + posture.mode === "locked_recovery" + ? null + : recoverActiveHermesRuntimeProviderMutation(sandboxName); if (recoveredProviderTarget && posture.mode !== "locked") { console.error(" Shields: ERROR (runtime-provider recovery restored lockdown)"); console.error( @@ -5914,7 +6625,9 @@ function shieldsStatusWithoutHostLock( console.log(" Config is mutable. Run `nemoclaw shields up` to opt into lockdown."); return; + case "locked_recovery": case "locked": { + const policyRecoveryLocked = posture.mode === "locked_recovery"; // Cross-check the sandbox filesystem so a host-root tamper that reverts // protected perms back to a sandbox-writable state is surfaced as drift // instead of reported as a clean lockdown. @@ -5924,6 +6637,7 @@ function shieldsStatusWithoutHostLock( const target = recoveredProviderTarget ?? ensureConfigHashSensitiveFile(resolveConfig(sandboxName)); if ( + !policyRecoveryLocked && !recoveredProviderTarget && target.agentName === "hermes" && inspectHermesShieldsProtocol(sandboxName, target) === "provider-state-mutation-v2" @@ -5999,6 +6713,22 @@ function shieldsStatusWithoutHostLock( } throw new DeferredShieldsExit("Locked shields state has filesystem drift", 2); } + if (policyRecoveryLocked) { + const policyRecovery = inspectPolicyRecovery(sandboxName); + console.error(" Shields: DOWN (CONFIG LOCKED — POLICY RECOVERY REQUIRED)"); + console.error(policyLine); + console.error(" Config: locked and verified"); + if (policyRecovery.status === "unavailable") { + console.error(` Policy authority: ${policyRecovery.detail}`); + } else if (policyRecovery.status === "external") { + console.error(` Recovery: ${policyRecovery.handoff}`); + } else { + console.error( + ` Recovery: run \`${CLI_NAME} ${sandboxName} shields up\` to verify policy and complete Shields up.`, + ); + } + throw new DeferredShieldsExit("Locked config is waiting for policy recovery", 2); + } if (!state.fileHashes) { // Legacy state file pre-dates the content seal. Perm-only // verification cannot prove the locked bytes were not already @@ -6031,8 +6761,24 @@ function shieldsStatusWithoutHostLock( const elapsed = downSince ? Math.floor((Date.now() - downSince.getTime()) / 1000) : 0; const remaining = state.shieldsDownTimeout != null ? Math.max(0, state.shieldsDownTimeout - elapsed) : null; + const policyRecovery = inspectPolicyRecovery(sandboxName); - console.log(` Shields: ${posture.statusText}`); + if (policyRecovery.status === "unavailable") { + console.error(" Shields: DOWN (RECOVERY REQUIRED — policy authority unavailable)"); + console.error(` Policy authority: ${policyRecovery.detail}`); + console.error( + ` Recovery: restore policy authority inspection for sandbox '${sandboxName}', then retry \`${CLI_NAME} ${sandboxName} shields status\` before relying on automatic lockdown.`, + ); + throw new DeferredShieldsExit("Policy authority inspection is required", 2); + } + + const recoveryHandoff = policyRecovery.status === "external" ? policyRecovery.handoff : null; + + console.log( + recoveryHandoff + ? " Shields: DOWN (RECOVERY REQUIRED — policy is externally managed)" + : ` Shields: ${posture.statusText}`, + ); console.log(` Since: ${state.shieldsDownAt ?? "unknown"}`); if (remaining !== null) { const mins = Math.floor(remaining / 60); @@ -6041,6 +6787,10 @@ function shieldsStatusWithoutHostLock( } console.log(` Reason: ${state.shieldsDownReason ?? "not specified"}`); console.log(` Policy: ${state.shieldsDownPolicy ?? "permissive"}`); + if (recoveryHandoff) { + console.error(` Recovery: ${recoveryHandoff}`); + throw new DeferredShieldsExit("External policy restoration is required", 2); + } return; } } @@ -6076,13 +6826,14 @@ function shieldsStatus( /** * Legacy mutability predicate. Fresh sandboxes and temporarily unlocked - * sandboxes both return true because their config is mutable; user-facing - * callers should use getShieldsPosture() so fresh state is labeled as - * "not configured" instead of "down". + * sandboxes return true because their config is mutable. Locked recovery + * returns false because config remains fail-closed while policy recovery is + * pending. User-facing callers should use getShieldsPosture() so fresh state + * is labeled as "not configured" instead of "down". */ function isShieldsDown(sandboxName: string, allowInlineRecovery = false): boolean { const posture = getShieldsPosture(sandboxName, allowInlineRecovery); - return posture.mode !== "error" && posture.mode !== "locked"; + return posture.mode === "mutable_default" || posture.mode === "temporarily_unlocked"; } /** @@ -6091,20 +6842,23 @@ function isShieldsDown(sandboxName: string, allowInlineRecovery = false): boolea * the live sandbox is gone, so the recorded lock seal/file-hashes no longer * correspond to any live image. Clearing the state prevents a stale seal from * blocking a fresh `shields up` and stops a freshly recreated (mutable) sandbox - * from being reported as locked. Best-effort: a missing state file is fine. + * from being reported as locked. A missing state file or recovery artifact is + * fine; a recovery artifact that cannot be removed remains bound and blocks + * state cleanup. */ function clearShieldsStateWithoutHostLock(sandboxName: string): void { validateName(sandboxName, "sandbox name"); const timerMarker = readTimerMarker(sandboxName); + commitExternalPolicyRecoveryArtifactRetirement(sandboxName, () => { + const filePath = stateFilePath(sandboxName); + const stateFileExists = fs.existsSync(filePath); + fs.rmSync(filePath, { force: true }); + if (stateFileExists) fsyncShieldsStateDirectory(); + }); killTimer(sandboxName); if (timerMarker?.processToken && /^[0-9a-f]{32}$/.test(timerMarker.processToken)) { clearShieldsDownTransition(sandboxName, timerMarker.processToken); } - try { - fs.rmSync(stateFilePath(sandboxName), { force: true }); - } catch { - /* best effort — absent or unreadable state is already mutable_default */ - } } function clearShieldsState(sandboxName: string): void { @@ -6120,6 +6874,7 @@ function clearShieldsState(sandboxName: string): void { export { applyShieldsPolicySnapshot, + assertShieldsPolicyMutationAuthority, clearShieldsState, completeAutoRestoreTransition, DEFAULT_TIMEOUT_SECONDS, diff --git a/src/lib/shields/legacy-hermes-compat.test.ts b/src/lib/shields/legacy-hermes-compat.test.ts index bfc638f816c..5274e003090 100644 --- a/src/lib/shields/legacy-hermes-compat.test.ts +++ b/src/lib/shields/legacy-hermes-compat.test.ts @@ -19,6 +19,7 @@ import { writeBoundForwardPolicy, writeTimerAuthorizationProof, } from "../../../test/helpers/hermes-shields-provider-consumer-harness"; +import * as shieldsFlow from "../../../test/helpers/shields-flow-harness"; import { testTimeout } from "../../../test/helpers/timeouts"; @@ -250,11 +251,13 @@ describe("legacy Hermes shields compatibility", () => { ]), vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)), vi.spyOn(policy, "resolvePermissivePolicyPath").mockReturnValue(permissivePolicyPath), + ...shieldsFlow.bindManagedPolicyMutationAuthority(policy), vi.spyOn(agentConfig, "resolveAgentConfig").mockImplementation(() => hermesTarget()), vi.spyOn(registry, "getSandbox").mockImplementation((name: unknown) => ({ name: String(name), agent: "hermes", openshellDriver: "docker", + policyAuthority: "nemoclaw-managed", lifecycleGeneration: "legacy-generation", workload: { kind: "managed-image" }, })), diff --git a/src/lib/shields/mcp-policy-transition.test.ts b/src/lib/shields/mcp-policy-transition.test.ts index c99ffd08f5c..49431d1b5bb 100644 --- a/src/lib/shields/mcp-policy-transition.test.ts +++ b/src/lib/shields/mcp-policy-transition.test.ts @@ -9,6 +9,7 @@ import { hasManagedMcpPolicyClaims, inspectProvableManagedMcpPoliciesForDeadline, inspectExactManagedMcpPolicies as inspectRegisteredManagedMcpPolicies, + inspectRecordedManagedMcpPolicies, MCP_BRIDGE_POLICY_SOURCE, } from "../actions/sandbox/mcp-bridge-policy"; import { @@ -126,6 +127,23 @@ function inspectExactManagedMcpPolicies(sandbox: SandboxEntry, livePolicyYaml: s } describe("managed MCP Shields policy transitions (#7952)", () => { + it("renders a canonical recorded entry for an external policy handoff (#9833)", () => { + const alpha = registeredPolicy("alpha", "8.8.8.8"); + + expect( + inspectRecordedManagedMcpPolicies("alpha", { + getSandbox: () => sandboxWithPolicies([alpha]), + }), + ).toEqual([ + expect.objectContaining({ + key: "mcp_bridge_alpha", + networkPolicy: networkEntry(alpha.content, "alpha"), + policyName: "mcp-bridge-alpha", + server: "alpha", + }), + ]); + }); + it("admits only canonical committed registrations that exactly match the live policy", () => { const alpha = registeredPolicy("alpha", "8.8.8.8"); const sandbox = sandboxWithPolicies([alpha]); @@ -631,25 +649,25 @@ describe("managed MCP Shields policy transitions (#7952)", () => { ); }); - 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"; + 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 }, - ); + 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/) }), - ]); - }); + 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"); diff --git a/src/lib/shields/mutable-config-repair.test.ts b/src/lib/shields/mutable-config-repair.test.ts index e1ebcd1a97c..36c0f0077dc 100644 --- a/src/lib/shields/mutable-config-repair.test.ts +++ b/src/lib/shields/mutable-config-repair.test.ts @@ -1,9 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createShieldsFlowHarness } from "../../../test/helpers/shields-flow-harness"; const NORMALIZER = "/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py"; const NORMALIZER_WATCHDOG = ["/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "15s"]; @@ -152,3 +156,64 @@ describe("mutable OpenClaw config repair", () => { expect(dockerExecFileSync).toHaveBeenCalledTimes(3); }); }); + +describe("locked Shields policy recovery status", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-locked-policy-recovery-")); + vi.stubEnv("HOME", homeDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(homeDir, { recursive: true, force: true }); + }); + + it("verifies Hermes locked recovery status without mutating provider state (#9833)", () => { + const sandboxName = "hermes"; + const target = { + agentName: "hermes", + configDir: "/sandbox/.hermes", + configFile: "config.yaml", + configPath: "/sandbox/.hermes/config.yaml", + format: "yaml", + sensitiveFiles: ["/sandbox/.hermes/.env"], + stateLockPlanInImage: true, + }; + const harness = createShieldsFlowHarness(requireSource, homeDir, { + agentConfigTarget: target, + sandboxName, + }); + const stateDir = path.join(homeDir, ".nemoclaw", "state"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, `shields-${sandboxName}.json`), + JSON.stringify({ + shieldsDown: true, + policyRecoveryConfigLocked: true, + chattrApplied: true, + fileHashes: { [target.configPath]: "a".repeat(64) }, + }), + ); + const mutationCount = harness.dockerSpawnCalls.length; + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process exit ${String(code)}`); + }) as typeof process.exit); + + expect(() => + harness.shieldsStatus(sandboxName, false, { + inspectPolicyRecovery: () => ({ status: "external", handoff: "policy handoff" }), + resolveConfig: () => target, + verifyLockState: () => ({ ok: true, issues: [] }), + verifyStateLockPlan: () => [], + }), + ).toThrow("process exit 2"); + + expect(harness.dockerSpawnCalls).toHaveLength(mutationCount); + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( + "DOWN (CONFIG LOCKED — POLICY RECOVERY REQUIRED)", + ); + }); +}); diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts index 2cfae91884f..118c7727d3d 100644 --- a/src/lib/shields/permissive-runtime.ts +++ b/src/lib/shields/permissive-runtime.ts @@ -2,13 +2,17 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import { createHash } from "node:crypto"; import YAML from "yaml"; +import { diagnosticPreview } from "../sandbox-name-contract"; + export { type ExactManagedMcpPolicy, hasManagedMcpPolicyClaims, inspectExactManagedMcpPolicies, inspectProvableManagedMcpPoliciesForDeadline, + inspectRecordedManagedMcpPolicies, type ManagedMcpPolicyOmission, } from "../actions/sandbox/mcp-bridge-policy"; @@ -16,6 +20,35 @@ import type { ExactManagedMcpPolicy, ManagedMcpPolicyOmission, } from "../actions/sandbox/mcp-bridge-policy"; + +function canonicalPolicyValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalPolicyValue); + if (!value || typeof value !== "object") return value; + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalPolicyValue(record[key])]), + ); +} + +export function serializeCanonicalPolicy(policy: Record): string { + return YAML.stringify(canonicalPolicyValue(policy)); +} + +export function describeCanonicalPolicyReference(policy: Record): string { + const digest = createHash("sha256") + .update(JSON.stringify(canonicalPolicyValue(policy)), "utf8") + .digest("hex"); + const networkPolicies = policy.network_policies; + const policyKeys = + networkPolicies && typeof networkPolicies === "object" && !Array.isArray(networkPolicies) + ? Object.keys(networkPolicies).sort() + : []; + return `canonical JSON SHA-256 ${digest}; network policy keys: ${ + policyKeys.length > 0 ? policyKeys.map(diagnosticPreview).join(", ") : "(none)" + }`; +} import { materializeMessagingPolicySandboxName } from "../messaging/channels/policy"; import { cleanupTempDir, secureTempFile } from "../onboard/temp-files"; diff --git a/src/lib/shields/policy-transition.test.ts b/src/lib/shields/policy-transition.test.ts index e28c2a4a937..5f1904c63ed 100644 --- a/src/lib/shields/policy-transition.test.ts +++ b/src/lib/shields/policy-transition.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } fr import YAML from "yaml"; import { createShieldsFlowHarness, + externalPolicyAuthorityInspection, type ShieldsFlowHarnessOptions, } from "../../../test/helpers/shields-flow-harness"; @@ -31,12 +32,53 @@ function sandboxCommandFailure( } const TRANSITION_LOCK_MODULE = "./transition-lock.js"; +function mockManagedPolicyAuthority(sandboxName: string): void { + const registry = requireSource("../state/registry.js") as typeof import("../state/registry.js"); + const policyAuthority = requireSource( + "../adapters/openshell/policy-authority.js", + ) as typeof import("../adapters/openshell/policy-authority.js"); + const policy = requireSource("../policy/index.js") as typeof import("../policy/index.js"); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: sandboxName, + openshellDriver: "docker", + policyAuthority: "nemoclaw-managed", + }); + vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + vi.spyOn(policyAuthority, "inspectSandboxPolicyAuthority").mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: { version: 1, network_policies: {} }, + }); + const receipt = { + authority: "nemoclaw-managed" as const, + authorityRecordedNow: false, + gatewayName: "nemoclaw", + inspection: { + authority: "nemoclaw-managed" as const, + effectivePolicy: { version: 1, network_policies: {} }, + }, + }; + vi.spyOn(policy, "inspectPolicyMutationAuthority").mockReturnValue(receipt); + vi.spyOn(policy, "recheckPolicyMutationAuthority").mockReturnValue(receipt); +} + describe("shields policy transition", () => { let homeDir: string; let runSpy: MockInstance; let runCaptureSpy: MockInstance; let shields: typeof import("./index.js"); + function writePolicySnapshot(sandboxName: string, fileName: string): string { + const stateDir = path.join(homeDir, ".nemoclaw", "state"); + const snapshotPath = path.join(stateDir, fileName); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies:\n test: {}\n"); + fs.writeFileSync( + path.join(stateDir, `shields-${sandboxName}.json`), + JSON.stringify({ shieldsDown: true, shieldsPolicySnapshotPath: snapshotPath }), + ); + return snapshotPath; + } + beforeEach(() => { homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shields-policy-transition-")); vi.stubEnv("HOME", homeDir); @@ -72,6 +114,7 @@ describe("shields policy transition", () => { (_sandboxName: unknown, cmd: unknown) => cmd as string[], ); vi.spyOn(dockerExec, "dockerExecFileSync").mockReturnValue(""); + mockManagedPolicyAuthority("openclaw"); vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); shields = requireSource(SHIELDS_MODULE); @@ -85,6 +128,61 @@ describe("shields policy transition", () => { fs.rmSync(homeDir, { recursive: true, force: true }); }); + it("rechecks policy authority immediately before direct snapshot restore (#9833)", () => { + const sandboxName = "openclaw"; + const snapshotPath = writePolicySnapshot(sandboxName, "policy-snapshot-authority-race.yaml"); + const policy = requireSource("../policy/index.js") as typeof import("../policy/index.js"); + vi.mocked(policy.recheckPolicyMutationAuthority).mockImplementation(() => { + throw new Error("OpenShell policy authority changed during snapshot restore"); + }); + + expect(() => shields.applyShieldsPolicySnapshot(sandboxName, snapshotPath)).toThrow( + /policy authority changed/, + ); + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("refuses external authority before Shields snapshot recovery (#9833)", () => { + const sandboxName = "openclaw"; + const snapshotPath = writePolicySnapshot(sandboxName, "policy-snapshot-external.yaml"); + const policy = requireSource("../policy/index.js") as typeof import("../policy/index.js"); + vi.mocked(policy.inspectPolicyMutationAuthority).mockReturnValue({ + authority: "externally-managed", + authorityRecordedNow: false, + gatewayName: "nemoclaw", + inspection: { + authority: "externally-managed", + effectivePolicy: { version: 1, network_policies: {} }, + }, + }); + + expect(() => shields.applyShieldsPolicySnapshot(sandboxName, snapshotPath)).toThrow( + /does not match.*canonical JSON SHA-256 [a-f0-9]{64}; network policy keys: "test"/su, + ); + expect(runSpy).not.toHaveBeenCalled(); + + const matchingExternalAuthority = { + authority: "externally-managed" as const, + authorityRecordedNow: false, + gatewayName: "nemoclaw", + inspection: { + authority: "externally-managed" as const, + effectivePolicy: { version: 1, network_policies: { test: {} } }, + }, + }; + vi.mocked(policy.inspectPolicyMutationAuthority).mockReturnValue(matchingExternalAuthority); + vi.spyOn(policy, "inspectPolicyRecoveryAuthority") + .mockReturnValueOnce(matchingExternalAuthority) + .mockReturnValue({ + ...matchingExternalAuthority, + authority: "nemoclaw-managed", + inspection: { ...matchingExternalAuthority.inspection, authority: "nemoclaw-managed" }, + }); + expect(() => shields.applyShieldsPolicySnapshot(sandboxName, snapshotPath)).toThrow( + /Policy authority changed.*canonical JSON SHA-256 [a-f0-9]{64}.*Stop without applying.*Restore the recorded externally managed authority.*NemoClaw will not change policy authority/su, + ); + }); + it("never relaxes policy or persists mutable state when the base-policy read fails", () => { expect(() => shields.shieldsDown("openclaw", { throwOnError: true })).toThrow( "Cannot capture current policy", @@ -138,6 +236,46 @@ describe("shields down policy rejection", () => { }); } + it("stops Shields down before mutation when policy is externally managed (#9833)", () => { + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + policyAuthorityInspection: externalPolicyAuthorityInspection, + sandboxEntry: { + name: "openclaw", + openshellDriver: "docker", + policyAuthority: "externally-managed", + }, + }); + + expect(() => harness.shieldsDown("openclaw", { throwOnError: true })).toThrow( + "externally managed", + ); + expect(harness.runSpy).not.toHaveBeenCalled(); + expect(harness.dockerSpawnCalls).toEqual([]); + }); + + it("pins Shields policy inspection, reads, and writes to the recorded gateway (#9833)", () => { + const gatewayName = "nemoclaw-18080"; + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + confirmOpenClawInodeFlags: true, + sandboxEntry: { + name: "openclaw", + gatewayName, + gatewayPort: 18080, + openshellDriver: "docker", + policyAuthority: "nemoclaw-managed", + }, + }); + + harness.shieldsDown("openclaw", { throwOnError: true }); + + expect(harness.policyAuthoritySpy).toHaveBeenCalledWith("openclaw", "lower Shields"); + const policyCommands = [...harness.runCaptureSpy.mock.calls, ...harness.runSpy.mock.calls] + .map(([command]) => command) + .filter((command) => Array.isArray(command) && command.includes("policy")); + expect(policyCommands.length).toBeGreaterThan(0); + expect(policyCommands.every((command) => command.includes(gatewayName))).toBe(true); + }); + it("keeps `shields status` at `UP` when OpenShell rejects the permissive policy (#8198)", () => { const harness = createRejectedPolicyHarness(); @@ -589,6 +727,7 @@ describe("shields config lock without a shipped config hash", () => { vi.spyOn(stateDirLock, "preflightStateDirLock").mockReturnValue([]); applyStateDirLockModeSpy = vi.spyOn(stateDirLock, "applyStateDirLockMode").mockReturnValue([]); restoreStateDirLockPostureSpy = vi.spyOn(stateDirLock, "restoreStateDirLockPosture"); + mockManagedPolicyAuthority("dcode-safety"); errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); shields = requireSource(SHIELDS_MODULE); @@ -629,26 +768,31 @@ describe("shields config lock without a shipped config hash", () => { "rollback-failed", " CRITICAL: Deep Agents config lock transaction could not restore its original posture. Restore this sandbox from a trusted snapshot or recreate it before retrying. rollback failed", ], - ] as const)("maps the anchored %s child protocol to exact bounded guidance (#7995)", (status, expectedGuidance) => { - const stderr = - status === "sandbox-parent" ? Buffer.from(lockFailure(status), "utf8") : lockFailure(status); - commandHandlers.set( - "python3", - rejectConfigLock( - sandboxCommandFailure( - stderr, - `hostile argv marker ${lockFailure("incomplete")}`, - lockFailure("config-root"), + ] as const)( + "maps the anchored %s child protocol to exact bounded guidance (#7995)", + (status, expectedGuidance) => { + const stderr = + status === "sandbox-parent" + ? Buffer.from(lockFailure(status), "utf8") + : lockFailure(status); + commandHandlers.set( + "python3", + rejectConfigLock( + sandboxCommandFailure( + stderr, + `hostile argv marker ${lockFailure("incomplete")}`, + lockFailure("config-root"), + ), ), - ), - ); + ); - expect(() => shields.lockAgentConfig("dcode-safety", target(), false)).toThrow( - DEEP_AGENTS_LOCK_GENERIC_ERROR, - ); - expect(errorSpy).toHaveBeenCalledWith(expectedGuidance); - expect(errorSpy).toHaveBeenCalledTimes(1); - }); + expect(() => shields.lockAgentConfig("dcode-safety", target(), false)).toThrow( + DEEP_AGENTS_LOCK_GENERIC_ERROR, + ); + expect(errorSpy).toHaveBeenCalledWith(expectedGuidance); + expect(errorSpy).toHaveBeenCalledTimes(1); + }, + ); it("accepts transaction-failed without inventing a containment or rollback claim (#7995)", () => { commandHandlers.set( @@ -758,54 +902,57 @@ describe("shields config lock without a shipped config hash", () => { format: "json", }), ], - ])("pins expired inline recovery to Deep Agents when the registry %s (#7995)", (_scenario, resolveTarget) => { - const sandboxName = "dcode-safety"; - const stateDir = path.join(homeDir, ".nemoclaw", "state"); - const snapshotPath = path.join(stateDir, "policy-snapshot-inline-recovery.yaml"); - const markerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); - fs.mkdirSync(stateDir, { recursive: true }); - fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies: {}\n", { mode: 0o600 }); - fs.writeFileSync( - path.join(stateDir, `shields-${sandboxName}.json`), - JSON.stringify({ - shieldsDown: true, - shieldsDownAt: new Date(Date.now() - 120_000).toISOString(), - shieldsDownTimeout: 60, - shieldsDownReason: "identity coverage", - shieldsDownPolicy: "permissive", - shieldsPolicySnapshotPath: snapshotPath, - }), - { mode: 0o600 }, - ); - fs.writeFileSync( - markerPath, - JSON.stringify({ - pid: 4242, - sandboxName, - snapshotPath, - restoreAt: new Date(Date.now() - 30_000).toISOString(), - processToken: "7".repeat(32), - agentName: "langchain-deepagents-code", - configPath: CONFIG_PATH, - configDir: CONFIG_DIR, - }), - { mode: 0o600 }, - ); - vi.spyOn(process, "kill").mockImplementation(reportMissingTimerProcess); - resolveAgentConfigSpy.mockImplementation(resolveTarget); + ])( + "pins expired inline recovery to Deep Agents when the registry %s (#7995)", + (_scenario, resolveTarget) => { + const sandboxName = "dcode-safety"; + const stateDir = path.join(homeDir, ".nemoclaw", "state"); + const snapshotPath = path.join(stateDir, "policy-snapshot-inline-recovery.yaml"); + const markerPath = path.join(stateDir, `shields-timer-${sandboxName}.json`); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(snapshotPath, "version: 1\nnetwork_policies: {}\n", { mode: 0o600 }); + fs.writeFileSync( + path.join(stateDir, `shields-${sandboxName}.json`), + JSON.stringify({ + shieldsDown: true, + shieldsDownAt: new Date(Date.now() - 120_000).toISOString(), + shieldsDownTimeout: 60, + shieldsDownReason: "identity coverage", + shieldsDownPolicy: "permissive", + shieldsPolicySnapshotPath: snapshotPath, + }), + { mode: 0o600 }, + ); + fs.writeFileSync( + markerPath, + JSON.stringify({ + pid: 4242, + sandboxName, + snapshotPath, + restoreAt: new Date(Date.now() - 30_000).toISOString(), + processToken: "7".repeat(32), + agentName: "langchain-deepagents-code", + configPath: CONFIG_PATH, + configDir: CONFIG_DIR, + }), + { mode: 0o600 }, + ); + vi.spyOn(process, "kill").mockImplementation(reportMissingTimerProcess); + resolveAgentConfigSpy.mockImplementation(resolveTarget); - const posture = shields.getShieldsPosture(sandboxName, true); - const state = JSON.parse( - fs.readFileSync(path.join(stateDir, `shields-${sandboxName}.json`), "utf-8"), - ); + const posture = shields.getShieldsPosture(sandboxName, true); + const state = JSON.parse( + fs.readFileSync(path.join(stateDir, `shields-${sandboxName}.json`), "utf-8"), + ); - expect(posture.mode).toBe("locked"); - expect(lockCalls).toHaveLength(2); - expect(lockCalls.every((command) => command[4] === CONFIG_DIR)).toBe(true); - expect(lockCalls.every((command) => command[5] === CONFIG_PATH)).toBe(true); - expect(Object.keys(state.fileHashes)).toEqual([CONFIG_PATH, HASH_PATH]); - expect(fs.existsSync(markerPath)).toBe(false); - }); + expect(posture.mode).toBe("locked"); + expect(lockCalls).toHaveLength(2); + expect(lockCalls.every((command) => command[4] === CONFIG_DIR)).toBe(true); + expect(lockCalls.every((command) => command[5] === CONFIG_PATH)).toBe(true); + expect(Object.keys(state.fileHashes)).toEqual([CONFIG_PATH, HASH_PATH]); + expect(fs.existsSync(markerPath)).toBe(false); + }, + ); it("restores the managed sandbox parent when the config is unlocked", () => { entries.set(CONFIG_DIR, { mode: "755", owner: "root:root" }); @@ -839,6 +986,9 @@ describe("managed MCP policy deadline restoration (#7952)", () => { 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 policyAuthority = requireSource( + "../adapters/openshell/policy-authority.js", + ) as typeof import("../adapters/openshell/policy-authority.js"); const policySetBodies: string[] = []; vi.spyOn(runner, "runCapture").mockReturnValue( @@ -854,7 +1004,24 @@ describe("managed MCP policy deadline restoration (#7952)", () => { vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "openclaw", openshellDriver: "docker", + policyAuthority: "nemoclaw-managed", + }); + vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + vi.spyOn(policyAuthority, "inspectSandboxPolicyAuthority").mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: { version: 1, network_policies: {} }, }); + const authorityReceipt = { + authority: "nemoclaw-managed" as const, + authorityRecordedNow: false, + gatewayName: "nemoclaw", + inspection: { + authority: "nemoclaw-managed" as const, + effectivePolicy: { version: 1, network_policies: {} }, + }, + }; + vi.spyOn(policy, "inspectPolicyMutationAuthority").mockReturnValue(authorityReceipt); + vi.spyOn(policy, "recheckPolicyMutationAuthority").mockReturnValue(authorityReceipt); const shields = requireSource(SHIELDS_MODULE) as typeof import("./index.js"); return { applyShieldsPolicySnapshot: shields.applyShieldsPolicySnapshot, policySetBodies }; diff --git a/src/lib/shields/status-state-lock-plan.test.ts b/src/lib/shields/status-state-lock-plan.test.ts index 193e703580e..4dd060b8d1e 100644 --- a/src/lib/shields/status-state-lock-plan.test.ts +++ b/src/lib/shields/status-state-lock-plan.test.ts @@ -47,6 +47,43 @@ async function loadShieldsModule() { } describe("Shields status state lock plan drift", () => { + it("reports unavailable policy authority during temporary unlock (#9833)", async () => { + const sandboxName = "openclaw"; + const stateDir = path.join(tmpDir, ".nemoclaw", "state"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, `shields-${sandboxName}.json`), + JSON.stringify({ shieldsDown: true, shieldsDownAt: new Date().toISOString() }), + { mode: 0o600 }, + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`exit ${String(code)}`); + }); + + const { shieldsStatus } = await loadShieldsModule(); + expect(() => + shieldsStatus(sandboxName, false, { + inspectPolicyRecovery: () => ({ + status: "unavailable", + detail: "OpenShell sandbox policy authority inspection failed: the query timed out.", + }), + }), + ).toThrow("exit 2"); + + const output = logSpy.mock.calls.flat().join("\n"); + const errors = errorSpy.mock.calls.flat().join("\n"); + expect(exitSpy).toHaveBeenCalledWith(2); + expect(output).not.toContain("DOWN (temporarily unlocked)"); + expect(output).not.toContain("Auto-lockdown in:"); + expect(errors).toContain("Shields: DOWN (RECOVERY REQUIRED — policy authority unavailable)"); + expect(errors).toContain("OpenShell sandbox policy authority inspection failed"); + expect(errors).toContain( + "Recovery: restore policy authority inspection for sandbox 'openclaw'", + ); + }); + it("reports a mismatched installed state lock plan as drift", async () => { const sandboxName = "openclaw"; writeSealedLockedState(sandboxName); diff --git a/src/lib/state/registry-normalization.test.ts b/src/lib/state/registry-normalization.test.ts index f62a8128756..9b4772b740f 100644 --- a/src/lib/state/registry-normalization.test.ts +++ b/src/lib/state/registry-normalization.test.ts @@ -12,6 +12,7 @@ import { normalizeBaselineExclusions, normalizeBaselineExclusionTransition, normalizeCustomPolicyEntries, + normalizeSandboxPolicyAuthority, } from "./registry-normalization"; const originalHome = process.env.HOME; @@ -67,6 +68,29 @@ describe("sandbox registry normalization", () => { estimatedModelDownloadBytes: null, } as const; + const createPolicyAttribution = () => { + const exclusion = { + version: 1 as const, + agent: "hermes", + key: "nous_research", + digest: "a".repeat(64), + acknowledgedAt: "2026-08-20T00:00:00.000Z", + }; + return { + policies: ["weather"], + customPolicies: [{ name: "private-api", content: "network_policies: {}" }], + baselineExclusions: [exclusion], + baselineExclusionTransition: { + id: "123e4567-e89b-42d3-a456-426614174983", + operation: "exclude" as const, + exclusion, + targetLiveDigest: null, + startedAt: "2026-08-20T00:00:01.000Z", + }, + policyPresetsFinalized: true, + }; + }; + it.each([null, [], 42, "invalid"])( "treats a non-object top-level registry document as empty: %j", async (document) => { @@ -273,6 +297,154 @@ describe("sandbox registry normalization", () => { }); expect(() => registry.getSandbox("profile")).toThrow("invalid serving profile provenance"); }); + + function expectExternalAttributionCleared(entry: unknown, name: string): void { + expect(entry).toMatchObject({ + name, + policies: [], + policyAuthority: "externally-managed", + }); + expect(entry).not.toHaveProperty("customPolicies"); + expect(entry).not.toHaveProperty("baselineExclusions"); + expect(entry).not.toHaveProperty("baselineExclusionTransition"); + expect(entry).not.toHaveProperty("policyPresetsFinalized"); + expect(entry).not.toHaveProperty("policyTier"); + } + + it("round-trips known policy authority while leaving legacy authority unknown (#9833)", async () => { + const registry = await loadRegistryWith({ + legacy: { name: "legacy" }, + managed: { name: "managed", policyAuthority: "nemoclaw-managed" }, + external: { name: "external", policyAuthority: "externally-managed" }, + }); + registry.save(registry.load()); + const persisted = JSON.parse( + fs.readFileSync(path.join(process.env.HOME!, ".nemoclaw", "sandboxes.json"), "utf8"), + ) as { sandboxes: Record> }; + expect(registry.getSandbox("legacy")?.policyAuthority).toBeUndefined(); + expect(registry.getSandbox("managed")?.policyAuthority).toBe("nemoclaw-managed"); + expect(registry.getSandbox("external")?.policyAuthority).toBe("externally-managed"); + expect(persisted.sandboxes.legacy).not.toHaveProperty("policyAuthority"); + expect(persisted.sandboxes.managed?.policyAuthority).toBe("nemoclaw-managed"); + expect(persisted.sandboxes.external?.policyAuthority).toBe("externally-managed"); + }); + + it("clears NemoClaw policy attribution from externally managed rows (#9833)", async () => { + const attribution = createPolicyAttribution(); + const registry = await loadRegistryWith({ + legacy: { name: "legacy", ...attribution, policyTier: "strict" }, + managed: { + name: "managed", + ...attribution, + policyAuthority: "nemoclaw-managed", + policyTier: "strict", + }, + external: { + name: "external", + ...attribution, + policyAuthority: "externally-managed", + policyTier: "strict", + }, + }); + + expect(registry.getSandbox("legacy")).toMatchObject(attribution); + expect(registry.getSandbox("managed")).toMatchObject(attribution); + expectExternalAttributionCleared(registry.getSandbox("external"), "external"); + }); + + it.each([null, "sandbox", {}])( + "fails closed on malformed persisted policy authority %j (#9833)", + async (policyAuthority) => { + const registry = await loadRegistryWith({ + alpha: { name: "alpha", policyAuthority }, + }); + + expect(() => registry.getSandbox("alpha")).toThrow(/invalid policy authority/i); + }, + ); + + it("backfills policy authority once without allowing later changes or removal (#9833)", async () => { + const registry = await loadRegistryWith({ legacy: { name: "legacy" } }); + + expect(() => registry.updateSandbox("legacy", { policyAuthority: "global" as never })).toThrow( + /invalid policy authority/i, + ); + expect(registry.updateSandbox("legacy", { policyAuthority: "nemoclaw-managed" })).toBe(true); + expect(() => + registry.updateSandbox("legacy", { policyAuthority: "externally-managed" }), + ).toThrow(/policy authority changed/u); + expect(() => registry.updateSandbox("legacy", { policyAuthority: undefined })).toThrow( + /policy authority changed/u, + ); + expect(registry.getSandbox("legacy")?.policyAuthority).toBe("nemoclaw-managed"); + + expect(() => + registry.registerSandbox({ name: "legacy", policyAuthority: "externally-managed" }), + ).toThrow(/policy authority changed/u); + }); + + it("canonicalizes external attribution across registry mutations and recovery (#9833)", async () => { + const registry = await loadRegistryWith({ + updated: { name: "updated", ...createPolicyAttribution(), policyTier: "strict" }, + }); + const externalEntry = (name: string) => ({ + name, + ...createPolicyAttribution(), + policyAuthority: "externally-managed" as const, + policyTier: "strict", + }); + + expectExternalAttributionCleared( + registry.registerSandbox(externalEntry("registered")), + "registered", + ); + expect(registry.updateSandbox("updated", { policyAuthority: "externally-managed" })).toBe(true); + expectExternalAttributionCleared(registry.getSandbox("updated"), "updated"); + + registry.restoreSandboxEntry(externalEntry("recovered")); + expectExternalAttributionCleared(registry.getSandbox("recovered"), "recovered"); + const receipt = registry.removeSandboxWithReceipt("recovered")!; + expect( + registry.restoreSandboxEntryIfMissing({ ...receipt, entry: externalEntry("recovered") }), + ).toBe(true); + expectExternalAttributionCleared(registry.getSandbox("recovered"), "recovered"); + }); + + it("preserves a replacement row when recovery has a different policy authority (#9833)", async () => { + const registry = await loadRegistryWith({ + alpha: { + name: "alpha", + model: "current", + policyAuthority: "externally-managed", + }, + }); + + expect(() => + registry.restoreSandboxEntry({ + name: "alpha", + model: "recovered", + policyAuthority: "nemoclaw-managed", + }), + ).toThrow(/policy authority changed during recovery/u); + expect(registry.getSandbox("alpha")).toMatchObject({ + model: "current", + policyAuthority: "externally-managed", + }); + }); +}); + +describe("sandbox policy authority normalization", () => { + it.each([ + [undefined, undefined], + ["nemoclaw-managed", "nemoclaw-managed"], + ["externally-managed", "externally-managed"], + ])("normalizes known policy authority %j (#9833)", (input, expected) => { + expect(normalizeSandboxPolicyAuthority(input)).toBe(expected); + }); + + it.each(["sandbox", null, {}])("rejects invalid policy authority %j (#9833)", (input) => { + expect(() => normalizeSandboxPolicyAuthority(input)).toThrow(/invalid policy authority/i); + }); }); describe("custom policy pin receipt normalization (#8176)", () => { diff --git a/src/lib/state/registry-normalization.ts b/src/lib/state/registry-normalization.ts index 7381b9c8381..8888e5c62fb 100644 --- a/src/lib/state/registry-normalization.ts +++ b/src/lib/state/registry-normalization.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { SandboxPolicyAuthority } from "../adapters/openshell/policy-authority"; import { isObjectRecord } from "../core/json-types"; import { normalizeTrustedPrivatePolicyPinReceipt } from "../policy/trusted-private-endpoints"; import type { @@ -8,13 +9,60 @@ import type { BaselineExclusionTransition, CustomPolicyEntry, SandboxEntry, -} from "./registry"; +} from "./registry/types"; const BASELINE_TRANSITION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const BASELINE_TRANSITION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; const SHA256_DIGEST_PATTERN = /^[a-f0-9]{64}$/; +/** Keep legacy absence unknown and reject every unrecognized authority value. */ +export function normalizeSandboxPolicyAuthority( + value: unknown, +): SandboxPolicyAuthority | undefined { + if (value === undefined) return undefined; + if (value === "nemoclaw-managed" || value === "externally-managed") return value; + throw new Error( + "Sandbox registry contains an invalid policy authority; repair the registry before continuing", + ); +} + +/** Remove policy attribution that an external authority owns and normalize managed state. */ +export function normalizeSandboxPolicyAttribution(entry: SandboxEntry): SandboxEntry { + const policyAuthority = normalizeSandboxPolicyAuthority(entry.policyAuthority); + const { + policies: _policies, + customPolicies: _customPolicies, + baselineExclusions: _baselineExclusions, + baselineExclusionTransition: _baselineExclusionTransition, + policyPresetsFinalized: _policyPresetsFinalized, + policyTier: _policyTier, + policyAuthority: _policyAuthority, + ...rest + } = entry; + if (policyAuthority === "externally-managed") { + return { ...rest, policies: [], policyAuthority }; + } + + const baselineExclusions = normalizeBaselineExclusions(entry.baselineExclusions); + const baselineExclusionTransition = normalizeBaselineExclusionTransition( + entry.baselineExclusionTransition, + ); + const customPolicies = normalizeCustomPolicyEntries(entry.customPolicies); + return { + ...rest, + ...(entry.policies !== undefined ? { policies: entry.policies } : {}), + ...(customPolicies ? { customPolicies } : {}), + ...(baselineExclusions ? { baselineExclusions } : {}), + ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), + ...(entry.policyPresetsFinalized !== undefined + ? { policyPresetsFinalized: entry.policyPresetsFinalized } + : {}), + ...(entry.policyTier !== undefined ? { policyTier: entry.policyTier } : {}), + ...(policyAuthority !== undefined ? { policyAuthority } : {}), + }; +} + /** Normalize persisted custom policy content and its generated-pin authority. */ export function normalizeCustomPolicyEntries(value: unknown): CustomPolicyEntry[] | undefined { if (value === undefined) return undefined; diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 233ccafcd81..3173526a60e 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { isDeepStrictEqual } from "node:util"; +import { PolicyAuthorityRefusalError } from "../adapters/openshell/policy-authority"; import type { InferenceSelection } from "../inference/selection"; import { inferenceSelectionRegistryFields, @@ -39,6 +40,8 @@ import { normalizeBaselineExclusions, normalizeBaselineExclusionTransition, normalizeCustomPolicyEntries, + normalizeSandboxPolicyAttribution, + normalizeSandboxPolicyAuthority, retainedDefaultSandbox, } from "./registry-normalization"; import * as reversibleRemoval from "./registry-reversible-removal"; @@ -115,7 +118,11 @@ export { getMessagingPlanFromEntry, type SandboxMessagingState, } from "./registry-messaging"; -export { hasUnsafeHostMountTerminalText, normalizeCustomPolicyEntries }; +export { + hasUnsafeHostMountTerminalText, + normalizeCustomPolicyEntries, + normalizeSandboxPolicyAttribution, +}; export type SandboxRemovalReceipt = reversibleRemoval.RegistryRemovalReceipt; @@ -159,6 +166,20 @@ export function registerSandbox( if (entry.servingProfileProvenance !== undefined && !servingProfileProvenance) { throw new Error("Cannot register a sandbox with invalid serving profile provenance"); } + const requestedPolicyAuthority = normalizeSandboxPolicyAuthority(entry.policyAuthority); + const recordedPolicyAuthority = normalizeSandboxPolicyAuthority( + data.sandboxes[entry.name]?.policyAuthority, + ); + if ( + recordedPolicyAuthority !== undefined && + requestedPolicyAuthority !== undefined && + recordedPolicyAuthority !== requestedPolicyAuthority + ) { + throw new PolicyAuthorityRefusalError( + "Cannot register a sandbox after its policy authority changed", + ); + } + const policyAuthority = requestedPolicyAuthority ?? recordedPolicyAuthority; if (retainedDefaultSandbox(data.defaultSandbox, data.sandboxes) === null) { data.defaultSandbox = null; } @@ -219,12 +240,17 @@ export function registerSandbox( : undefined, openshellDriver: entry.openshellDriver || null, openshellVersion: entry.openshellVersion || null, - policies: entry.policies || [], - baselineExclusions: normalizeBaselineExclusions(entry.baselineExclusions), - baselineExclusionTransition: normalizeBaselineExclusionTransition( - entry.baselineExclusionTransition, - ), - policyTier: entry.policyTier || null, + ...(policyAuthority !== undefined ? { policyAuthority } : {}), + ...(policyAuthority === "externally-managed" + ? { policies: [] } + : { + policies: entry.policies || [], + baselineExclusions: normalizeBaselineExclusions(entry.baselineExclusions), + baselineExclusionTransition: normalizeBaselineExclusionTransition( + entry.baselineExclusionTransition, + ), + policyTier: entry.policyTier || null, + }), webSearchEnabled: typeof entry.webSearchEnabled === "boolean" ? entry.webSearchEnabled : undefined, // Preserve absence on reconstructed legacy rows. Only a freshly built @@ -417,6 +443,19 @@ function changesHostLocalInferenceLifecycleAuthority( ); } +function assertRecordedPolicyAuthorityUnchanged( + current: SandboxEntry, + updates: Partial, +): void { + if (!Object.prototype.hasOwnProperty.call(updates, "policyAuthority")) return; + const requested = normalizeSandboxPolicyAuthority(updates.policyAuthority); + if (current.policyAuthority === undefined || requested === current.policyAuthority) return; + throw new PolicyAuthorityRefusalError( + `Refusing to update sandbox '${current.name}' because its policy authority changed ` + + `from ${current.policyAuthority} to ${requested ?? "unrecorded"}.`, + ); +} + export function updateSandbox(name: string, updates: Partial): boolean { return withLock(() => { const data = load(); @@ -426,7 +465,8 @@ export function updateSandbox(name: string, updates: Partial): boo return false; } if (changesHostLocalInferenceLifecycleAuthority(current, updates)) return false; - data.sandboxes[name] = { ...current, ...updates }; + assertRecordedPolicyAuthorityUnchanged(current, updates); + data.sandboxes[name] = normalizeSandboxPolicyAttribution({ ...current, ...updates }); save(data); return true; }); @@ -471,7 +511,24 @@ export function restoreSandboxEntry( ): void { withLock(() => { const data = load(); - save(reversibleRemoval.restoreSandboxEntryInRegistry(data, entry, options.defaultTransition)); + const normalizedEntry = normalizeSandboxPolicyAttribution(entry); + const current = data.sandboxes[normalizedEntry.name]; + if ( + current && + normalizeSandboxPolicyAuthority(current.policyAuthority) !== + normalizeSandboxPolicyAuthority(normalizedEntry.policyAuthority) + ) { + throw new PolicyAuthorityRefusalError( + `Refusing to restore sandbox '${normalizedEntry.name}' because its policy authority changed during recovery.`, + ); + } + save( + reversibleRemoval.restoreSandboxEntryInRegistry( + data, + normalizedEntry, + options.defaultTransition, + ), + ); }); } @@ -479,7 +536,10 @@ export function restoreSandboxEntry( export function restoreSandboxEntryIfMissing(receipt: SandboxRemovalReceipt): boolean { return withLock(() => { const data = load(); - const result = reversibleRemoval.restoreSandboxIfMissingInRegistry(data, receipt); + const result = reversibleRemoval.restoreSandboxIfMissingInRegistry(data, { + ...receipt, + entry: normalizeSandboxPolicyAttribution(receipt.entry), + }); if (!result.restored) return false; save(result.registry); return result.restored; diff --git a/src/lib/state/registry/persistence.ts b/src/lib/state/registry/persistence.ts index 85e0c02d3d3..5e81706532d 100644 --- a/src/lib/state/registry/persistence.ts +++ b/src/lib/state/registry/persistence.ts @@ -13,9 +13,7 @@ import { serializeSandboxMessagingStateForDisk, } from "../registry-messaging"; import { - normalizeBaselineExclusions, - normalizeBaselineExclusionTransition, - normalizeCustomPolicyEntries, + normalizeSandboxPolicyAttribution, parseSandboxRegistryEntries, retainedDefaultSandbox, } from "../registry-normalization"; @@ -162,11 +160,7 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { "load", ); const mcp = normalizeSandboxMcpState(entry.mcp); - const baselineExclusions = normalizeBaselineExclusions(entry.baselineExclusions); - const baselineExclusionTransition = normalizeBaselineExclusionTransition( - entry.baselineExclusionTransition, - ); - const customPolicies = normalizeCustomPolicyEntries(entry.customPolicies); + const policyEntry = normalizeSandboxPolicyAttribution(entry); const { cuaRuntimeReadiness: _legacyCuaRuntimeReadiness, messaging: _messaging, @@ -175,11 +169,8 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { hostLocalInferenceProvenance: _hostLocalInferenceProvenance, servingProfileProvenance: _servingProfileProvenance, mcp: _mcp, - baselineExclusions: _baselineExclusions, - baselineExclusionTransition: _baselineExclusionTransition, - customPolicies: _customPolicies, ...rest - } = entry as SandboxEntry & { cuaRuntimeReadiness?: unknown }; + } = policyEntry as SandboxEntry & { cuaRuntimeReadiness?: unknown }; return { ...rest, ...(workload ? { workload } : {}), @@ -188,9 +179,6 @@ function normalizeSandboxEntryForRuntime(entry: SandboxEntry): SandboxEntry { ...(servingProfileProvenance ? { servingProfileProvenance } : {}), ...(messaging ? { messaging } : {}), ...(mcp ? { mcp } : {}), - ...(baselineExclusions ? { baselineExclusions } : {}), - ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), - ...(customPolicies ? { customPolicies } : {}), }; } @@ -230,11 +218,7 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { "save", ); const mcp = serializeSandboxMcpStateForDisk(durable.mcp); - const baselineExclusions = normalizeBaselineExclusions(durable.baselineExclusions); - const baselineExclusionTransition = normalizeBaselineExclusionTransition( - durable.baselineExclusionTransition, - ); - const customPolicies = normalizeCustomPolicyEntries(durable.customPolicies); + const policyEntry = normalizeSandboxPolicyAttribution(durable); const { cuaRuntimeReadiness: _legacyCuaRuntimeReadiness, messaging: _messaging, @@ -243,11 +227,8 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { hostLocalInferenceProvenance: _hostLocalInferenceProvenance, servingProfileProvenance: _servingProfileProvenance, mcp: _mcp, - baselineExclusions: _baselineExclusions, - baselineExclusionTransition: _baselineExclusionTransition, - customPolicies: _customPolicies, ...rest - } = durable as SandboxEntry & { cuaRuntimeReadiness?: unknown }; + } = policyEntry as SandboxEntry & { cuaRuntimeReadiness?: unknown }; return { ...rest, ...(rest.dashboardPort === 0 ? { dashboardPort: null } : {}), @@ -257,8 +238,5 @@ function serializeSandboxEntryForDisk(entry: SandboxEntry): SandboxEntry { ...(servingProfileProvenance ? { servingProfileProvenance } : {}), ...(messaging ? { messaging } : {}), ...(mcp ? { mcp } : {}), - ...(baselineExclusions ? { baselineExclusions } : {}), - ...(baselineExclusionTransition ? { baselineExclusionTransition } : {}), - ...(customPolicies ? { customPolicies } : {}), }; } diff --git a/src/lib/state/registry/types.ts b/src/lib/state/registry/types.ts index 578104a2ecb..5c23bf7c241 100644 --- a/src/lib/state/registry/types.ts +++ b/src/lib/state/registry/types.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { SandboxPolicyAuthority } from "../../adapters/openshell/policy-authority"; import type { InferenceSelection } from "../../inference/selection"; import type { ServingProfileProvenance } from "../../inference/serving/types"; import type { WebSearchProvider } from "../../inference/web-search"; @@ -119,6 +120,8 @@ export interface SandboxEntry extends Partial { hostMounts?: SandboxHostMount[]; openshellDriver?: string | null; openshellVersion?: string | null; + /** Policy authority for a completed sandbox; absence means unknown. */ + policyAuthority?: SandboxPolicyAuthority; policies?: string[]; customPolicies?: CustomPolicyEntry[]; /** Operator exclusions from the agent baseline policy, replayed on rebuild. */ diff --git a/test/cli/sandbox-mutations.test.ts b/test/cli/sandbox-mutations.test.ts index 57b3e2c1d78..9c4c1b9df6f 100644 --- a/test/cli/sandbox-mutations.test.ts +++ b/test/cli/sandbox-mutations.test.ts @@ -29,6 +29,11 @@ function writePolicyMutationOpenshellStub(home: string): string { "#!/usr/bin/env bash", "set -euo pipefail", 'if [ "$1" = "policy" ] && [ "$2" = "get" ]; then', + ' if [[ " $* " == *" --output json "* ]]; then', + ' sandbox="${@: -1}"', + ' printf \'{"scope":"sandbox","sandbox":"%s","status":"effective","policy_source":"sandbox","policy":{}}\\n\' "$sandbox"', + " exit 0", + " fi", " cat <<'YAML'", "version: 1", "network_policies:", diff --git a/test/e2e-test.sh b/test/e2e-test.sh index 2d20f22955d..5a72d5a17b1 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -149,21 +149,41 @@ info "4b. Verify blueprint runner apply smoke test" # response is intentionally rejected by the runner. FAKE_OPENSHELL_BIN=$(mktemp -d) APPLY_OUTPUT=$(mktemp) +APPLY_CALLS="$FAKE_OPENSHELL_BIN/calls" cleanup_apply_fixture() { rm -rf "$FAKE_OPENSHELL_BIN" - rm -f "$APPLY_OUTPUT" + rm -f "$APPLY_OUTPUT" "$APPLY_CALLS" } trap cleanup_apply_fixture EXIT cat >"$FAKE_OPENSHELL_BIN/openshell" <<'SH' #!/usr/bin/env bash set -euo pipefail -case "${1:-} ${2:-} ${3:-}" in - "policy get --base") +if [ "${1:-}" = "status" ]; then + printf '%s\n' 'Gateway Status' ' Status: Connected' ' Gateway: fixture-gateway' + exit 0 +fi +if [ "${1:-} ${2:-}" = "policy list" ]; then + exit 0 +fi +if [ "${1:-} ${2:-}" = "policy get" ]; then + printf '%s\n' "$*" >>"${BASH_SOURCE[0]%/*}/calls" +fi +if [ "${1:-} ${2:-}" = "policy get" ] && [[ " $* " == *" --output json "* ]]; then + sandbox="${@: -1}" + printf '{"scope":"sandbox","sandbox":"%s","status":"effective","policy_source":"sandbox","policy":{}}\n' "$sandbox" + exit 0 +fi +case "$*" in + "policy get -g fixture-gateway --base "*) + if [ "$#" -ne 6 ] || [ -z "${6:-}" ]; then + echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 + exit 64 + fi printf '%s\n' 'Policy for sandbox fixture' '---' cat /opt/nemoclaw-blueprint/policies/openclaw-sandbox.yaml ;; "policy get "*) - echo "unexpected policy read: expected policy get --base" >&2 + echo "unexpected policy read: expected policy get -g fixture-gateway --base " >&2 exit 64 ;; esac @@ -173,7 +193,6 @@ PATH="$FAKE_OPENSHELL_BIN:$PATH" NEMOCLAW_BLUEPRINT_PATH=/opt/nemoclaw-blueprint const { main } = await import('/opt/nemoclaw/dist/blueprint/runner.js'); await main(['apply', '--profile', 'ncp']); " 2>&1 | tee "$APPLY_OUTPUT" -rm -rf "$FAKE_OPENSHELL_BIN" if grep -q "RUN_ID:" "$APPLY_OUTPUT"; then pass "Apply generates run ID" else @@ -194,6 +213,11 @@ if grep -q "PROGRESS:100:Apply complete" "$APPLY_OUTPUT"; then else fail "Apply did not complete" fi +if grep -Eq '^policy get -g fixture-gateway --base [^ ]+$' "$APPLY_CALLS"; then + pass "Apply reads base policy through the active gateway" +else + fail "Apply did not use the gateway-pinned base-policy read" +fi # Verify run state was persisted to disk RUN_ID=$(grep -o 'nc-[0-9]*-[0-9]*-[a-f0-9]*' "$APPLY_OUTPUT" | head -1) if [ -f "$HOME/.nemoclaw/state/runs/$RUN_ID/plan.json" ]; then @@ -201,7 +225,7 @@ if [ -f "$HOME/.nemoclaw/state/runs/$RUN_ID/plan.json" ]; then else fail "Apply did not persist run state (plan.json missing for $RUN_ID)" fi -rm -f "$APPLY_OUTPUT" +cleanup_apply_fixture trap - EXIT # ------------------------------------------------------- diff --git a/test/helpers/hermes-shields-provider-consumer-harness.ts b/test/helpers/hermes-shields-provider-consumer-harness.ts index 6f3e3f6ece3..b2dd802a704 100644 --- a/test/helpers/hermes-shields-provider-consumer-harness.ts +++ b/test/helpers/hermes-shields-provider-consumer-harness.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { type MockInstance, vi } from "vitest"; import type { SandboxEntry } from "../../src/lib/state/registry"; +import { managedPolicyMutationAuthority } from "./shields-flow-harness"; const INDEX_MODULE = "./index.js"; export const HERMES_PROVIDER_CAPABILITY_PATH = @@ -46,6 +47,7 @@ export const hermesProviderConsumerSandbox: SandboxEntry = { name: "current-hermes", agent: "hermes", openshellDriver: "docker", + policyAuthority: "nemoclaw-managed", lifecycleGeneration: "generation-1", workload: { schemaVersion: 1, @@ -285,6 +287,12 @@ export function createHermesShieldsProviderConsumerHarness( String(file), String(name), ]), + vi + .spyOn(policy, "inspectPolicyMutationAuthority") + .mockReturnValue(managedPolicyMutationAuthority), + vi + .spyOn(policy, "recheckPolicyMutationAuthority") + .mockReturnValue(managedPolicyMutationAuthority), registrySpy, vi .spyOn(privilegedExec, "privilegedSandboxExecArgv") diff --git a/test/helpers/hermes-unsafe-config-shields-harness.ts b/test/helpers/hermes-unsafe-config-shields-harness.ts index 3e8a013ad1b..16989a94faa 100644 --- a/test/helpers/hermes-unsafe-config-shields-harness.ts +++ b/test/helpers/hermes-unsafe-config-shields-harness.ts @@ -7,6 +7,7 @@ import os from "node:os"; import path from "node:path"; import { expect, type MockInstance, vi } from "vitest"; +import { managedPolicyMutationAuthority } from "./shields-flow-harness"; type RequireSource = NodeJS.Require; @@ -280,11 +281,18 @@ export function createHermesUnsafeConfigHarness( ); vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)); vi.spyOn(policy, "resolvePermissivePolicyPath").mockReturnValue(permissivePolicyPath); + vi + .spyOn(policy, "inspectPolicyMutationAuthority") + .mockReturnValue(managedPolicyMutationAuthority); + vi + .spyOn(policy, "recheckPolicyMutationAuthority") + .mockReturnValue(managedPolicyMutationAuthority); vi.spyOn(agentConfig, "resolveAgentConfig").mockReturnValue(hermesTarget); vi.spyOn(registry, "getSandbox").mockImplementation((name: unknown) => ({ name: String(name), agent: "hermes", openshellDriver: "docker", + policyAuthority: "nemoclaw-managed", lifecycleGeneration: "legacy-generation", workload: { kind: "managed-image" }, })); diff --git a/test/helpers/shields-flow-harness.ts b/test/helpers/shields-flow-harness.ts index e17449608a6..71b3cfcb83e 100644 --- a/test/helpers/shields-flow-harness.ts +++ b/test/helpers/shields-flow-harness.ts @@ -7,20 +7,52 @@ import path from "node:path"; import { expect, type MockInstance, vi } from "vitest"; import YAML from "yaml"; import { buildMcpBridgePolicyYaml } from "../../src/lib/actions/sandbox/mcp-bridge-policy-render"; +import type { SandboxPolicyAuthorityInspection } from "../../src/lib/adapters/openshell/policy-authority"; import type { AgentConfigTarget } from "../../src/lib/sandbox/agent-config"; import type { SandboxEntry } from "../../src/lib/state/registry"; const shieldsModulePath = "./index.js"; +export const externalPolicyAuthorityInspection = { + authority: "externally-managed" as const, + effectivePolicy: { version: 1, network_policies: {} }, +}; + +export const managedPolicyMutationAuthority = { + authority: "nemoclaw-managed" as const, + authorityRecordedNow: false, + gatewayName: "nemoclaw", + inspection: { + authority: "nemoclaw-managed" as const, + effectivePolicy: { version: 1, network_policies: {} }, + }, +}; + +export function bindManagedPolicyMutationAuthority( + policy: typeof import("../../src/lib/policy"), +): MockInstance[] { + return [ + vi + .spyOn(policy, "inspectPolicyMutationAuthority") + .mockReturnValue(managedPolicyMutationAuthority), + vi + .spyOn(policy, "recheckPolicyMutationAuthority") + .mockReturnValue(managedPolicyMutationAuthority), + ]; +} + export type ShieldsFlowHarness = { applyShieldsPolicySnapshot: typeof import("../../src/lib/shields/index.js").applyShieldsPolicySnapshot; auditSpy: MockInstance; + clearShieldsState: typeof import("../../src/lib/shields/index.js").clearShieldsState; cleanupTempDirSpy: MockInstance; dockerSpawnCalls: Array<{ args: string[]; timeout: number | undefined }>; errorSpy: MockInstance; getShieldsPosture: typeof import("../../src/lib/shields/index.js").getShieldsPosture; getOpenClawPosture: () => "locked" | "mutable"; logSpy: MockInstance; + policyAuthoritySpy: MockInstance; + policyRecoveryAuthoritySpy: MockInstance; policySetBodies: string[]; runCaptureSpy: MockInstance; runSpy: MockInstance; @@ -54,6 +86,7 @@ export type ShieldsFlowHarnessOptions = { detail: string; }>; processStartIdentity?: string; + policyAuthorityInspection?: SandboxPolicyAuthorityInspection; timerAuthorizationOutcome?: "authorized" | "dies-before-proof"; timerDiesAfterUnlock?: boolean; fork?: (...args: unknown[]) => { @@ -160,6 +193,7 @@ export function createShieldsFlowHarness( 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("../adapters/openshell/policy-authority.js")]; delete require.cache[requireDist.resolve("../sandbox/privileged-exec.js")]; delete require.cache[requireDist.resolve("../cli/branding.js")]; const timerControl = requireDist( @@ -221,6 +255,9 @@ export function createShieldsFlowHarness( const policy = requireDist("../policy/index.js"); const agentConfig = requireDist("../sandbox/agent-config.js"); const registry = requireDist("../state/registry.js"); + const policyAuthority = requireDist( + "../adapters/openshell/policy-authority.js", + ) as typeof import("../../src/lib/adapters/openshell/policy-authority.js"); const privilegedExec = requireDist("../sandbox/privileged-exec.js"); const dockerExec = requireDist("../adapters/docker/exec.js"); const audit = requireDist("./audit.js"); @@ -284,11 +321,25 @@ export function createShieldsFlowHarness( }; }); } - vi.spyOn(policy, "buildPolicyGetCommand").mockReturnValue(["openshell", "policy", "get"]); - vi.spyOn(policy, "buildPolicySetCommand").mockImplementation((file: unknown) => { - recordPolicySetBody(policySetBodies, file); - return ["openshell", "policy", "set"]; - }); + vi.spyOn(policy, "buildPolicyGetCommand").mockImplementation( + (_sandboxName: unknown, gatewayName: unknown) => [ + "openshell", + "policy", + "get", + ...(typeof gatewayName === "string" ? ["-g", gatewayName] : []), + ], + ); + vi.spyOn(policy, "buildPolicySetCommand").mockImplementation( + (file: unknown, _sandbox, gateway) => { + recordPolicySetBody(policySetBodies, file); + return [ + "openshell", + "policy", + "set", + ...(typeof gateway === "string" ? ["-g", gateway] : []), + ]; + }, + ); vi.spyOn(policy, "parseCurrentPolicy").mockImplementation((raw: unknown) => String(raw)); vi.spyOn(policy, "resolvePermissivePolicyPath").mockReturnValue( path.join(tmpDir, "permissive.yaml"), @@ -305,12 +356,38 @@ export function createShieldsFlowHarness( }; vi.spyOn(agentConfig, "resolveAgentConfig").mockReturnValue(resolvedAgentConfig); vi.spyOn(registry, "getSandbox").mockReturnValue( - options.sandboxEntry ?? { - name: options.sandboxName ?? "openclaw", - agent: resolvedAgentConfig.agentName, - openshellDriver: "docker", - }, + options.sandboxEntry + ? { policyAuthority: "nemoclaw-managed", ...options.sandboxEntry } + : { + name: options.sandboxName ?? "openclaw", + agent: resolvedAgentConfig.agentName, + openshellDriver: "docker", + policyAuthority: "nemoclaw-managed", + }, + ); + vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + const policyAuthorityInspection = options.policyAuthorityInspection ?? { + authority: "nemoclaw-managed" as const, + effectivePolicy: YAML.parse( + options.livePolicyYaml ?? "version: 1\nnetwork_policies:\n test: {}\n", + ) as Record, + }; + vi.spyOn(policyAuthority, "inspectSandboxPolicyAuthority").mockReturnValue( + policyAuthorityInspection, ); + const policyMutationAuthority = { + authority: policyAuthorityInspection.authority, + authorityRecordedNow: false, + gatewayName: options.sandboxEntry?.gatewayName ?? "nemoclaw", + inspection: policyAuthorityInspection, + }; + const policyAuthoritySpy = vi + .spyOn(policy, "inspectPolicyMutationAuthority") + .mockReturnValue(policyMutationAuthority); + const policyRecoveryAuthoritySpy = vi + .spyOn(policy, "inspectPolicyRecoveryAuthority") + .mockReturnValue(policyMutationAuthority); + vi.spyOn(policy, "recheckPolicyMutationAuthority").mockReturnValue(policyMutationAuthority); vi.spyOn(registry, "listSandboxes").mockReturnValue({ sandboxes: [{ name: options.sandboxName ?? "openclaw", agent: resolvedAgentConfig.agentName }], }); @@ -574,12 +651,15 @@ export function createShieldsFlowHarness( return { applyShieldsPolicySnapshot: shields.applyShieldsPolicySnapshot, auditSpy, + clearShieldsState: shields.clearShieldsState, cleanupTempDirSpy, dockerSpawnCalls, errorSpy, getShieldsPosture: shields.getShieldsPosture, getOpenClawPosture: () => openClawPosture, logSpy, + policyAuthoritySpy, + policyRecoveryAuthoritySpy, policySetBodies, runCaptureSpy, runSpy, diff --git a/test/mcp/mcp-policy-key-ownership.test.ts b/test/mcp/mcp-policy-key-ownership.test.ts index 02518ae87db..b06ecae6c3b 100644 --- a/test/mcp/mcp-policy-key-ownership.test.ts +++ b/test/mcp/mcp-policy-key-ownership.test.ts @@ -10,6 +10,16 @@ import { describe, expect, it } from "vitest"; const MATCHING_OPENSHELL = path.resolve("test/fixtures/openshell-v0.0.106"); const MATCHING_OPENSHELL_VERSION_CLAUSE = `if [ "$1" = "--version" ]; then printf '%s\\n' 'openshell 0.0.106'; exit 0; fi`; +const MANAGED_POLICY_AUTHORITY_CLAUSE = `if [ "$1 $2" = "policy get" ]; then + case " $* " in + *" --output json "*) + sandbox="" + for sandbox in "$@"; do :; done + printf '{"scope":"sandbox","sandbox":"%s","status":"effective","policy_source":"sandbox","policy":{}}\\n' "$sandbox" + exit 0 + ;; + esac +fi`; const PRESET = `network_policies: example: @@ -29,6 +39,7 @@ function runApply( path.join(binDir, "openshell"), `#!/bin/sh ${MATCHING_OPENSHELL_VERSION_CLAUSE} +${MANAGED_POLICY_AUTHORITY_CLAUSE} printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} if [ "$1 $2" = "policy get" ]; then printf 'Version: 1\nHash: test\n---\nversion: 1\n${ @@ -79,6 +90,7 @@ function runContentMatch(liveName: string) { path.join(binDir, "openshell"), `#!/bin/sh ${MATCHING_OPENSHELL_VERSION_CLAUSE} +${MANAGED_POLICY_AUTHORITY_CLAUSE} printf 'Version: 1\nHash: test\n---\nversion: 1\nnetwork_policies:\n example:\n name: ${liveName}\n endpoints: []\n' `, { mode: 0o755 }, @@ -109,6 +121,7 @@ function runFailedPolicyMutation(operation: "apply" | "remove") { path.join(binDir, "openshell"), `#!/bin/sh ${MATCHING_OPENSHELL_VERSION_CLAUSE} +${MANAGED_POLICY_AUTHORITY_CLAUSE} if [ "$1 $2" = "policy get" ]; then printf 'Version: 1\nHash: test\n---\nversion: 1\nnetwork_policies:\n example:\n name: generated-policy\n endpoints: []\n' exit 0 @@ -174,6 +187,7 @@ function runSuccessfulPolicyRemoval(skipRegistryUpdate: boolean) { path.join(binDir, "openshell"), `#!/bin/sh ${MATCHING_OPENSHELL_VERSION_CLAUSE} +${MANAGED_POLICY_AUTHORITY_CLAUSE} if [ "$1 $2" = "policy get" ]; then printf 'Version: 1\nHash: test\n---\nversion: 1\nnetwork_policies:\n example:\n name: generated-policy\n endpoints: []\n' fi @@ -295,6 +309,7 @@ describe("MCP-generated network policy ownership", () => { path.join(binDir, "openshell"), `#!/bin/sh ${MATCHING_OPENSHELL_VERSION_CLAUSE} +${MANAGED_POLICY_AUTHORITY_CLAUSE} printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} if [ "$1 $2 $3" = "status --output json" ]; then printf '%s\n' 'ready' @@ -394,6 +409,7 @@ bridge.addMcpBridge("alpha", { path.join(binDir, "openshell"), `#!/bin/sh ${MATCHING_OPENSHELL_VERSION_CLAUSE} +${MANAGED_POLICY_AUTHORITY_CLAUSE} printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} if [ "$1 $2 $3" = "status --output json" ]; then printf '%s\n' 'ready' diff --git a/test/package-contract/openshell-policy-boundary.test.ts b/test/package-contract/openshell-policy-boundary.test.ts index c89807b5395..84b9947309f 100644 --- a/test/package-contract/openshell-policy-boundary.test.ts +++ b/test/package-contract/openshell-policy-boundary.test.ts @@ -64,10 +64,16 @@ describe("OpenShell policy boundary package contract", () => { it("routes the CommonJS CLI and ESM plugin through one canonical CJS boundary", async () => { const cliPolicy = require("../../dist/lib/policy/merge.js") as { + assertExternalPolicyRequirementContainment: (...args: unknown[]) => void; + assertMatchingPolicyAuthority: (recorded: unknown, observed: unknown) => void; parseOpenShellPolicy: (raw: string) => { yamlBody: string; policy: Record; }; + parseSandboxPolicyAuthorityMetadata: ( + raw: string, + sandboxName: string, + ) => { authority: string; effectivePolicy: Record }; withoutProviderComposedPolicies: ( policies: Record, ) => Record; @@ -82,10 +88,13 @@ describe("OpenShell policy boundary package contract", () => { path.join(repoRoot, "nemoclaw", "dist", "shared", "openshell-policy-boundary.cjs"), ).href )) as { + assertExternalPolicyRequirementContainment: typeof cliPolicy.assertExternalPolicyRequirementContainment; + assertMatchingPolicyAuthority: typeof cliPolicy.assertMatchingPolicyAuthority; parseOpenShellPolicy: (raw: string) => { yamlBody: string; policy: Record; }; + parseSandboxPolicyAuthorityMetadata: typeof cliPolicy.parseSandboxPolicyAuthorityMetadata; withoutProviderComposedPolicies: ( policies: Record, ) => Record; @@ -93,7 +102,10 @@ describe("OpenShell policy boundary package contract", () => { }; const canonicalBoundary = require("../../nemoclaw/dist/shared/openshell-policy-boundary.cjs") as { + assertExternalPolicyRequirementContainment: typeof cliPolicy.assertExternalPolicyRequirementContainment; + assertMatchingPolicyAuthority: typeof cliPolicy.assertMatchingPolicyAuthority; parseOpenShellPolicy: typeof cliPolicy.parseOpenShellPolicy; + parseSandboxPolicyAuthorityMetadata: typeof cliPolicy.parseSandboxPolicyAuthorityMetadata; stripProviderComposedPolicies: typeof cliPolicy.stripProviderComposedPolicies; }; expect( @@ -119,6 +131,25 @@ describe("OpenShell policy boundary package contract", () => { expect(cliPolicy.stripProviderComposedPolicies).toBe( canonicalBoundary.stripProviderComposedPolicies, ); + expect(cliPolicy.parseSandboxPolicyAuthorityMetadata).toBe( + canonicalBoundary.parseSandboxPolicyAuthorityMetadata, + ); + expect(cliPolicy.assertMatchingPolicyAuthority).toBe( + canonicalBoundary.assertMatchingPolicyAuthority, + ); + expect(cliPolicy.assertExternalPolicyRequirementContainment).toBe( + canonicalBoundary.assertExternalPolicyRequirementContainment, + ); + const sandboxMetadata = JSON.stringify({ + scope: "sandbox", + sandbox: "alpha", + status: "effective", + policy_source: "global", + policy: { version: 1, network_policies: {} }, + }); + expect(pluginBoundary.parseSandboxPolicyAuthorityMetadata(sandboxMetadata, "alpha")).toEqual( + canonicalBoundary.parseSandboxPolicyAuthorityMetadata(sandboxMetadata, "alpha"), + ); const pluginRunner = await import( pathToFileURL(path.join(repoRoot, "nemoclaw", "dist", "blueprint", "runner.js")).href diff --git a/test/platform/images/image-cleanup.test.ts b/test/platform/images/image-cleanup.test.ts index f9e5cff9037..aa64651e8a9 100644 --- a/test/platform/images/image-cleanup.test.ts +++ b/test/platform/images/image-cleanup.test.ts @@ -298,13 +298,17 @@ describe("image cleanup: sandbox destroy removes Docker image (#2086)", () => { it("destroy neutralizes active shields timer and only deletes target sandbox files", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "destroy-shields-")); + const alphaRecovery = path.join(stateDir, "shields-external-policy-alpha.yaml"); const alphaState = path.join(stateDir, "shields-alpha.json"); const alphaTimer = path.join(stateDir, "shields-timer-alpha.json"); + const betaRecovery = path.join(stateDir, "shields-external-policy-beta.yaml"); const betaState = path.join(stateDir, "shields-beta.json"); const betaTimer = path.join(stateDir, "shields-timer-beta.json"); + fs.writeFileSync(alphaRecovery, "version: 1\nnetwork_policies: {}\n"); fs.writeFileSync(alphaState, '{"shieldsDown":true}'); fs.writeFileSync(alphaTimer, '{"pid":9999}'); + fs.writeFileSync(betaRecovery, "version: 1\nnetwork_policies: {}\n"); fs.writeFileSync(betaState, '{"shieldsDown":true}'); fs.writeFileSync(betaTimer, '{"pid":9999}'); @@ -320,40 +324,42 @@ describe("image cleanup: sandbox destroy removes Docker image (#2086)", () => { }); expect(killCalls).toEqual(["alpha"]); + expect(fs.existsSync(alphaRecovery)).toBe(false); expect(fs.existsSync(alphaState)).toBe(false); expect(fs.existsSync(alphaTimer)).toBe(false); + expect(fs.existsSync(betaRecovery)).toBe(true); expect(fs.existsSync(betaState)).toBe(true); expect(fs.existsSync(betaTimer)).toBe(true); fs.rmSync(stateDir, { recursive: true, force: true }); }); - it("destroy shields cleanup warns on timer/cleanup failures but keeps best-effort flow", () => { + it("destroy preserves Shields state when external recovery cleanup fails (#9833)", () => { const warnings: string[] = []; const rmSync = vi.fn((artifactPath: string) => { - if (artifactPath.endsWith("shields-alpha.json")) { + if (artifactPath.endsWith("shields-external-policy-alpha.yaml")) { const error = new Error("permission denied") as NodeJS.ErrnoException; error.code = "EACCES"; throw error; } }); - cleanupShieldsDestroyArtifacts("alpha", { - stateDir: "/tmp/nonexistent-state-dir", - rmSync: rmSync as unknown as typeof fs.rmSync, - killShieldsTimer: () => ({ - warnings: ["Failed to terminate shields timer PID 4242"], + expect(() => + cleanupShieldsDestroyArtifacts("alpha", { + stateDir: "/tmp/nonexistent-state-dir", + rmSync: rmSync as unknown as typeof fs.rmSync, + killShieldsTimer: () => ({ + warnings: ["Failed to terminate Shields timer PID 4242"], + }), + warn: (message) => warnings.push(message), }), - warn: (message) => warnings.push(message), - }); + ).toThrow( + "Could not remove external Shields policy recovery artifact '/tmp/nonexistent-state-dir/shields-external-policy-alpha.yaml': permission denied. Shields state was preserved for retry.", + ); - expect(warnings).toContain("Failed to terminate shields timer PID 4242"); - expect( - warnings.some((message) => message.includes("Failed to remove shields cleanup artifact")), - ).toBe(true); - expect(rmSync).toHaveBeenCalledTimes(2); - expect(rmSync.mock.calls[0][0]).toContain("shields-alpha.json"); - expect(rmSync.mock.calls[1][0]).toContain("shields-timer-alpha.json"); + expect(warnings).toEqual(["Failed to terminate Shields timer PID 4242"]); + expect(rmSync).toHaveBeenCalledOnce(); + expect(rmSync.mock.calls[0][0]).toContain("shields-external-policy-alpha.yaml"); }); it("state-dir helper resolves ~/.nemoclaw/state from a single shared helper", () => { @@ -363,18 +369,21 @@ describe("image cleanup: sandbox destroy removes Docker image (#2086)", () => { }); describe("shields state cleanup on destroy (#3114)", () => { - it("removes shields and shields-timer state files for the sandbox", () => { + it("removes Shields state, timer, and external recovery files for the sandbox", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shields-cleanup-")); try { const shieldsFile = path.join(tmpDir, "shields-alpha.json"); const timerFile = path.join(tmpDir, "shields-timer-alpha.json"); + const recoveryFile = path.join(tmpDir, "shields-external-policy-alpha.yaml"); fs.writeFileSync(shieldsFile, JSON.stringify({ shieldsDown: false })); fs.writeFileSync(timerFile, JSON.stringify({ pid: 12345 })); + fs.writeFileSync(recoveryFile, "version: 1\nnetwork_policies: {}\n"); removeShieldsState("alpha", tmpDir); expect(fs.existsSync(shieldsFile)).toBe(false); expect(fs.existsSync(timerFile)).toBe(false); + expect(fs.existsSync(recoveryFile)).toBe(false); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -394,11 +403,14 @@ describe("shields state cleanup on destroy (#3114)", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shields-cleanup-")); try { const otherFile = path.join(tmpDir, "shields-bravo.json"); + const otherRecoveryFile = path.join(tmpDir, "shields-external-policy-bravo.yaml"); fs.writeFileSync(otherFile, JSON.stringify({ shieldsDown: false })); + fs.writeFileSync(otherRecoveryFile, "version: 1\nnetwork_policies: {}\n"); removeShieldsState("alpha", tmpDir); expect(fs.existsSync(otherFile)).toBe(true); + expect(fs.existsSync(otherRecoveryFile)).toBe(true); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/test/runtime/policy/policies-permissive-policy.test.ts b/test/runtime/policy/policies-permissive-policy.test.ts index 26b53badaa3..58b5138ed7e 100644 --- a/test/runtime/policy/policies-permissive-policy.test.ts +++ b/test/runtime/policy/policies-permissive-policy.test.ts @@ -42,6 +42,10 @@ policies.applyPermissivePolicy("hermes-sandbox"); fakeOpenshell, `#!/usr/bin/env bash set -euo pipefail +if [ "$1 $2" = "policy get" ]; then + printf '%s\n' '{"scope":"sandbox","sandbox":"hermes-sandbox","status":"effective","policy_source":"sandbox","policy":{}}' + exit 0 +fi if [ "$1 $2" = "policy set" ]; then policy_file="" while [ "$#" -gt 0 ]; do diff --git a/test/runtime/policy/policies-teams.test.ts b/test/runtime/policy/policies-teams.test.ts index 39c77aff0e8..4bc61c004f8 100644 --- a/test/runtime/policy/policies-teams.test.ts +++ b/test/runtime/policy/policies-teams.test.ts @@ -100,6 +100,10 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ `#!/usr/bin/env bash set -euo pipefail if [ "$1 $2" = "policy get" ]; then + if [[ " $* " == *" --output json "* ]]; then + printf '%s\n' '{"scope":"sandbox","sandbox":"hermes-sandbox","status":"effective","policy_source":"sandbox","policy":{}}' + exit 0 + fi printf 'Version: 1\nHash: test\n---\nversion: 1\n\nnetwork_policies: {}\n' exit 0 fi diff --git a/test/runtime/policy/policies.test.ts b/test/runtime/policy/policies.test.ts index dd71abbafc7..a83ec901372 100644 --- a/test/runtime/policy/policies.test.ts +++ b/test/runtime/policy/policies.test.ts @@ -17,6 +17,12 @@ const policies = requireForTest( const resolveOpenshellModule = requireForTest( path.join(REPO_ROOT, "src", "lib", "adapters", "openshell", "resolve.ts"), ) as { resolveOpenshell: (...args: unknown[]) => string | null }; +const policyAuthorityModule = requireForTest( + path.join(REPO_ROOT, "src", "lib", "adapters", "openshell", "policy-authority.ts"), +) as typeof import("../../../src/lib/adapters/openshell/policy-authority"); +const registryForTest = requireForTest( + path.join(REPO_ROOT, "src", "lib", "state", "registry.ts"), +) as typeof import("../../../src/lib/state/registry"); const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "policy", "index.ts")); const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "src", "lib", "state", "registry.ts")); const SOURCE_NODE_ARGS = ["--import", "tsx"]; @@ -36,7 +42,28 @@ function parseResultPayload(stdout: string): any { return JSON.parse(stdout.slice(markerIndex + marker.length)); } +function managedPolicyMetadata(sandboxName: string): string { + return JSON.stringify({ + scope: "sandbox", + sandbox: sandboxName, + status: "effective", + policy_source: "sandbox", + policy: { version: 1, network_policies: {} }, + }); +} + describe("policies", () => { + beforeEach(() => { + vi.spyOn(policyAuthorityModule, "inspectSandboxPolicyAuthority").mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + describe("listPresets", () => { it.each(Array.from(policies.listPresets(), (value) => [value]))( "$name has a name and description", @@ -162,6 +189,10 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ set -euo pipefail printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} if [ "$1 $2" = "policy get" ]; then + if [[ " $* " == *" --output json "* ]]; then + printf '%s\n' ${JSON.stringify(managedPolicyMetadata("test-sandbox"))} + exit 0 + fi printf 'Version: 1\nHash: test\n---\nversion: 1\n\nnetwork_policies: {}\n' exit 0 fi @@ -199,9 +230,9 @@ exit 1 expect(result.status).toBe(0); const payload = parseResultPayload(result.stdout); expect(payload.result).toBe(true); - expect(payload.calls.filter((call: string) => call.startsWith("policy get "))).toHaveLength( - 1, - ); + const policyGets = payload.calls.filter((call: string) => call.startsWith("policy get ")); + expect(policyGets.some((call: string) => call.includes("--output json"))).toBe(true); + expect(policyGets.some((call: string) => !call.includes("--output json"))).toBe(true); expect(payload.calls.filter((call: string) => call.startsWith("policy set "))).toHaveLength( 1, ); @@ -234,6 +265,10 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ `#!/usr/bin/env bash set -euo pipefail if [ "$1 $2" = "policy get" ]; then + if [[ " $* " == *" --output json "* ]]; then + printf '%s\n' ${JSON.stringify(managedPolicyMetadata("hermes-sandbox"))} + exit 0 + fi printf 'Version: 1\nHash: test\n---\nversion: 1\n\nnetwork_policies: {}\n' exit 0 fi @@ -314,6 +349,10 @@ process.stdout.write("\n__RESULT__" + JSON.stringify({ `#!/usr/bin/env bash set -euo pipefail if [ "$1 $2" = "policy get" ]; then + if [[ " $* " == *" --output json "* ]]; then + printf '%s\n' ${JSON.stringify(managedPolicyMetadata("hermes-sandbox"))} + exit 0 + fi printf 'Version: 1\nHash: test\n---\nversion: 1\n\nnetwork_policies: {}\n' exit 0 fi @@ -376,6 +415,12 @@ exit 1 ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const sandboxSpy = vi.spyOn(registryForTest, "getSandbox").mockReturnValue({ + name: "test-sandbox", + agent: "openclaw", + policies: [], + policyAuthority: "nemoclaw-managed", + }); vi.stubEnv("NEMOCLAW_OPENSHELL_BIN", fakeOpenshell); try { try { @@ -390,6 +435,7 @@ exit 1 } finally { logSpy.mockRestore(); errSpy.mockRestore(); + sandboxSpy.mockRestore(); vi.unstubAllEnvs(); fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -469,6 +515,31 @@ exit 1 expect(waitIdx < nameIdx).toBeTruthy(); }); + it("pins policy reads and writes to an explicit gateway (#9833)", () => { + expect( + policies + .buildPolicySetCommand("/tmp/policy.yaml", "my-assistant", "nemoclaw-18080") + .slice(1), + ).toEqual([ + "policy", + "set", + "-g", + "nemoclaw-18080", + "--policy", + "/tmp/policy.yaml", + "--wait", + "my-assistant", + ]); + expect(policies.buildPolicyGetCommand("my-assistant", "nemoclaw-18080").slice(1)).toEqual([ + "policy", + "get", + "-g", + "nemoclaw-18080", + "--base", + "my-assistant", + ]); + }); + it("uses the resolved openshell binary for every policy command", () => { const resolved = "/opt/nvidia/bin/openshell"; const resolveSpy = vi @@ -634,6 +705,12 @@ exit 1 const mkdtempSpy = vi.spyOn(fs, "mkdtempSync"); const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const sandboxSpy = vi.spyOn(registryForTest, "getSandbox").mockReturnValue({ + name: "my-assistant", + agent: "openclaw", + policies: [], + policyAuthority: "nemoclaw-managed", + }); const exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => { throw new Error("__test_exit__"); }) as never); @@ -651,6 +728,7 @@ exit 1 mkdtempSpy.mockRestore(); errSpy.mockRestore(); logSpy.mockRestore(); + sandboxSpy.mockRestore(); exitSpy.mockRestore(); } }); @@ -683,7 +761,10 @@ exit 1 .mockReturnValue(fakeOpenshell); savedGetSandbox = registryModule.getSandbox; savedAddCustomPolicy = registryModule.addCustomPolicy; - registryModule.getSandbox = (name: string) => ({ name }); + registryModule.getSandbox = (name: string) => ({ + name, + policyAuthority: "nemoclaw-managed", + }); registryModule.addCustomPolicy = () => true; }); @@ -789,9 +870,9 @@ network_policies: fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it("returns false and warns when a custom preset cannot be recorded locally", () => { - // Sandbox is Ready on the gateway but missing from the local registry - // (e.g. after stale-registry pruning), so addCustomPolicy cannot persist. + it("refuses a custom preset when policy authority cannot be recorded (#9833)", () => { + // The sandbox is ready on the gateway but missing from the local + // registry, so the first observed authority cannot be persisted. registryModule.getSandbox = () => null; const addSpy = vi.fn(() => false); registryModule.addCustomPolicy = addSpy; @@ -807,21 +888,18 @@ network_policies: CUSTOM_CONTENT, { custom: { sourcePath: SOURCE_PATH } }, ); - // Pre-fix this returned true (silent exit 0) while policy-list/status - // never showed the preset. The command must not claim success. expect(result).toBe(false); expect(addSpy).not.toHaveBeenCalled(); const combined = errors.join("\n"); expect(combined).toContain("my-assistant"); - expect(combined).toMatch(/could not be\s+recorded locally/); - expect(combined).toMatch(/policy list or status/); + expect(combined).toContain("could not record policy authority"); } finally { errSpy.mockRestore(); logSpy.mockRestore(); } }); - it("warns but keeps the mutation when a built-in preset cannot be recorded locally (#9295)", () => { + it("refuses a built-in preset when policy authority cannot be recorded (#9833)", () => { registryModule.getSandbox = () => null; const updateSpy = vi.fn(() => true); registryModule.updateSandbox = updateSpy; @@ -831,16 +909,12 @@ network_policies: }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); try { - // A built-in preset stays discoverable from the gateway, so the applied - // policy stands. The warning is what tells the operator why policy list - // will report it without local state behind it. const result = policies.applyPresetContent("my-assistant", "github", BUILTIN_CONTENT, {}); - expect(result).toBe(true); + expect(result).toBe(false); expect(updateSpy).not.toHaveBeenCalled(); const combined = errors.join("\n"); expect(combined).toContain("my-assistant"); - expect(combined).toMatch(/could not be\s+recorded locally/); - expect(combined).toMatch(/active on gateway, missing\s+from local state/); + expect(combined).toContain("could not record policy authority"); } finally { errSpy.mockRestore(); logSpy.mockRestore(); @@ -848,7 +922,12 @@ network_policies: }); it("applies a well-formed custom preset and records it verbatim (#9406)", () => { - registryModule.getSandbox = (name: string) => ({ name }); + let sandbox: Record = { name: "my-assistant" }; + registryModule.getSandbox = () => sandbox; + registryModule.updateSandbox = (_name: string, updates: Record) => { + sandbox = { ...sandbox, ...updates }; + return true; + }; const addSpy = vi.fn(() => true); registryModule.addCustomPolicy = addSpy; const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/test/runtime/policy/policy-mutation-read-discovery.test.ts b/test/runtime/policy/policy-mutation-read-discovery.test.ts index 56849a2fcaa..d09cd1ec54a 100644 --- a/test/runtime/policy/policy-mutation-read-discovery.test.ts +++ b/test/runtime/policy/policy-mutation-read-discovery.test.ts @@ -71,6 +71,40 @@ describe("OpenShell policy mutation read discovery (#6921)", () => { expect(countPolicyReadCalls(source, "/repo/src/lib/fixture.ts", "/repo")).toBe(7); }); + it("classifies gateway-pinned direct policy reads", () => { + const source = [ + "function actionApply(gateway: string, sandboxName: string) {", + ' return runCmd(["openshell", "policy", "get", "-g", gateway, "--base", sandboxName], { reject: false });', + "}", + "function inspectAuthority(gateway: string, sandboxName: string) {", + ' return runCmd(["openshell", "policy", "get", "-g", gateway, "--full", "--output", "json", sandboxName]);', + "}", + ].join("\n"); + + expect(classifyPolicyReadCalls(source, "/repo/nemoclaw/src/blueprint/runner.ts", "/repo")) + .toEqual([ + { site: "actionApply", view: "base", failureHandling: "error-preserving" }, + { + site: "inspectAuthority", + view: "full", + failureHandling: "error-preserving", + }, + ]); + }); + + it("ignores direct policy reads with ambiguous view flags (#9833)", () => { + const source = [ + "function both(sandboxName: string) {", + ' return runCmd(["openshell", "policy", "get", "--base", "--full", sandboxName]);', + "}", + "function neither(sandboxName: string) {", + ' return runCmd(["openshell", "policy", "get", sandboxName]);', + "}", + ].join("\n"); + + expect(classifyPolicyReadCalls(source, "/repo/src/lib/fixture.ts", "/repo")).toEqual([]); + }); + it("classifies each read by function, policy view, and failure handling", () => { const source = [ 'import { buildPolicyGetCommand as buildBase, buildPolicyGetFullCommand as buildFull } from "./policy/commands";', diff --git a/test/runtime/policy/policy-mutation-read-failure.test.ts b/test/runtime/policy/policy-mutation-read-failure.test.ts index dee933205d0..19b5cd05ecc 100644 --- a/test/runtime/policy/policy-mutation-read-failure.test.ts +++ b/test/runtime/policy/policy-mutation-read-failure.test.ts @@ -5,12 +5,27 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const requireForTest = createRequire(import.meta.url); const policies = requireForTest( path.join(import.meta.dirname, "..", "../..", "src", "lib", "policy", "index.ts"), ) as typeof import("../../../src/lib/policy"); +const policyAuthority = requireForTest( + path.join( + import.meta.dirname, + "..", + "../..", + "src", + "lib", + "adapters", + "openshell", + "policy-authority.ts", + ), +) as typeof import("../../../src/lib/adapters/openshell/policy-authority"); +const registry = requireForTest( + path.join(import.meta.dirname, "..", "../..", "src", "lib", "state", "registry.ts"), +) as typeof import("../../../src/lib/state/registry"); const CUSTOM_PRESET = "network_policies:\n example:\n host: example.com\n"; const MALFORMED_BASE_POLICIES = [ ["network_policies string", "version: 1\nnetwork_policies: invalid\n"], @@ -28,6 +43,18 @@ const UNMARKED_NON_POLICY_MAPPINGS = [ describe("OpenShell policy mutation read failures", () => { const tempDirs: string[] = []; + beforeEach(() => { + vi.spyOn(policyAuthority, "inspectSandboxPolicyAuthority").mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + }); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "alpha", + agent: "openclaw", + policyAuthority: "nemoclaw-managed", + }); + }); + afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); diff --git a/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts b/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts index 53f09f3289d..cc2c63b5422 100644 --- a/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts +++ b/test/runtime/policy/policy-openclaw-npm-compatibility.test.ts @@ -139,6 +139,12 @@ function runLiveScenario({ initialPolicy, childScript, setMode = "success" }: Li fakeOpenshell, `#!/usr/bin/env bash set -euo pipefail +if [ "$1 $2" = "policy get" ] && [[ " $* " == *" --output json "* ]]; then + sandbox="" + for sandbox in "$@"; do :; done + printf '{"scope":"sandbox","sandbox":"%s","status":"effective","policy_source":"sandbox","policy":{}}\n' "$sandbox" + exit 0 +fi printf '%s\n' "$*" >> ${JSON.stringify(callsPath)} if [ "$1 $2" = "policy get" ]; then printf 'Version: 1\nHash: test\n---\n' diff --git a/test/runtime/policy/policy-preset-noop-disclosure.test.ts b/test/runtime/policy/policy-preset-noop-disclosure.test.ts index 31b5cd6c980..1522fb5812e 100644 --- a/test/runtime/policy/policy-preset-noop-disclosure.test.ts +++ b/test/runtime/policy/policy-preset-noop-disclosure.test.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import YAML from "yaml"; import * as policies from "../../../src/lib/policy"; @@ -38,11 +39,22 @@ function runScenario({ const openshell = path.join(root, "openshell"); fs.writeFileSync(currentPolicyPath, currentPolicy); fs.writeFileSync(callsPath, ""); + const policyMetadata = JSON.stringify({ + scope: "sandbox", + sandbox: "alpha", + status: "effective", + policy_source: "sandbox", + policy: YAML.parse(currentPolicy), + }); fs.writeFileSync( openshell, `#!/usr/bin/env bash set -euo pipefail if [ "$1 $2" = "policy get" ]; then + if [[ " $* " == *" --output json "* ]]; then + printf '%s\n' ${JSON.stringify(policyMetadata)} + exit 0 + fi printf 'Version: 1\nHash: test\n---\n' cat ${JSON.stringify(currentPolicyPath)} exit 0 diff --git a/test/runtime/policy/policy-preset-picker.test.ts b/test/runtime/policy/policy-preset-picker.test.ts index f9f53938318..861665ba484 100644 --- a/test/runtime/policy/policy-preset-picker.test.ts +++ b/test/runtime/policy/policy-preset-picker.test.ts @@ -14,9 +14,9 @@ import { describe, expect, it, vi } from "vitest"; const requireForTest = createRequire(import.meta.url); const readline = requireForTest("node:readline") as typeof import("node:readline"); const REPO_ROOT = path.join(import.meta.dirname, "../../.."); -const policies = requireForTest( - path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"), -) as typeof import("../../../src/lib/policy"); +const policyModulePath = path.join(REPO_ROOT, "src", "lib", "policy", "index.ts"); +const brandingModulePath = path.join(REPO_ROOT, "src", "lib", "cli", "branding.ts"); +const policies = requireForTest(policyModulePath) as typeof import("../../../src/lib/policy"); const SELECT_FROM_LIST_ITEMS = [ { name: "npm", description: "npm and Yarn registry access", file: "npm.yaml" }, @@ -24,6 +24,7 @@ const SELECT_FROM_LIST_ITEMS = [ ]; type AppliedOptions = { applied?: string[]; + policyModule?: typeof policies; }; type SelectionFunction = "selectFromList" | "selectForRemoval"; @@ -31,7 +32,7 @@ type SelectionFunction = "selectFromList" | "selectForRemoval"; async function runSelectionPrompt( functionName: SelectionFunction, input: string, - { applied = [] }: AppliedOptions = {}, + { applied = [], policyModule = policies }: AppliedOptions = {}, ) { const originalExitCode = process.exitCode; process.exitCode = undefined; @@ -86,7 +87,7 @@ async function runSelectionPrompt( }; try { - const selected = await policies[functionName](SELECT_FROM_LIST_ITEMS, { applied }); + const selected = await policyModule[functionName](SELECT_FROM_LIST_ITEMS, { applied }); return { selected, stderr: stderr.join(""), @@ -258,6 +259,27 @@ describe("policy preset pickers", () => { expect(result.selected).toBeNull(); }); + it("uses the invoked CLI brand in the policy recovery command", async () => { + vi.stubEnv("NEMOCLAW_INVOKED_AS", "nemohermes"); + delete require.cache[requireForTest.resolve(policyModulePath)]; + delete require.cache[requireForTest.resolve(brandingModulePath)]; + const brandedPolicies = requireForTest(policyModulePath) as typeof policies; + + try { + const result = await runSelectionPrompt("selectFromList", "1\n", { + applied: ["npm"], + policyModule: brandedPolicies, + }); + + expect(result.stderr).toContain("'nemohermes policy add npm'"); + expect(result.stderr).not.toContain("'nemoclaw policy add npm'"); + } finally { + vi.unstubAllEnvs(); + delete require.cache[requireForTest.resolve(policyModulePath)]; + delete require.cache[requireForTest.resolve(brandingModulePath)]; + } + }); + it("rejects out-of-range preset number with a failure status (#9742)", async () => { const result = await runSelectionPrompt("selectFromList", "99\n"); diff --git a/test/runtime/policy/policy-semantic-validation-runtime.test.ts b/test/runtime/policy/policy-semantic-validation-runtime.test.ts index 82e4f2d943c..784977e84cd 100644 --- a/test/runtime/policy/policy-semantic-validation-runtime.test.ts +++ b/test/runtime/policy/policy-semantic-validation-runtime.test.ts @@ -6,13 +6,29 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { runCapture } = vi.hoisted(() => ({ runCapture: vi.fn() })); +const { getSandbox, inspectSandboxPolicyAuthority, runCapture } = vi.hoisted(() => ({ + getSandbox: vi.fn(), + inspectSandboxPolicyAuthority: vi.fn(), + runCapture: vi.fn(), +})); + +vi.mock("../../../src/lib/adapters/openshell/policy-authority", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../../../src/lib/adapters/openshell/policy-authority") + >()), + inspectSandboxPolicyAuthority, +})); vi.mock("../../../src/lib/runner", async (importOriginal) => ({ ...(await importOriginal()), runCapture, })); +vi.mock("../../../src/lib/state/registry", async (importOriginal) => ({ + ...(await importOriginal()), + getSandbox, +})); + import { applyPresetContent, loadPreset, loadPresetFromFile } from "../../../src/lib/policy"; const tempDirs: string[] = []; @@ -28,6 +44,18 @@ network_policies: `; beforeEach(() => { + getSandbox.mockReset(); + getSandbox.mockImplementation((name: string) => ({ + name, + agent: "openclaw", + policies: [], + policyAuthority: "nemoclaw-managed", + })); + inspectSandboxPolicyAuthority.mockReset(); + inspectSandboxPolicyAuthority.mockReturnValue({ + authority: "nemoclaw-managed", + effectivePolicy: {}, + }); runCapture.mockReset(); }); diff --git a/test/runtime/policy/portable-policy-failure-finality.test.ts b/test/runtime/policy/portable-policy-failure-finality.test.ts index 7beb16dad35..9da609af194 100644 --- a/test/runtime/policy/portable-policy-failure-finality.test.ts +++ b/test/runtime/policy/portable-policy-failure-finality.test.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import YAML from "yaml"; const repoRoot = path.join(import.meta.dirname, "../../.."); const policyModulePath = path.join(repoRoot, "src", "lib", "policy", "index.ts"); @@ -97,8 +98,23 @@ interface PolicySetBehavior { * the `policy set --wait` result is what each scenario varies. */ function buildOpenshellStub(policySet: PolicySetBehavior, basePolicy: string): string { + const policyMetadata = JSON.stringify({ + scope: "sandbox", + sandbox: SANDBOX_NAME, + status: "effective", + policy_source: "sandbox", + policy: YAML.parse(basePolicy), + }); return `#!/bin/sh if [ "$1" = "policy" ] && [ "$2" = "get" ]; then + case " $* " in + *" --output json "*) + cat <<'JSON' +${policyMetadata} +JSON + exit 0 + ;; + esac cat <<'YAML' ${basePolicy} YAML @@ -136,12 +152,14 @@ const APPLY_PRESETS_DRIVER = * `removePreset` and `applyPreset` bypass the batch path that `applyPresets` * takes, and each composes its own policy document through its own temp file. */ -const REMOVE_PRESET_DRIVER = buildDriver( - `removePreset(${JSON.stringify(SANDBOX_NAME)}, ${JSON.stringify(PRESET_NAME)})`, -); -const APPLY_PRESET_DRIVER = buildDriver( - `applyPreset(${JSON.stringify(SANDBOX_NAME)}, ${JSON.stringify(PRESET_NAME)})`, -); +const REMOVE_PRESET_DRIVER = + `const registry = require(${JSON.stringify(registryModulePath)});\n` + + `registry.registerSandbox({ name: ${JSON.stringify(SANDBOX_NAME)}, agent: "openclaw", policies: [${JSON.stringify(PRESET_NAME)}] });\n` + + buildDriver(`removePreset(${JSON.stringify(SANDBOX_NAME)}, ${JSON.stringify(PRESET_NAME)})`); +const APPLY_PRESET_DRIVER = + `const registry = require(${JSON.stringify(registryModulePath)});\n` + + `registry.registerSandbox({ name: ${JSON.stringify(SANDBOX_NAME)}, agent: "openclaw", policies: [] });\n` + + buildDriver(`applyPreset(${JSON.stringify(SANDBOX_NAME)}, ${JSON.stringify(PRESET_NAME)})`); interface ChildRun { readonly result: SpawnSyncReturns; @@ -309,7 +327,7 @@ describe.each(POLICY_SET_FAILURES)( }, ); -describe("applyPresets when openshell policy set succeeds", () => { +describe("applyPresets when OpenShell policy set succeeds", () => { let run: ChildRun; beforeAll(() => { diff --git a/test/shields-external-policy-recovery.test.ts b/test/shields-external-policy-recovery.test.ts new file mode 100644 index 00000000000..f7e5eefee75 --- /dev/null +++ b/test/shields-external-policy-recovery.test.ts @@ -0,0 +1,474 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; +import { + createShieldsFlowHarness, + externalPolicyAuthorityInspection, + managedMcpPolicy, + managedMcpSandbox, + type ShieldsFlowHarness, +} from "./helpers/shields-flow-harness"; + +const requireSource = createRequire( + path.join(import.meta.dirname, "..", "src", "lib", "shields", "index.js"), +); +let tmpDir: string; +const TEST_PROCESS_START_IDENTITY = "test-process-start-identity"; + +function externalPolicyMutationAuthority(effectivePolicy: Record) { + return { + authority: "externally-managed" as const, + authorityRecordedNow: false, + gatewayName: "nemoclaw", + inspection: { authority: "externally-managed" as const, effectivePolicy }, + }; +} + +function prepareExternalMcpRecoveryFixture() { + const alpha = managedMcpPolicy("alpha"); + const beta = managedMcpPolicy("beta"); + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + livePolicyYaml: YAML.stringify({ + version: 1, + network_policies: { restrictive_baseline: {}, [alpha.key]: alpha.networkPolicy }, + }), + processStartIdentity: TEST_PROCESS_START_IDENTITY, + sandboxEntry: managedMcpSandbox([alpha]), + }); + harness.shieldsDown("openclaw", { throwOnError: true }); + const snapshotPath = String( + harness.getShieldsPosture("openclaw", false).state.shieldsPolicySnapshotPath, + ); + const savedPolicy = YAML.parse(fs.readFileSync(snapshotPath, "utf-8")); + const registry = requireSource( + "../state/registry.js", + ) as typeof import("../src/lib/state/registry.js"); + vi.mocked(registry.getSandbox).mockReturnValue({ + ...managedMcpSandbox([beta]), + policyAuthority: "externally-managed", + }); + return { alpha, beta, harness, savedPolicy, snapshotPath }; +} + +function bindExternalPolicyRecovery( + harness: ShieldsFlowHarness, + effectivePolicy: Record, +): void { + const authority = externalPolicyMutationAuthority(effectivePolicy); + harness.policyAuthoritySpy.mockReturnValue(authority); + harness.policyRecoveryAuthoritySpy.mockReturnValue(authority); + harness.runCaptureSpy.mockReturnValue(YAML.stringify(effectivePolicy)); +} + +function countPolicySets(harness: ShieldsFlowHarness): number { + return harness.runSpy.mock.calls.filter( + ([command]) => Array.isArray(command) && command.includes("policy") && command.includes("set"), + ).length; +} + +function readRestrictivePolicy(harness: ShieldsFlowHarness, sandboxName: string) { + const state = harness.getShieldsPosture(sandboxName, false).state; + return YAML.parse(fs.readFileSync(String(state.shieldsPolicySnapshotPath), "utf-8")) as Record< + string, + unknown + >; +} + +function readExternalRecoveryArtifact(artifactPath: string): { + content: string; + mode: number; +} { + const fileDescriptor = fs.openSync(artifactPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { + return { + content: fs.readFileSync(fileDescriptor, "utf-8"), + mode: fs.fstatSync(fileDescriptor).mode & 0o777, + }; + } finally { + fs.closeSync(fileDescriptor); + } +} + +function mismatchedExternalAuthority() { + return { + authority: "externally-managed", + authorityRecordedNow: false, + gatewayName: "nemoclaw", + inspection: externalPolicyAuthorityInspection, + } as const; +} + +function throwInjectedFailure(message: string): never { + throw new Error(message); +} + +function prepareExternalRecoveryRetirementFixture() { + const sandboxName = "openclaw"; + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + confirmOpenClawInodeFlags: true, + initialOpenClawPosture: "locked", + processStartIdentity: TEST_PROCESS_START_IDENTITY, + }); + harness.shieldsDown(sandboxName, { throwOnError: true }); + harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( + "must make the effective policy", + ); + const recoveryState = harness.getShieldsPosture(sandboxName, false).state; + const recoveryArtifact = recoveryState.externalPolicyRecoveryArtifact; + const recoveryArtifactPath = String(recoveryArtifact?.path); + const recoveryArtifactContent = readExternalRecoveryArtifact(recoveryArtifactPath).content; + const restoredExternalAuthority = externalPolicyMutationAuthority( + readRestrictivePolicy(harness, sandboxName), + ); + harness.policyAuthoritySpy.mockReturnValue(restoredExternalAuthority); + harness.policyRecoveryAuthoritySpy.mockReturnValue(restoredExternalAuthority); + return { + harness, + recoveryArtifact, + recoveryArtifactContent, + recoveryArtifactPath, + sandboxName, + }; +} + +function injectStateCommitFailure(statePath: string, recoveryArtifactPath: string): void { + const originalRenameSync = fs.renameSync.bind(fs); + let injectedFailure = false; + vi.spyOn(fs, "renameSync").mockImplementation((oldPath, newPath) => { + const shouldInject = + !injectedFailure && String(newPath) === statePath && !fs.existsSync(recoveryArtifactPath); + injectedFailure = injectedFailure || shouldInject; + return shouldInject + ? throwInjectedFailure("state commit denied") + : originalRenameSync(oldPath, newPath); + }); +} + +describe("external Shields policy recovery (#9833)", () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(`${os.tmpdir()}/nemoclaw-external-shields-recovery-`); + vi.stubEnv("HOME", tmpDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + delete require.cache[requireSource.resolve("./index.js")]; + delete require.cache[requireSource.resolve("./timer-bound-lock.js")]; + delete require.cache[requireSource.resolve("./transition-lock.js")]; + delete require.cache[requireSource.resolve("./permissive-runtime.js")]; + delete require.cache[requireSource.resolve("../actions/sandbox/mcp-bridge-policy.js")]; + delete require.cache[requireSource.resolve("../cli/branding.js")]; + }); + + it("publishes the complete current MCP policy handoff (#9833)", () => { + const { alpha, beta, harness, savedPolicy, snapshotPath } = prepareExternalMcpRecoveryFixture(); + bindExternalPolicyRecovery(harness, savedPolicy); + + expect(() => + harness.applyShieldsPolicySnapshot("openclaw", snapshotPath, { + persistExternalRecoveryArtifact: true, + }), + ).toThrow(/must make the effective policy/iu); + + const requiredPolicy = structuredClone(savedPolicy); + delete requiredPolicy.network_policies[alpha.key]; + requiredPolicy.network_policies[beta.key] = beta.networkPolicy; + const recoveryPolicy = YAML.parse( + fs.readFileSync( + path.join(tmpDir, ".nemoclaw", "state", "shields-external-policy-openclaw.yaml"), + "utf-8", + ), + ); + expect(recoveryPolicy.network_policies).not.toHaveProperty(alpha.key); + expect(recoveryPolicy.network_policies[beta.key]).toEqual(beta.networkPolicy); + bindExternalPolicyRecovery(harness, requiredPolicy); + expect(harness.applyShieldsPolicySnapshot("openclaw", snapshotPath).status).toBe(0); + }); + + it("bounds and escapes control characters in policy-key diagnostics (#9833)", () => { + const unsafeKey = "safe\n\u001b[31m\u0085"; + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + initialOpenClawPosture: "locked", + livePolicyYaml: YAML.stringify({ version: 1, network_policies: { [unsafeKey]: {} } }), + processStartIdentity: TEST_PROCESS_START_IDENTITY, + }); + harness.shieldsDown("openclaw", { throwOnError: true }); + harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + + expect(() => harness.shieldsUp("openclaw", { throwOnError: true })).toThrow( + String.raw`network policy keys: "safe\u000a\u001b[31m\u0085"`, + ); + }); + + it("locks configuration only after external authority restores the exact snapshot (#9833)", () => { + const sandboxName = "openclaw"; + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + confirmOpenClawInodeFlags: true, + initialOpenClawPosture: "locked", + processStartIdentity: TEST_PROCESS_START_IDENTITY, + }); + harness.shieldsDown(sandboxName, { throwOnError: true }); + const policySetsAfterDown = countPolicySets(harness); + harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( + "must make the effective policy", + ); + expect(countPolicySets(harness)).toBe(policySetsAfterDown); + expect(harness.getOpenClawPosture()).toBe("mutable"); + const recoveryState = harness.getShieldsPosture(sandboxName, false).state; + const recoveryArtifactPath = String(recoveryState.externalPolicyRecoveryArtifact?.path); + const recoveryArtifactBeforeStatus = readExternalRecoveryArtifact(recoveryArtifactPath); + expect(recoveryArtifactBeforeStatus.mode).toBe(0o600); + expect(YAML.parse(recoveryArtifactBeforeStatus.content)).toEqual( + readRestrictivePolicy(harness, sandboxName), + ); + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain(recoveryArtifactPath); + + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process exit ${String(code)}`); + }) as typeof process.exit); + expect(() => harness.shieldsStatus(sandboxName, false)).toThrow("process exit 2"); + expect(harness.errorSpy.mock.calls.flat().join("\n")).toContain( + "must make the effective policy", + ); + expect(readExternalRecoveryArtifact(recoveryArtifactPath).content).toBe( + recoveryArtifactBeforeStatus.content, + ); + + const restoredExternalAuthority = externalPolicyMutationAuthority( + readRestrictivePolicy(harness, sandboxName), + ); + harness.policyAuthoritySpy.mockReturnValue(restoredExternalAuthority); + harness.policyRecoveryAuthoritySpy.mockReturnValue(restoredExternalAuthority); + + expect(() => harness.shieldsStatus(sandboxName, false)).toThrow("process exit 2"); + const verifiedUnlockedStatus = harness.errorSpy.mock.calls.flat().join("\n"); + expect(verifiedUnlockedStatus).toContain("to lock configuration and commit Shields UP"); + expect(verifiedUnlockedStatus).not.toContain("Configuration is already locked"); + + harness.shieldsUp(sandboxName, { throwOnError: true }); + + expect(harness.isShieldsDown(sandboxName)).toBe(false); + expect(harness.getOpenClawPosture()).toBe("locked"); + expect(countPolicySets(harness)).toBe(policySetsAfterDown); + expect(fs.existsSync(recoveryArtifactPath)).toBe(false); + expect(harness.getShieldsPosture(sandboxName, false).state).not.toHaveProperty( + "externalPolicyRecoveryArtifact", + ); + }); + + it("removes the external recovery artifact when Shields state is cleared (#9833)", () => { + const sandboxName = "openclaw"; + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + confirmOpenClawInodeFlags: true, + initialOpenClawPosture: "locked", + processStartIdentity: TEST_PROCESS_START_IDENTITY, + }); + harness.shieldsDown(sandboxName, { throwOnError: true }); + harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( + "must make the effective policy", + ); + const recoveryState = harness.getShieldsPosture(sandboxName, false).state; + const recoveryArtifactPath = String(recoveryState.externalPolicyRecoveryArtifact?.path); + expect(fs.existsSync(recoveryArtifactPath)).toBe(true); + + harness.clearShieldsState(sandboxName); + + expect(fs.existsSync(recoveryArtifactPath)).toBe(false); + expect(harness.getShieldsPosture(sandboxName, false).mode).toBe("mutable_default"); + }); + + it("keeps the external recovery artifact bound when state cleanup cannot remove it (#9833)", () => { + const sandboxName = "openclaw"; + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + confirmOpenClawInodeFlags: true, + initialOpenClawPosture: "locked", + processStartIdentity: TEST_PROCESS_START_IDENTITY, + }); + harness.shieldsDown(sandboxName, { throwOnError: true }); + harness.policyAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + harness.policyRecoveryAuthoritySpy.mockReturnValue(mismatchedExternalAuthority()); + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( + "must make the effective policy", + ); + const recoveryState = harness.getShieldsPosture(sandboxName, false).state; + const recoveryArtifactPath = String(recoveryState.externalPolicyRecoveryArtifact?.path); + const removalError = new Error("permission denied") as NodeJS.ErrnoException; + removalError.code = "EACCES"; + vi.spyOn(fs, "rmSync").mockImplementationOnce((artifactPath) => { + expect(String(artifactPath)).toBe(recoveryArtifactPath); + throw removalError; + }); + + expect(() => harness.clearShieldsState(sandboxName)).toThrow( + `Could not remove external Shields policy recovery artifact '${recoveryArtifactPath}': permission denied`, + ); + + expect(fs.existsSync(recoveryArtifactPath)).toBe(true); + expect( + harness.getShieldsPosture(sandboxName, false).state.externalPolicyRecoveryArtifact?.path, + ).toBe(recoveryArtifactPath); + }); + + it("restores the bound recovery artifact when its removal cannot be made durable (#9833)", () => { + const { + harness, + recoveryArtifact, + recoveryArtifactContent, + recoveryArtifactPath, + sandboxName, + } = prepareExternalRecoveryRetirementFixture(); + const originalRmSync = fs.rmSync.bind(fs); + const originalFsyncSync = fs.fsyncSync.bind(fs); + let failNextDirectoryFsync = false; + let injectedFailure = false; + vi.spyOn(fs, "rmSync").mockImplementation((filePath, options) => { + const shouldInject = String(filePath) === recoveryArtifactPath && !injectedFailure; + originalRmSync(filePath, options); + failNextDirectoryFsync = failNextDirectoryFsync || shouldInject; + injectedFailure = injectedFailure || shouldInject; + }); + vi.spyOn(fs, "fsyncSync").mockImplementation((fileDescriptor) => { + const shouldInject = failNextDirectoryFsync; + failNextDirectoryFsync = false; + return shouldInject + ? throwInjectedFailure("directory sync denied") + : originalFsyncSync(fileDescriptor); + }); + + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( + "Could not make removal of external Shields policy recovery artifact", + ); + + expect(readExternalRecoveryArtifact(recoveryArtifactPath).content).toBe( + recoveryArtifactContent, + ); + expect( + harness.getShieldsPosture(sandboxName, false).state.externalPolicyRecoveryArtifact, + ).toEqual(recoveryArtifact); + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).not.toThrow(); + expect(fs.existsSync(recoveryArtifactPath)).toBe(false); + }); + + it("restores the bound recovery artifact when the Shields state commit fails (#9833)", () => { + const { + harness, + recoveryArtifact, + recoveryArtifactContent, + recoveryArtifactPath, + sandboxName, + } = prepareExternalRecoveryRetirementFixture(); + const statePath = path.join(tmpDir, ".nemoclaw", "state", `shields-${sandboxName}.json`); + injectStateCommitFailure(statePath, recoveryArtifactPath); + + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( + "Could not commit Shields state after removing external policy recovery artifact", + ); + + expect(readExternalRecoveryArtifact(recoveryArtifactPath).content).toBe( + recoveryArtifactContent, + ); + expect( + harness.getShieldsPosture(sandboxName, false).state.externalPolicyRecoveryArtifact, + ).toEqual(recoveryArtifact); + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).not.toThrow(); + expect(fs.existsSync(recoveryArtifactPath)).toBe(false); + }); + + it("does not claim to restore an unbound recovery artifact after a state failure (#9833)", () => { + const { harness, recoveryArtifactPath, sandboxName } = + prepareExternalRecoveryRetirementFixture(); + const statePath = path.join(tmpDir, ".nemoclaw", "state", `shields-${sandboxName}.json`); + const state = JSON.parse(fs.readFileSync(statePath, "utf-8")) as Record; + delete state.externalPolicyRecoveryArtifact; + fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); + injectStateCommitFailure(statePath, recoveryArtifactPath); + + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( + "restored Shields state; no bound artifact was available to restore", + ); + + expect(fs.existsSync(recoveryArtifactPath)).toBe(false); + expect(harness.getShieldsPosture(sandboxName, false).state).not.toHaveProperty( + "externalPolicyRecoveryArtifact", + ); + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).not.toThrow(); + }); + + it("withholds Shields success when external policy changes during config locking (#9833)", () => { + const sandboxName = "openclaw"; + const harness = createShieldsFlowHarness(requireSource, tmpDir, { + confirmOpenClawInodeFlags: true, + initialOpenClawPosture: "locked", + processStartIdentity: TEST_PROCESS_START_IDENTITY, + }); + harness.shieldsDown(sandboxName, { throwOnError: true }); + const policySetsAfterDown = countPolicySets(harness); + const restoredExternalAuthority = externalPolicyMutationAuthority( + readRestrictivePolicy(harness, sandboxName), + ); + const changedExternalAuthority = externalPolicyMutationAuthority({ + version: 1, + network_policies: {}, + }); + harness.policyAuthoritySpy.mockReturnValue(restoredExternalAuthority); + harness.policyRecoveryAuthoritySpy + .mockReturnValueOnce(restoredExternalAuthority) + .mockReturnValueOnce(restoredExternalAuthority) + .mockReturnValue(changedExternalAuthority); + + expect(() => harness.shieldsUp(sandboxName, { throwOnError: true })).toThrow( + "policy verification after config lock failed", + ); + + expect(harness.getOpenClawPosture()).toBe("locked"); + expect(countPolicySets(harness)).toBe(policySetsAfterDown); + const errors = harness.errorSpy.mock.calls.flat().join("\n"); + expect(errors).toContain( + "Config remains locked; Shields remain DOWN until policy verification succeeds.", + ); + expect(errors).not.toContain("Config remains unlocked"); + expect(harness.auditSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "shields_up" }), + ); + const lockedRecovery = harness.getShieldsPosture(sandboxName, false); + const lockedRecoveryArtifactPath = String( + lockedRecovery.state.externalPolicyRecoveryArtifact?.path, + ); + expect(lockedRecovery.mode).toBe("locked_recovery"); + expect(fs.existsSync(lockedRecoveryArtifactPath)).toBe(true); + expect(harness.isShieldsDown(sandboxName)).toBe(false); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process exit ${String(code)}`); + }) as typeof process.exit); + expect(() => harness.shieldsStatus(sandboxName, false)).toThrow("process exit 2"); + expect(harness.getOpenClawPosture()).toBe("locked"); + harness.policyRecoveryAuthoritySpy.mockReturnValue(restoredExternalAuthority); + expect(() => harness.shieldsStatus(sandboxName, false)).toThrow("process exit 2"); + const verifiedLockedStatus = harness.errorSpy.mock.calls.flat().join("\n"); + expect(verifiedLockedStatus).toContain("Configuration is already locked"); + expect(verifiedLockedStatus).toContain(lockedRecoveryArtifactPath); + expect(verifiedLockedStatus).not.toContain("to lock configuration and commit Shields UP"); + harness.shieldsUp(sandboxName, { throwOnError: true }); + expect(harness.isShieldsDown(sandboxName)).toBe(false); + expect(harness.getOpenClawPosture()).toBe("locked"); + expect(fs.existsSync(lockedRecoveryArtifactPath)).toBe(false); + }); +});