Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
35 changes: 28 additions & 7 deletions src/lib/shields/permissive-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ const TEMP_FILE_PREFIX = "nemoclaw-permissive-runtime";
* the bug class this helper exists for is path removal on a live sandbox,
* not policy shape changes.
*
* The live `landlock` stanza is carried through as well when the live policy
* has one (#8461). OpenShell applies Landlock at startup and rejects any
* later policy that changes it, so the emitted document must restate the
* value the sandbox is already running rather than the static base's.
*
* 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`
Expand All @@ -62,9 +67,9 @@ const TEMP_FILE_PREFIX = "nemoclaw-permissive-runtime";
* 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
* policy carries filesystem paths or a Landlock stanza that must be
* preserved. Falls back to the static base path when the live policy has
* neither, 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.
*/
Expand Down Expand Up @@ -97,10 +102,15 @@ export function buildRuntimePermissivePolicy(
const liveRo = readStringList(live, "read_only");
const managedMcpPolicies = deps.managedMcpPolicies ?? [];

// No live filesystem section to merge — keep the static path so the
// caller's apply path is unchanged unless exact managed MCP entries must
// survive the complete-policy replacement.
if (liveRw.length === 0 && liveRo.length === 0 && managedMcpPolicies.length === 0) {
// No live startup-sealed or filesystem state to carry forward — keep the
// static path so the caller's apply path is unchanged unless exact managed
// MCP entries must survive the complete-policy replacement.
if (
liveRw.length === 0 &&
liveRo.length === 0 &&
live?.landlock === undefined &&
managedMcpPolicies.length === 0
) {
return basePermissivePath;
}

Expand Down Expand Up @@ -144,6 +154,17 @@ export function buildRuntimePermissivePolicy(
fsPolicy.read_write = [...baseRw];
fsPolicy.read_only = [...baseRo];

// OpenShell applies Landlock at sandbox startup and rejects a policy whose
// stanza differs from the one the sandbox started with. An agent that ships
// no permissive policy of its own falls back to the OpenClaw document. Its
// `best_effort` then contradicts a baseline such as Deep Agents Code's
// `strict`, and OpenShell refuses the policy (#8461). Carry the live stanza
// through so the emitted document proposes no Landlock change. This can only
// ever restate what the sandbox is already running.
if (live?.landlock !== undefined) {
base.landlock = live.landlock;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const yaml = composeManagedMcpPolicies(YAML.stringify(base), managedMcpPolicies);
if (deps.writeTempPolicy) {
try {
Expand Down
61 changes: 57 additions & 4 deletions test/permissive-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,67 @@ describe("buildRuntimePermissivePolicy (#3942)", () => {
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" } });
it("carries the live landlock stanza so a startup-sealed field is not changed (#8461)", () => {
// Deep Agents Code starts with `strict` but ships no permissive policy of
// its own, so the base is the OpenClaw document with `best_effort`.
const liveYaml = YAML.stringify({
filesystem_policy: { read_only: ["/etc"], read_write: ["/tmp"] },
landlock: { compatibility: "strict" },
});

const out = buildRuntimePermissivePolicy("/unused-base.yaml", {
livePolicyYaml: liveYaml,
readBasePolicy: () => BASE_PERMISSIVE,
});
trackTempForCleanup(out, "/unused-base.yaml");

const result = YAML.parse(fs.readFileSync(out, "utf-8"));
expect(result.landlock).toEqual({ compatibility: "strict" });
});

it("carries a live landlock stanza that already equals the base (#8461)", () => {
const liveYaml = YAML.stringify({
filesystem_policy: { read_only: ["/etc"], read_write: ["/tmp"] },
landlock: { compatibility: "best_effort" },
});

const out = buildRuntimePermissivePolicy("/unused-base.yaml", {
livePolicyYaml: liveYaml,
readBasePolicy: () => BASE_PERMISSIVE,
});
trackTempForCleanup(out, "/unused-base.yaml");

const result = YAML.parse(fs.readFileSync(out, "utf-8"));
expect(result.landlock).toEqual({ compatibility: "best_effort" });
});

it("keeps the base landlock stanza when the live policy carries none (#8461)", () => {
const liveYaml = YAML.stringify({
filesystem_policy: { read_only: ["/etc"], read_write: ["/tmp"] },
});

const out = buildRuntimePermissivePolicy("/unused-base.yaml", {
livePolicyYaml: liveYaml,
readBasePolicy: () => BASE_PERMISSIVE,
});
trackTempForCleanup(out, "/unused-base.yaml");

const result = YAML.parse(fs.readFileSync(out, "utf-8"));
expect(result.landlock).toEqual({ compatibility: "best_effort" });
});

it("carries Landlock when the live policy has no filesystem paths (#8461)", () => {
const basePath = "/unused-base.yaml";
const liveYaml = YAML.stringify({ landlock: { compatibility: "strict" } });
const out = buildRuntimePermissivePolicy(basePath, {
livePolicyYaml: liveYaml,
readBasePolicy: () => BASE_PERMISSIVE,
});
expect(out).toBe(basePath);
trackTempForCleanup(out, basePath);

expect(out).not.toBe(basePath);
const result = YAML.parse(fs.readFileSync(out, "utf-8"));
expect(result.landlock).toEqual({ compatibility: "strict" });
});

it("returns the static base path when readBasePolicy throws (I/O failure)", () => {
Expand Down
Loading