diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 9f8a7e315a4..ceba4524396 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -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(); @@ -902,8 +906,21 @@ 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.read_only/read_write into + // the static permissive baseline. OpenShell rejects removal of those + // paths on a live sandbox, and runtime-injected entries (/proc on + // GPU, /opt/hermes on Hermes, /home/linuxbrew on post-#3913 OpenClaw, + // etc.) are not present in the static YAML. See #3942, #3957, #3168. + // policyYaml is the pre-parsed body we already captured for the + // snapshot above — reuse it instead of re-fetching. + policyFile = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: policyYaml, + readBasePolicy: () => fs.readFileSync(basePath, "utf-8"), + }); + policyFileIsTemp = policyFile !== basePath; } else if (fs.existsSync(policyName)) { policyFile = path.resolve(policyName); } else { @@ -914,7 +931,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 diff --git a/src/lib/shields/permissive-runtime.ts b/src/lib/shields/permissive-runtime.ts new file mode 100644 index 00000000000..46f523f60be --- /dev/null +++ b/src/lib/shields/permissive-runtime.ts @@ -0,0 +1,154 @@ +// 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 { cleanupTempDir, secureTempFile } from "../onboard/temp-files"; + +const TEMP_FILE_PREFIX = "nemoclaw-permissive-runtime"; + +/** + * Build a permissive policy YAML whose filesystem path lists + * (`filesystem_policy.read_only` + `filesystem_policy.read_write`) are a + * superset of the live sandbox's, so OpenShell never has to remove a path + * on a live transition. + * + * Only the two path lists are unioned. Other `filesystem_policy` fields + * (e.g. `include_workdir`) are preserved verbatim from the static base — + * the bug class this helper exists for is path removal on a live sandbox, + * not policy shape changes. + * + * 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 + * past mismatch 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 in both `read_only` and + * `read_write`: + * - 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 when the live + * policy carries a filesystem section that needs merging. Falls back to + * the static base path when the live policy is empty / has no filesystem + * lists, when the base YAML cannot be parsed, or when temp-file I/O + * fails — degrading to the existing static apply path rather than + * aborting shields-down with an I/O error. + */ +export interface PermissiveRuntimeDeps { + // Pre-parsed live policy YAML body (e.g. parseCurrentPolicy(rawPolicy) + // from the caller, which already strips the OpenShell header). Passed + // in rather than fetched here so live-policy acquisition stays + // outside this helper — the helper itself still does I/O (base read, + // temp file write) but does not shell out to openshell. + livePolicyYaml: string; + // Lazy because callers may want to defer the read until the helper + // actually needs it. The returned string is parsed by YAML.parse. + readBasePolicy: () => string; + // Injectable temp-file writer. Defaults to fs.writeFileSync via + // secureTempFile when omitted. Exposed so tests can drive the + // write-failure fallback path without monkey-patching node:fs. + writeTempPolicy?: (yaml: string) => string; +} + +export function buildRuntimePermissivePolicy( + basePermissivePath: string, + deps: PermissiveRuntimeDeps, +): string { + const live = deps.livePolicyYaml ? safeYamlObject(deps.livePolicyYaml) : 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; + } + + let baseYaml: string; + try { + baseYaml = deps.readBasePolicy(); + } catch { + return basePermissivePath; + } + const base = safeYamlObject(baseYaml); + if (!base) { + return basePermissivePath; + } + const fsPolicy = + base.filesystem_policy && typeof base.filesystem_policy === "object" + ? (base.filesystem_policy as Record) + : ((base.filesystem_policy = {} as Record), + base.filesystem_policy as Record); + + 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 yaml = YAML.stringify(base); + if (deps.writeTempPolicy) { + try { + return deps.writeTempPolicy(yaml); + } catch { + return basePermissivePath; + } + } + let tmpPath: string | null = null; + try { + tmpPath = secureTempFile(TEMP_FILE_PREFIX, ".yaml"); + fs.writeFileSync(tmpPath, yaml, { mode: 0o600 }); + return tmpPath; + } catch { + // secureTempFile may have created an mkdtemp directory before + // writeFileSync failed. Clean it up so we do not leak a 0700 dir + // on /tmp every time the write path errors. + if (tmpPath) cleanupTempDir(tmpPath, TEMP_FILE_PREFIX); + return basePermissivePath; + } +} + +function safeYamlObject(text: string): Record | null { + try { + const parsed = YAML.parse(text); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return null; + } + return null; +} + +function readStringList( + root: Record | null, + key: "read_only" | "read_write", +): string[] { + const fsPolicy = root?.filesystem_policy; + if (!fsPolicy || typeof fsPolicy !== "object") return []; + const value = (fsPolicy as Record)[key]; + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} diff --git a/test/permissive-runtime.test.ts b/test/permissive-runtime.test.ts new file mode 100644 index 00000000000..ddbd8a26dda --- /dev/null +++ b/test/permissive-runtime.test.ts @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +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: { + include_workdir: true, + read_only: ["/proc", "/etc"], + read_write: ["/tmp", "/sandbox/.openclaw"], + }, + landlock: { compatibility: "best_effort" }, +}); + +const tempFilesToClean: string[] = []; + +function trackTempForCleanup(out: string, basePath: string): void { + // Defensive: if the helper degrades to the static base path we must + // never try to `rm -rf` its parent dir — that would target the + // user's checkout. Only enqueue paths that the helper actually + // produced via mkdtemp. + if (out === basePath) return; + const tempRoot = path.resolve(os.tmpdir()); + const parent = path.resolve(path.dirname(out)); + const rel = path.relative(tempRoot, parent); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return; + tempFilesToClean.push(out); +} + +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 + } + } +}); + +describe("buildRuntimePermissivePolicy (#3942)", () => { + it("preserves /proc when the live GPU sandbox has it in read_write", () => { + const liveYaml = 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("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE, + }); + trackTempForCleanup(out, "/unused-base.yaml"); + expect(out).not.toBe("/unused-base.yaml"); + + 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("preserves non-list filesystem_policy fields (e.g. include_workdir)", () => { + const liveYaml = YAML.stringify({ + filesystem_policy: { read_write: ["/proc"], read_only: ["/usr"] }, + }); + + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE, + }); + trackTempForCleanup(out, "/unused-base.yaml"); + expect(out).not.toBe("/unused-base.yaml"); + + const result = YAML.parse(fs.readFileSync(out, "utf-8")); + expect(result.filesystem_policy.include_workdir).toBe(true); + }); + + it("merges live read_only paths into base read_only without clobbering rw", () => { + const liveYaml = 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("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE, + }); + trackTempForCleanup(out, "/unused-base.yaml"); + expect(out).not.toBe("/unused-base.yaml"); + + 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 liveYaml = YAML.stringify({ + filesystem_policy: { + read_only: ["/etc", "/etc"], + read_write: ["/tmp", "/tmp", "/proc"], + }, + }); + + const out = buildRuntimePermissivePolicy("/unused-base.yaml", { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE, + }); + trackTempForCleanup(out, "/unused-base.yaml"); + expect(out).not.toBe("/unused-base.yaml"); + + 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); + 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", () => { + const basePath = "/path/to/static.yaml"; + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: "", + 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 liveYaml = YAML.stringify({ landlock: { compatibility: "best_effort" } }); + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE, + }); + expect(out).toBe(basePath); + }); + + it("returns the static base path when readBasePolicy throws (I/O failure)", () => { + const basePath = "/path/to/static.yaml"; + const liveYaml = YAML.stringify({ + filesystem_policy: { read_write: ["/proc"] }, + }); + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => { + throw new Error("ENOENT"); + }, + }); + expect(out).toBe(basePath); + }); + + it("returns the static base path when base YAML is unparseable", () => { + const basePath = "/path/to/static.yaml"; + const liveYaml = YAML.stringify({ + filesystem_policy: { read_write: ["/proc"] }, + }); + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => "::: not yaml :::", + }); + expect(out).toBe(basePath); + }); + + it("returns the static base path when temp-file write throws", () => { + const basePath = "/path/to/static.yaml"; + const liveYaml = YAML.stringify({ + filesystem_policy: { read_write: ["/proc"] }, + }); + let writeAttempts = 0; + const out = buildRuntimePermissivePolicy(basePath, { + livePolicyYaml: liveYaml, + readBasePolicy: () => BASE_PERMISSIVE, + writeTempPolicy: () => { + writeAttempts += 1; + throw new Error("ENOSPC: simulated /tmp full"); + }, + }); + expect(out).toBe(basePath); + expect(writeAttempts).toBe(1); + }); +});