Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion src/lib/shields/flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -61,6 +74,7 @@ type HarnessOptions = {
send: () => boolean;
kill: () => boolean;
};
livePolicyYaml?: string;
run?: (cmd: unknown) => { status: number };
};

Expand Down Expand Up @@ -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 };
});
Expand Down Expand Up @@ -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");
Expand Down
32 changes: 20 additions & 12 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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");
}
}

Expand Down
Loading