Skip to content
Merged
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
3 changes: 3 additions & 0 deletions scripts/lib/normalize_mutable_config_perms.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,9 @@ def lock_recovery_baseline(
BASELINE_NAME, dir_fd=root_fd, follow_symlinks=False
)
except FileNotFoundError:
# Expected before the first successful post-override capture. There
# is no recovery source to lock yet, and normal startup must remain
# quiet; capture mode creates the baseline through a fresh inode.
before = None

if before is not None:
Expand Down
9 changes: 9 additions & 0 deletions src/lib/actions/sandbox/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ function repairFailureDetail(
* `nemoclaw <sandbox> exec` command boundary. OpenShell executes the requested
* process directly, so the sandbox entrypoint's one-shot cleanup does not run
* on this path. Hermes and custom agents are deliberately left unchanged.
*
* Each production inspect/repair call takes the cross-process, timer-bound
* shields transition lock and rechecks posture while holding it. The repair is
* idempotent, so two CLI processes may interleave only between those protected
* steps: they can repeat a repair or make one caller report conservative drift,
* but host-side repair mutations cannot overlap or weaken shields-up. A
* process-local mutex would not serialize separate CLI invocations, while a
* lock inside the sandbox-owned config tree would put lock authority on the
* wrong trust side.
*/
export function cleanupOpenClawAfterExec(
sandboxName: string,
Expand Down
26 changes: 3 additions & 23 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ const {
inspectMutableConfigPerms: inspectMutableConfigPermsCore,
repairMutableConfigPerms: repairMutableConfigPermsCore,
}: typeof import("./mutable-config-perms") = require("./mutable-config-perms");
const {
normalizeMutableOpenClawConfig,
}: typeof import("./mutable-config-repair") = require("./mutable-config-repair");
type MutableConfigPermsInspection = import("./mutable-config-perms").MutableConfigPermsInspection;
type MutableConfigRepairResult = import("./mutable-config-perms").MutableConfigRepairResult;
type ProcessIdentity = import("./timer-control").ProcessIdentity;
Expand All @@ -98,7 +101,6 @@ const HERMES_RUNTIME_CONFIG_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-conf
const HERMES_PYTHON = "/opt/hermes/.venv/bin/python";
const HERMES_RESTART_SEAL_STATE = "/run/nemoclaw/hermes-restart-seal.json";
const HERMES_CONFIG_HASH = "/etc/nemoclaw/hermes.config-hash";
const MUTABLE_CONFIG_NORMALIZER = "/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py";
const STATE_DIR_GUARD_TIMEOUT_MS = 15 * 60 * 1000;
const OPENCLAW_CONFIG_GUARD_TIMEOUT_MS = 6 * 60 * 1000;
const HERMES_CONFIG_GUARD_TIMEOUT_MS = 11 * 60 * 1000;
Expand Down Expand Up @@ -323,28 +325,6 @@ function privilegedSandboxExecCapture(sandboxName: string, cmd: string[], timeou
}).trim();
}

function sandboxIdentityId(sandboxName: string, flag: "-u" | "-g"): string {
const id = privilegedSandboxExecCapture(sandboxName, ["/usr/bin/id", flag, "sandbox"]);
if (!/^[1-9][0-9]*$/.test(id)) {
const kind = flag === "-u" ? "UID" : "GID";
throw new Error(`sandbox identity lookup returned an invalid ${kind}`);
}
return id;
}

function normalizeMutableOpenClawConfig(sandboxName: string, configDir: string): void {
const sandboxUid = sandboxIdentityId(sandboxName, "-u");
const sandboxGid = sandboxIdentityId(sandboxName, "-g");
privilegedSandboxExec(sandboxName, [
"/usr/bin/python3",
"-I",
MUTABLE_CONFIG_NORMALIZER,
configDir,
sandboxUid,
sandboxGid,
]);
}

