diff --git a/src/lib/shields/flow.test.ts b/src/lib/shields/flow.test.ts index 1fd6bc6f8aa..cc80f7bfa42 100644 --- a/src/lib/shields/flow.test.ts +++ b/src/lib/shields/flow.test.ts @@ -18,6 +18,19 @@ const childReadyPath = process.argv[3]; spawn(process.execPath, [childScriptPath, childReadyPath], { stdio: "ignore" }); setInterval(() => {}, 60000); `; +// Model the filesystem paths returned by `openshell policy get --base`. +// The Shields down transition must apply the merged policy from a runtime temp directory. +const LIVE_POLICY_WITH_FILESYSTEM_PATHS = [ + "version: 1", + "filesystem_policy:", + " read_only:", + " - /etc", + " read_write:", + " - /opt/hermes", + "network_policies:", + " test: {}", + "", +].join("\n"); const WEAKENING_CHILD_SOURCE = ` const fs = require("node:fs"); const childReadyPath = process.argv[2]; @@ -61,6 +74,7 @@ type HarnessOptions = { send: () => boolean; kill: () => boolean; }; + livePolicyYaml?: string; run?: (cmd: unknown) => { status: number }; }; @@ -90,7 +104,9 @@ function createHarness(options: HarnessOptions = {}): ShieldsHarness { let openClawPosture: "locked" | "mutable" = "mutable"; vi.spyOn(runner, "validateName").mockImplementation((name: unknown) => String(name)); - vi.spyOn(runner, "runCapture").mockReturnValue("version: 1\nnetwork_policies:\n test: {}\n"); + vi.spyOn(runner, "runCapture").mockReturnValue( + options.livePolicyYaml ?? "version: 1\nnetwork_policies:\n test: {}\n", + ); const runSpy = vi.spyOn(runner, "run").mockImplementation((cmd: unknown) => { return options.run ? options.run(cmd) : { status: 0 }; }); @@ -874,6 +890,72 @@ describe("shields command flow", () => { expect(fs.existsSync(path.join(stateDir, "shields-timer-openclaw.json"))).toBe(true); }); + it("applies the merged permissive policy before removing its runtime temp directory (#7964)", () => { + const harness = createHarness({ livePolicyYaml: LIVE_POLICY_WITH_FILESYSTEM_PATHS }); + const policy = requireDist("../policy/index.js"); + const systemTemp = fs.mkdtempSync(path.join(tmpDir, "system-temp-")); + vi.stubEnv("TMPDIR", systemTemp); + let appliedPolicyPath = ""; + let appliedPolicyBody = ""; + vi.spyOn(policy, "buildPolicySetCommand").mockImplementation((policyPath: unknown) => { + appliedPolicyPath = String(policyPath); + appliedPolicyBody = fs.readFileSync(appliedPolicyPath, "utf-8"); + return ["openshell", "policy", "set"]; + }); + + harness.shieldsDown("openclaw", { skipTimer: true, throwOnError: true }); + + const appliedPolicyDir = path.dirname(appliedPolicyPath); + expect(path.dirname(appliedPolicyDir)).toBe(systemTemp); + expect(path.basename(appliedPolicyDir)).toMatch(/^nemoclaw-permissive-runtime-/); + expect(appliedPolicyBody).toContain("/opt/hermes"); + expect(appliedPolicyBody).toContain("/etc"); + expect(fs.readdirSync(systemTemp)).toEqual([]); + }); + + it("removes the permissive runtime temp directory when the auto-restore timer cannot start (#7964)", () => { + const harness = createHarness({ + livePolicyYaml: LIVE_POLICY_WITH_FILESYSTEM_PATHS, + fork: () => ({ + pid: 0, + disconnect: vi.fn(), + unref: vi.fn(), + send: vi.fn(() => true), + kill: vi.fn(() => true), + }), + }); + const systemTemp = fs.mkdtempSync(path.join(tmpDir, "system-temp-")); + vi.stubEnv("TMPDIR", systemTemp); + + expect(() => harness.shieldsDown("openclaw", { timeout: "5m", throwOnError: true })).toThrow( + /Cannot start auto-restore timer/, + ); + + expect(fs.readdirSync(systemTemp)).toEqual([]); + }); + + it("leaves no permissive runtime temp directory when the Shields down state write fails (#7964)", () => { + const harness = createHarness({ livePolicyYaml: LIVE_POLICY_WITH_FILESYSTEM_PATHS }); + const systemTemp = fs.mkdtempSync(path.join(tmpDir, "system-temp-")); + const statePath = path.join(tmpDir, ".nemoclaw", "state", "shields-openclaw.json"); + const originalWriteFileSync = fs.writeFileSync; + vi.stubEnv("TMPDIR", systemTemp); + vi.spyOn(fs, "writeFileSync").mockImplementation((( + target: fs.PathOrFileDescriptor, + ...args: unknown[] + ) => + String(target) === statePath + ? throwHarnessError(new Error("state write failed")) + : Reflect.apply(originalWriteFileSync, fs, [target, ...args])) as typeof fs.writeFileSync); + + expect(() => harness.shieldsDown("openclaw", { skipTimer: true, throwOnError: true })).toThrow( + "state write failed", + ); + + expect(fs.readdirSync(systemTemp)).toEqual([]); + expect(harness.runSpy).not.toHaveBeenCalled(); + }); + it("shieldsUp refuses to mark lockdown active when the saved restrictive policy snapshot is missing", () => { const harness = createHarness(); const stateDir = path.join(tmpDir, ".nemoclaw", "state"); diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index cd3110b8d3f..c1f650a02df 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -2678,9 +2678,12 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = fs.writeFileSync(snapshotPath, policyYaml, { mode: 0o600 }); console.log(` Saved: ${snapshotPath}`); - // 2. Determine and apply relaxed policy - let policyFile: string; - let policyFileIsTemp = false; + // 2. Resolve the relaxed policy source. Reject an unknown policy before + // committing recovery state or changing sandbox policy and configuration. + // Materialize a permissive policy only inside the `try` that applies it. + // Its `finally` block owns cleanup of the runtime temp directory. + // Materializing it here would leave the directory after an earlier failure. + let resolvePolicyFile: () => { path: string; isTemp: boolean }; if (policyName === "permissive") { const basePath = resolvePermissivePolicyPath(sandboxName); // Union the live sandbox's filesystem_policy.read_only/read_write into @@ -2690,13 +2693,16 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = // etc.) are not present in the static YAML. See #3942, #3957, #3168. // policyYaml is the pre-parsed body we already captured for the // snapshot above — reuse it instead of re-fetching. - policyFile = buildRuntimePermissivePolicy(basePath, { - livePolicyYaml: policyYaml, - readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), - }); - policyFileIsTemp = policyFile !== basePath; + resolvePolicyFile = () => { + const merged = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: policyYaml, + readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), + }); + return { path: merged, isTemp: merged !== basePath }; + }; } else if (fs.existsSync(policyName)) { - policyFile = path.resolve(policyName); + const resolved = path.resolve(policyName); + resolvePolicyFile = () => ({ path: resolved, isTemp: false }); } else { console.error(` Unknown policy "${policyName}". Use "permissive" or a path to a YAML file.`); return failShieldsCommand(`Unknown policy "${policyName}"`, opts.throwOnError); @@ -2814,11 +2820,13 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts = } console.log(` Applying ${policyName} policy...`); + let policyFile: { path: string; isTemp: boolean } | null = null; try { - run(buildPolicySetCommand(policyFile, sandboxName)); + policyFile = resolvePolicyFile(); + run(buildPolicySetCommand(policyFile.path, sandboxName)); } finally { - if (policyFileIsTemp) { - cleanupTempDir(policyFile, "nemoclaw-permissive-runtime"); + if (policyFile?.isTemp) { + cleanupTempDir(policyFile.path, "nemoclaw-permissive-runtime"); } }