Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 23 additions & 2 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const {
const { resolveNemoclawStateDir } = require("../state/paths");
const { appendAuditEntry } = require("./audit");
const { resolveAgentConfig } = require("../sandbox/config");
const {
buildRuntimePermissivePolicy,
}: typeof import("./permissive-runtime") = require("./permissive-runtime");
const { cleanupTempDir } = require("../onboard/temp-files");

const STATE_DIR = resolveNemoclawStateDir();

Expand Down Expand Up @@ -902,8 +906,19 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void {

// 2. Determine and apply relaxed policy
let policyFile: string;
let policyFileIsTemp = false;
if (policyName === "permissive") {
policyFile = resolvePermissivePolicyPath(sandboxName);
const basePath = resolvePermissivePolicyPath(sandboxName);
// Union the live sandbox's filesystem_policy into the static permissive
// baseline. OpenShell rejects removal of read_only / read_write paths
// on a live sandbox, and runtime-injected entries (/proc on GPU,
// /opt/hermes on Hermes, /home/linuxbrew on post-#3913 OpenClaw, etc.)
// are not present in the static YAML. See #3942, #3957, #3168.
policyFile = buildRuntimePermissivePolicy(sandboxName, basePath, {
fetchLivePolicy: () => rawPolicy,
readBasePolicy: () => fs.readFileSync(basePath, "utf-8"),
});
policyFileIsTemp = policyFile !== basePath;
} else if (fs.existsSync(policyName)) {
policyFile = path.resolve(policyName);
} else {
Expand All @@ -914,7 +929,13 @@ function shieldsDown(sandboxName: string, opts: ShieldsDownOpts = {}): void {
}

console.log(` Applying ${policyName} policy...`);
run(buildPolicySetCommand(policyFile, sandboxName));
try {
run(buildPolicySetCommand(policyFile, sandboxName));
} finally {
if (policyFileIsTemp) {
cleanupTempDir(policyFile, "nemoclaw-permissive-runtime");
}
}

// 2b. Return config to default mutable state.
// OpenClaw uses sandbox:sandbox 0660/2770 here so the gateway UID, which
Expand Down
131 changes: 131 additions & 0 deletions src/lib/shields/permissive-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import YAML from "yaml";

import { secureTempFile } from "../onboard/temp-files";

/**
* Build a permissive policy YAML that is guaranteed to be a strict superset
* of the live sandbox's filesystem policy.
*
* Background (#3942, #3957, #3168): OpenShell refuses to remove a
* `filesystem_policy.read_only` or `filesystem_policy.read_write` entry on a
* live sandbox. The static `openclaw-sandbox-permissive.yaml` baseline does
* not see runtime-injected paths — `/proc` on GPU sandboxes, `/opt/hermes`
* on Hermes, `/home/linuxbrew` on post-#3913 OpenClaw, and any future
* agent- or feature-specific enrichment. Each of those past mismatches
* shipped its own permissive-YAML patch. This helper closes the loop by
* unioning whatever the live sandbox advertises into the permissive YAML
* before it is applied, so future runtime injections are absorbed
* automatically.
*
* Resolution rules when a path appears on both sides:
* - Live `read_write` is the more permissive of the two and takes priority:
* if the live state writes a path, the permissive transition keeps it
* writable, removing it from `read_only` first so we never emit a path
* in both lists.
* - Live `read_only` is merged into base `read_only` only when the same
* path is not already granted `read_write` (either by base or by live).
*
* Returns the path to a freshly created temp YAML file. Falls back to the
* base permissive path if the live policy can't be parsed or omits the
* filesystem section — degrading to the existing static behavior rather
* than failing closed.
*/
export interface PermissiveRuntimeDeps {
fetchLivePolicy: (sandboxName: string) => string;
readBasePolicy: () => string;
}

export function buildRuntimePermissivePolicy(
sandboxName: string,
basePermissivePath: string,
deps: PermissiveRuntimeDeps,
): string {
const liveRaw = deps.fetchLivePolicy(sandboxName);
const liveYaml = parsePolicyBlock(liveRaw);
const live = liveYaml ? safeYamlObject(liveYaml) : null;
const liveRw = readStringList(live, "read_write");
const liveRo = readStringList(live, "read_only");

// No live filesystem section to merge — keep the static path so the
// caller's apply path is unchanged.
if (liveRw.length === 0 && liveRo.length === 0) {
return basePermissivePath;
}

const baseYaml = deps.readBasePolicy();
const base = safeYamlObject(baseYaml);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (!base) {
return basePermissivePath;
}
const fsPolicy =
base.filesystem_policy && typeof base.filesystem_policy === "object"
? (base.filesystem_policy as Record<string, unknown>)
: ((base.filesystem_policy = {} as Record<string, unknown>),
base.filesystem_policy as Record<string, unknown>);

const baseRw = new Set(readStringList(base, "read_write"));
const baseRo = new Set(readStringList(base, "read_only"));

// RW wins: a live write-path must stay writable in the new policy, and
// the same path cannot also live in read_only afterwards.
for (const p of liveRw) {
baseRo.delete(p);
baseRw.add(p);
}
for (const p of liveRo) {
if (!baseRw.has(p)) baseRo.add(p);
}

fsPolicy.read_write = [...baseRw];
fsPolicy.read_only = [...baseRo];

const tmpPath = secureTempFile("nemoclaw-permissive-runtime", ".yaml");
fs.writeFileSync(tmpPath, YAML.stringify(base), { mode: 0o600 });
return tmpPath;
}

function safeYamlObject(text: string): Record<string, unknown> | null {
try {
const parsed = YAML.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return null;
}
return null;
}

function readStringList(
root: Record<string, unknown> | null,
key: "read_only" | "read_write",
): string[] {
const fsPolicy = root?.filesystem_policy;
if (!fsPolicy || typeof fsPolicy !== "object") return [];
const value = (fsPolicy as Record<string, unknown>)[key];
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === "string");
}

// Lightweight clone of policy/index.ts:parseCurrentPolicy that strips the
// OpenShell header / error preamble before YAML.parse. Inlined to avoid a
// runtime cycle with the policy module.
function parsePolicyBlock(raw: string | null | undefined): string {
if (!raw) return "";
const sep = raw.indexOf("---");
const candidate = (sep === -1 ? raw : raw.slice(sep + 3)).trim();
if (!candidate) return "";
if (/^(error|failed|invalid|warning|status)\b/i.test(candidate)) return "";
if (!/^[a-z_][a-z0-9_]*\s*:/m.test(candidate)) return "";
try {
const parsed = YAML.parse(candidate);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
} catch {
return "";
}
return candidate;
}
137 changes: 137 additions & 0 deletions test/permissive-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import path from "node:path";
import { describe, it, expect, afterEach } from "vitest";
import YAML from "yaml";

import { buildRuntimePermissivePolicy } from "../dist/lib/shields/permissive-runtime.js";

const BASE_PERMISSIVE = YAML.stringify({
filesystem_policy: {
read_only: ["/proc", "/etc"],
read_write: ["/tmp", "/sandbox/.openclaw"],
},
landlock: { compatibility: "best_effort" },
});

const tempFilesToClean: string[] = [];

afterEach(() => {
while (tempFilesToClean.length > 0) {
const p = tempFilesToClean.pop();
if (!p) continue;
try {
fs.rmSync(path.dirname(p), { recursive: true, force: true });
} catch {
// best-effort
}
}
});

function withWrapper(yaml: string): string {
// Mirror `openshell policy get --full` output shape: a header line, then
// `---`, then the YAML body. parsePolicyBlock should strip the prefix.
return `policy: openclaw-sandbox\n---\n${yaml}`;
}

describe("buildRuntimePermissivePolicy (#3942)", () => {
it("preserves /proc when the live GPU sandbox has it in read_write", () => {
const live = YAML.stringify({
filesystem_policy: {
read_only: ["/etc", "/usr"],
// GPU enrichment from src/lib/onboard/initial-policy.ts:57.
read_write: ["/tmp", "/proc", "/home/linuxbrew"],
},
});

const out = buildRuntimePermissivePolicy("alpha", "/unused-base.yaml", {
fetchLivePolicy: () => withWrapper(live),
readBasePolicy: () => BASE_PERMISSIVE,
});
tempFilesToClean.push(out);

const result = YAML.parse(fs.readFileSync(out, "utf-8"));
expect(result.filesystem_policy.read_write).toEqual(
expect.arrayContaining(["/tmp", "/sandbox/.openclaw", "/proc", "/home/linuxbrew"]),
);
// /proc must NOT also appear in read_only; rw wins.
expect(result.filesystem_policy.read_only).not.toContain("/proc");
});

it("merges live read_only paths into base read_only without clobbering rw", () => {
const live = YAML.stringify({
filesystem_policy: {
// /tmp is in base read_write — live ro should NOT downgrade it.
read_only: ["/usr", "/tmp"],
read_write: [],
},
});

const out = buildRuntimePermissivePolicy("alpha", "/unused-base.yaml", {
fetchLivePolicy: () => withWrapper(live),
readBasePolicy: () => BASE_PERMISSIVE,
});
tempFilesToClean.push(out);

const result = YAML.parse(fs.readFileSync(out, "utf-8"));
expect(result.filesystem_policy.read_write).toContain("/tmp");
expect(result.filesystem_policy.read_only).toContain("/usr");
expect(result.filesystem_policy.read_only).not.toContain("/tmp");
});

it("deduplicates entries within each list and across lists", () => {
const live = YAML.stringify({
filesystem_policy: {
read_only: ["/etc", "/etc"], // duplicate within live ro
read_write: ["/tmp", "/tmp", "/proc"], // duplicate within live rw
},
});

const out = buildRuntimePermissivePolicy("alpha", "/unused-base.yaml", {
fetchLivePolicy: () => withWrapper(live),
readBasePolicy: () => BASE_PERMISSIVE,
});
tempFilesToClean.push(out);

const result = YAML.parse(fs.readFileSync(out, "utf-8"));
const rwCount = result.filesystem_policy.read_write.filter((p: string) => p === "/tmp").length;
const roCount = result.filesystem_policy.read_only.filter((p: string) => p === "/etc").length;
expect(rwCount).toBe(1);
expect(roCount).toBe(1);
// No path appears in both lists.
const rwSet = new Set(result.filesystem_policy.read_write);
for (const p of result.filesystem_policy.read_only) {
expect(rwSet.has(p)).toBe(false);
}
});

it("returns the static base path when live policy is empty / unparseable", () => {
const basePath = "/path/to/static.yaml";
const out = buildRuntimePermissivePolicy("alpha", basePath, {
fetchLivePolicy: () => "",
readBasePolicy: () => BASE_PERMISSIVE,
});
expect(out).toBe(basePath);
});

it("returns the static base path when openshell policy get errored", () => {
const basePath = "/path/to/static.yaml";
const out = buildRuntimePermissivePolicy("alpha", basePath, {
fetchLivePolicy: () => "Error: sandbox not found",
readBasePolicy: () => BASE_PERMISSIVE,
});
expect(out).toBe(basePath);
});

it("returns the static base path when live policy has no filesystem_policy section", () => {
const basePath = "/path/to/static.yaml";
const live = YAML.stringify({ landlock: { compatibility: "best_effort" } });
const out = buildRuntimePermissivePolicy("alpha", basePath, {
fetchLivePolicy: () => withWrapper(live),
readBasePolicy: () => BASE_PERMISSIVE,
});
expect(out).toBe(basePath);
});
});
Loading