function hermesShieldsGuardArgs(
action: string,
target: AgentConfigTarget,
Expand Down
154 changes: 154 additions & 0 deletions src/lib/shields/mutable-config-repair.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { createRequire } from "node:module";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const NORMALIZER = "/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py";
const NORMALIZER_WATCHDOG = ["/usr/bin/timeout", "--signal=TERM", "--kill-after=5s", "15s"];
const requireSource = createRequire(import.meta.url);

type DockerExecModule = typeof import("../adapters/docker/exec");
type MutableConfigRepairModule = typeof import("./mutable-config-repair");
type PrivilegedExecModule = typeof import("../sandbox/privileged-exec");

let dockerExec: DockerExecModule;
let normalizeMutableOpenClawConfig: MutableConfigRepairModule["normalizeMutableOpenClawConfig"];
let privilegedExec: PrivilegedExecModule;

function mockPrivilegedArgv() {
return vi
.spyOn(privilegedExec, "privilegedSandboxExecArgv")
.mockImplementation((_sandboxName, cmd) => ["privileged", ...cmd]);
}

describe("mutable OpenClaw config repair", () => {
beforeEach(() => {
delete require.cache[requireSource.resolve("./mutable-config-repair.js")];
dockerExec = requireSource("../adapters/docker/exec.js");
privilegedExec = requireSource("../sandbox/privileged-exec.js");
({ normalizeMutableOpenClawConfig } = requireSource("./mutable-config-repair.js"));
});

afterEach(() => {
vi.restoreAllMocks();
delete require.cache[requireSource.resolve("./mutable-config-repair.js")];
});

it("sanitizes identity probes and watchdogs the privileged normalizer", () => {
const privilegedArgv = mockPrivilegedArgv();
const dockerExecFileSync = vi
.spyOn(dockerExec, "dockerExecFileSync")
.mockReturnValueOnce("1000\n")
.mockReturnValueOnce("1001\n")
.mockReturnValue("");

normalizeMutableOpenClawConfig("alpha", "/sandbox/.openclaw");

expect(privilegedArgv.mock.calls).toEqual([
["alpha", ["/usr/bin/id", "-u", "sandbox"], false, true],
["alpha", ["/usr/bin/id", "-g", "sandbox"], false, true],
[
"alpha",
[
...NORMALIZER_WATCHDOG,
"/usr/bin/python3",
"-I",
NORMALIZER,
"/sandbox/.openclaw",
"1000",
"1001",
],
false,
true,
],
]);
expect(dockerExecFileSync).toHaveBeenCalledTimes(3);
expect(dockerExecFileSync.mock.calls.map(([argv]) => argv)).toEqual([
["privileged", "/usr/bin/id", "-u", "sandbox"],
["privileged", "/usr/bin/id", "-g", "sandbox"],
[
"privileged",
...NORMALIZER_WATCHDOG,
"/usr/bin/python3",
"-I",
NORMALIZER,
"/sandbox/.openclaw",
"1000",
"1001",
],
]);
expect(dockerExecFileSync.mock.calls.map(([, options]) => options)).toEqual([
{ stdio: ["ignore", "pipe", "pipe"], timeout: 15000 },
{ stdio: ["ignore", "pipe", "pipe"], timeout: 15000 },
{ stdio: ["ignore", "pipe", "pipe"], timeout: 25000 },
]);
});

it("rejects an invalid sandbox UID before the GID or normalizer runs", () => {
const privilegedArgv = mockPrivilegedArgv();
const dockerExecFileSync = vi.spyOn(dockerExec, "dockerExecFileSync").mockReturnValue("0\n");

expect(() => normalizeMutableOpenClawConfig("alpha", "/sandbox/.openclaw")).toThrow(
"sandbox identity lookup returned an invalid UID",
);
expect(privilegedArgv).toHaveBeenCalledOnce();
expect(privilegedArgv).toHaveBeenCalledWith(
"alpha",
["/usr/bin/id", "-u", "sandbox"],
false,
true,
);
expect(dockerExecFileSync).toHaveBeenCalledOnce();
});

it("rejects an invalid sandbox GID before the normalizer runs", () => {
const privilegedArgv = mockPrivilegedArgv();
const dockerExecFileSync = vi
.spyOn(dockerExec, "dockerExecFileSync")
.mockReturnValueOnce("1000\n")
.mockReturnValueOnce("not-a-gid\n");

expect(() => normalizeMutableOpenClawConfig("alpha", "/sandbox/.openclaw")).toThrow(
"sandbox identity lookup returned an invalid GID",
);
expect(privilegedArgv).toHaveBeenCalledTimes(2);
expect(privilegedArgv).not.toHaveBeenCalledWith(
"alpha",
expect.arrayContaining([NORMALIZER]),
false,
true,
);
expect(dockerExecFileSync).toHaveBeenCalledTimes(2);
});

it("propagates a trusted normalizer execution failure", () => {
const privilegedArgv = mockPrivilegedArgv();
const failure = new Error("docker exec failed");
const dockerExecFileSync = vi
.spyOn(dockerExec, "dockerExecFileSync")
.mockReturnValueOnce("1000\n")
.mockReturnValueOnce("1001\n")
.mockImplementationOnce(() => {
throw failure;
});

expect(() => normalizeMutableOpenClawConfig("alpha", "/sandbox/.openclaw")).toThrow(failure);
expect(privilegedArgv).toHaveBeenLastCalledWith(
"alpha",
[
...NORMALIZER_WATCHDOG,
"/usr/bin/python3",
"-I",
NORMALIZER,
"/sandbox/.openclaw",
"1000",
"1001",
],
false,
true,
);
expect(dockerExecFileSync).toHaveBeenCalledTimes(3);
});
});
69 changes: 69 additions & 0 deletions src/lib/shields/mutable-config-repair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const dockerExec: typeof import("../adapters/docker/exec") = require("../adapters/docker/exec");
const privilegedExecModule: typeof import("../sandbox/privileged-exec") = require("../sandbox/privileged-exec");

const MUTABLE_CONFIG_NORMALIZER = "/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py";
const MUTABLE_CONFIG_NORMALIZER_HOST_TIMEOUT_MS = 25000;
const MUTABLE_CONFIG_NORMALIZER_WATCHDOG = [
"/usr/bin/timeout",
"--signal=TERM",
"--kill-after=5s",
"15s",
] as const;

function runPrivileged(sandboxName: string, cmd: string[], timeout = 15000): void {
dockerExec.dockerExecFileSync(
privilegedExecModule.privilegedSandboxExecArgv(sandboxName, cmd, false, true),
{
stdio: ["ignore", "pipe", "pipe"],
timeout,
},
);
}

function privilegedExecCapture(sandboxName: string, cmd: string[], timeout = 15000): string {
return dockerExec
.dockerExecFileSync(
privilegedExecModule.privilegedSandboxExecArgv(sandboxName, cmd, false, true),
{
stdio: ["ignore", "pipe", "pipe"],
timeout,
},
)
.trim();
}

function sandboxIdentityId(sandboxName: string, flag: "-u" | "-g"): string {
const id = privilegedExecCapture(sandboxName, ["/usr/bin/id", flag, "sandbox"]);
// Keep the ownership target non-root so privileged repair cannot become a
// confused-deputy path.
if (!/^[1-9][0-9]*$/.test(id)) {
const kind = flag === "-u" ? "UID" : "GID";
throw new Error(`sandbox identity lookup returned an invalid ${kind}`);
}
return id;
}

/** Apply the mutable OpenClaw contract through the image's trusted helper. */
export function normalizeMutableOpenClawConfig(sandboxName: string, configDir: string): void {
const sandboxUid = sandboxIdentityId(sandboxName, "-u");
const sandboxGid = sandboxIdentityId(sandboxName, "-g");
// The in-sandbox watchdog signals the Python process group and reaps its
// direct child before the longer host-side Docker timeout can release the
// shields transition lock.
runPrivileged(
sandboxName,
[
...MUTABLE_CONFIG_NORMALIZER_WATCHDOG,
"/usr/bin/python3",
"-I",
MUTABLE_CONFIG_NORMALIZER,
configDir,
sandboxUid,
sandboxGid,
],
MUTABLE_CONFIG_NORMALIZER_HOST_TIMEOUT_MS,
);
}
6 changes: 5 additions & 1 deletion src/lib/shields/openclaw-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe("OpenClaw shields top-config transaction", () => {
switch (argv[0]) {
case "/usr/bin/id":
return "1000\n";
case "/usr/bin/python3":
case "/usr/bin/timeout":
return "";
default:
throw new Error(`unexpected privileged command: ${argv.join(" ")}`);
Expand All @@ -156,6 +156,10 @@ describe("OpenClaw shields top-config transaction", () => {
["/usr/bin/id", "-u", "sandbox"],
["/usr/bin/id", "-g", "sandbox"],
[
"/usr/bin/timeout",
"--signal=TERM",
"--kill-after=5s",
"15s",
"/usr/bin/python3",
"-I",
"/usr/local/lib/nemoclaw/normalize_mutable_config_perms.py",
Expand Down
Loading