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
61 changes: 53 additions & 8 deletions agents/hermes/runtime-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
SHIELDS_TRANSITION_LEASE_SECONDS = 300
STATE_WORKER_LEASE_SECONDS = 15 * 60
HERMES_STARTUP_READY_FILE = "/run/nemoclaw/hermes-startup-ready"
HERMES_ROOT_LIFECYCLE_MARKER = "/run/nemoclaw/hermes-root-lifecycle"
ROOT_LIFECYCLE_FINISH_CAPABILITY = "root-lifecycle-finish-v1"
NEMOCLAW_RUNTIME_DIR = "/run/nemoclaw"
NEMOCLAW_RUNTIME_DIR_MODE = 0o711
HERMES_RESTART_STATE_FILE = "/run/nemoclaw/hermes-restart-seal.json"
Expand Down Expand Up @@ -3542,15 +3544,20 @@ def begin_shields_transition(
finally:
os.close(hermes_fd)

state_data["phase"] = "shields-transition-pending"
state_data["shields_transition"] = {
transition: dict[str, object] = {
"mode": mode,
"original_locked": original_locked,
"rollback_mode": rollback_mode
or ("locked" if original_locked else "mutable"),
"lease_expires_ns": time.time_ns()
+ SHIELDS_TRANSITION_LEASE_SECONDS * 1_000_000_000,
}
if mode == "mutable":
transition["root_lifecycle_topology"] = (
_inspect_hermes_root_lifecycle_topology()
)
state_data["phase"] = "shields-transition-pending"
state_data["shields_transition"] = transition
_write_restart_state(state_file, state_data, create=False)
return lock_token, original_locked
except Exception:
Expand Down Expand Up @@ -3986,6 +3993,33 @@ def _freeze_shields_directories(state_data: dict[str, object], hermes_dir: str)
os.close(parent_fd)


def _inspect_hermes_root_lifecycle_topology() -> str:
try:
os.lstat(HERMES_ROOT_LIFECYCLE_MARKER)
except FileNotFoundError:
return "managed-nonroot"
except OSError as exc:
raise UnsafePathError(
"refusing Hermes root lifecycle topology probe failure"
) from exc
return "root-separated"


def _private_mutable_hermes_root_allowed(
transition: dict[str, object],
mode: str,
recorded_mode: object,
observed_mode: int,
) -> bool:
return (
mode == "mutable"
and recorded_mode == 0o3770
and observed_mode == 0o700
and transition.get("root_lifecycle_topology") == "managed-nonroot"
and _inspect_hermes_root_lifecycle_topology() == "managed-nonroot"
)


def finish_shields_transition(
hermes_dir: str, hash_file: str, state_file: str, lock_token: str
) -> tuple[str, bool]:
Expand Down Expand Up @@ -4023,11 +4057,20 @@ def finish_shields_transition(
hermes_st = os.fstat(hermes_fd)
if not _same_inode(hermes_st, hermes_meta):
raise UnsafePathError("refusing shields finish because .hermes changed")
if (
hermes_st.st_uid != hermes_meta.get("uid")
or hermes_st.st_gid != hermes_meta.get("gid")
or stat.S_IMODE(hermes_st.st_mode) != hermes_meta.get("mode")
):
observed_mode = stat.S_IMODE(hermes_st.st_mode)
owner_matches = (
hermes_st.st_uid == hermes_meta.get("uid")
and hermes_st.st_gid == hermes_meta.get("gid")
)
mode_matches = observed_mode == hermes_meta.get("mode")
if owner_matches and not mode_matches:
mode_matches = _private_mutable_hermes_root_allowed(
transition,
mode,
hermes_meta.get("mode"),
observed_mode,
)
if not owner_matches or not mode_matches:
raise UnsafePathError(
"refusing shields finish because .hermes metadata drifted"
)
Expand Down Expand Up @@ -4723,7 +4766,9 @@ def provider_placeholders(


def main() -> int:
parser = argparse.ArgumentParser()
parser = argparse.ArgumentParser(
epilog=f"capabilities: {ROOT_LIFECYCLE_FINISH_CAPABILITY}"
)
parser.add_argument(
"action",
choices=(
Expand Down
2 changes: 1 addition & 1 deletion docs/manage-sandboxes/recover-rebuild-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ When rebuild starts with shields up, NemoClaw opens a 30-minute shields-down win
A detached auto-lock timer remains the recovery authority until NemoClaw commits a successful shields-up state, including when the host rebuild process exits unexpectedly.

<AgentOnly variant="hermes">
For an older Hermes image that predates sealed shields transitions, only the rebuild workflow may use the descriptor-safe compatibility transition needed to archive and replace the sandbox.
For an older Hermes image that predates sealed shields transitions or lacks the current sealed-shields finish capability, only the rebuild workflow may use the descriptor-safe compatibility transition needed to archive and replace the sandbox.
That transition verifies the strict and compatibility hashes and publishes fresh config inodes before changing their lock posture, while ordinary `shields up` and `shields down` commands continue to refuse the older protocol.
</AgentOnly>

Expand Down
35 changes: 34 additions & 1 deletion src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
const HERMES_RUNTIME_CONFIG_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py";
const HERMES_PYTHON = "/opt/hermes/.venv/bin/python";
const HERMES_RESTART_SEAL_STATE = "/run/nemoclaw/hermes-restart-seal.json";
const HERMES_ROOT_LIFECYCLE_MARKER = "/run/nemoclaw/hermes-root-lifecycle";
const HERMES_CONFIG_HASH = "/etc/nemoclaw/hermes.config-hash";
const STATE_DIR_GUARD_TIMEOUT_MS = 15 * 60 * 1000;
const OPENCLAW_CONFIG_GUARD_TIMEOUT_MS = 6 * 60 * 1000;
Expand Down Expand Up @@ -423,7 +424,7 @@
console.error(
` If the retry still fails, rebuild a known-good baseline with \`${CLI_NAME} ${sandboxName} rebuild --yes\`.`,
);
}

Check failure on line 427 in src/lib/shields/index.ts

View workflow job for this annotation

GitHub Actions / cli-test-shards (1)

[integration] test/repro-2681-group-writable.test.ts > mutable agent config permissions > verifies the frozen Hermes tree before publishing and checking the locked parent

Error: Hermes runtime guard exposes an incomplete shields transition contract; rebuild the sandbox ❯ inspectHermesShieldsProtocol src/lib/shields/index.ts:427:11 ❯ requireHermesShieldsProtocol src/lib/shields/index.ts:430:22 ❯ resolveHermesShieldsProtocol src/lib/shields/index.ts:437:40 ❯ lockAgentConfigWithoutHostLock src/lib/shields/index.ts:1860:22 ❯ src/lib/shields/index.ts:1867:16 ❯ ShieldsTransitionLockManager.withShieldsTransitionLock src/lib/shields/transition-lock.ts:282:39 ❯ withShieldsTransitionLock src/lib/shields/transition-lock.ts:935:27 ❯ lockAgentConfig src/lib/shields/index.ts:1865:12 ❯ withMockedDockerExecFileSync.hermesLockedTransaction test/repro-2681-group-writable.test.ts:611:9

Check failure on line 427 in src/lib/shields/index.ts

View workflow job for this annotation

GitHub Actions / cli-test-shards (1)

[integration] test/repro-2681-group-writable.test.ts > mutable agent config permissions > shields-down restores Hermes sticky group-writable config root without group-writable config files

Error: Hermes runtime guard exposes an incomplete shields transition contract; rebuild the sandbox ❯ inspectHermesShieldsProtocol src/lib/shields/index.ts:427:11 ❯ requireHermesShieldsProtocol src/lib/shields/index.ts:430:22 ❯ resolveHermesShieldsProtocol src/lib/shields/index.ts:437:40 ❯ unlockAgentConfigWithoutHostLock src/lib/shields/index.ts:1527:22 ❯ src/lib/shields/index.ts:1533:16 ❯ ShieldsTransitionLockManager.withShieldsTransitionLock src/lib/shields/transition-lock.ts:282:39 ❯ withShieldsTransitionLock src/lib/shields/transition-lock.ts:935:27 ❯ unlockAgentConfig src/lib/shields/index.ts:1531:12 ❯ test/repro-2681-group-writable.test.ts:559:7

// The guard also uses startup-not-ready for structural PID 1 incompatibility.
// Match the complete transient diagnostic so a different detail or an
Expand Down Expand Up @@ -465,6 +466,22 @@
}).trim();
}

function inspectHermesRootLifecycleTopology(
sandboxName: string,
): "managed-nonroot" | "root-separated" {
const topology = privilegedSandboxExecCapture(sandboxName, [
"sh",
"-c",
'if [ ! -e "$1" ] && [ ! -L "$1" ]; then printf managed-nonroot; else printf root-separated; fi',
"sh",
HERMES_ROOT_LIFECYCLE_MARKER,
]);
if (topology !== "managed-nonroot" && topology !== "root-separated") {
throw new Error(`Unexpected Hermes workload topology response: ${topology}`);
}
return topology;
}

function hermesShieldsGuardArgs(
action: string,
target: AgentConfigTarget,
Expand Down Expand Up @@ -498,6 +515,7 @@
"prepare-shields-abort",
"abort-shields-transition",
"--rollback-shields-mode",
"root-lifecycle-finish-v1",
] as const;
const HERMES_LEGACY_GUARD_CONTRACT = [
"ensure-api-key",
Expand Down Expand Up @@ -1728,7 +1746,22 @@
target.configDir,
]);
const [mode, owner] = dirPerms.split(" ");
if (mode !== dirMode) issues.push(`config dir mode=${mode} (expected ${dirMode})`);
// A sealed transaction has already attested a live Hermes topology. The
// managed same-UID topology can tolerate the dashboard tightening its
// mutable home to 0700; the root-separated gateway still needs 03770.
const privateHermesRootAllowed =
target.agentName === "hermes" &&
mode === "700" &&
transaction !== null &&
inspectHermesRootLifecycleTopology(sandboxName) === "managed-nonroot";
const validDirMode = mode === dirMode || privateHermesRootAllowed;
if (!validDirMode) {
const expectedDirModes =
target.agentName === "hermes" && transaction !== null
? `${dirMode}, or 700 in the managed non-root topology`
: dirMode;
issues.push(`config dir mode=${mode} (expected ${expectedDirModes})`);
}
if (owner !== "sandbox:sandbox") {
issues.push(`config dir owner=${owner} (expected sandbox:sandbox)`);
}
Expand Down
148 changes: 145 additions & 3 deletions src/lib/shields/legacy-hermes-compat.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { execFileSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
Expand All @@ -12,20 +13,24 @@ const requireSource = createRequire(import.meta.url);
const INDEX_MODULE = "./index.js";
const HERMES_PYTHON = "/opt/hermes/.venv/bin/python";
const HERMES_GUARD = "/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py";
const HERMES_ROOT_LIFECYCLE_MARKER = "/run/nemoclaw/hermes-root-lifecycle";
const LOCK_TOKEN = "a".repeat(64);
const OLD_GUARD_HELP = "usage: guard {ensure-api-key,refresh-hashes,provider-placeholders}";
const PARTIAL_GUARD_HELP = "begin-shields-transition --rollback-shields-mode";
const CURRENT_GUARD_HELP = [
const PRE_PRIVATE_ROOT_GUARD_HELP = [
"begin-shields-transition",
"run-state-dir-transition",
"apply-shields-transition",
"finish-shields-transition",
"prepare-shields-abort",
"abort-shields-transition",
"--rollback-shields-mode",
OLD_GUARD_HELP,
].join(" ");
const CURRENT_GUARD_HELP = `${PRE_PRIVATE_ROOT_GUARD_HELP} root-lifecycle-finish-v1`;

type ShieldsModule = typeof import("./index");
type HermesRootLifecycleMarkerState = "missing" | "regular" | "dangling-symlink";

function hermesTarget() {
return {
Expand Down Expand Up @@ -128,7 +133,24 @@ describe("legacy Hermes shields compatibility", () => {
fs.rmSync(homeDir, { recursive: true, force: true });
});

function installExecResponses(help: string): void {
function installExecResponses(
help: string,
hermesDirMode = "3770",
markerState: HermesRootLifecycleMarkerState = "missing",
topologyResponseOverride?: string,
topologyProbeError?: Error,
): void {
const localMarker = path.join(homeDir, "hermes-root-lifecycle");
switch (markerState) {
case "missing":
break;
case "regular":
fs.writeFileSync(localMarker, "root-separated\n");
break;
case "dangling-symlink":
fs.symlinkSync(`${localMarker}.missing`, localMarker);
break;
}
dockerExecSpy.mockImplementation((cmd: string[]) => {
switch (true) {
case cmd.includes(HERMES_GUARD) && cmd.includes("--help"):
Expand All @@ -138,9 +160,26 @@ describe("legacy Hermes shields compatibility", () => {
case isGuardAction(cmd, "apply-shields-transition"):
return "shields_mode=mutable chattr_applied=0";
case cmd[0] === "stat":
return cmd.at(-1) === "/sandbox/.hermes" ? "3770 sandbox:sandbox" : "640 sandbox:sandbox";
return cmd.at(-1) === "/sandbox/.hermes"
? `${hermesDirMode} sandbox:sandbox`
: "640 sandbox:sandbox";
case cmd[0] === "lsattr":
return `---------------- ${cmd.at(-1)}`;
case cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER): {
switch (topologyProbeError) {
case undefined:
break;
default:
throw topologyProbeError;
}
const localProbe = cmd.map((arg) =>
arg === HERMES_ROOT_LIFECYCLE_MARKER ? localMarker : arg,
);
return (
topologyResponseOverride ??
execFileSync(localProbe[0], localProbe.slice(1), { encoding: "utf8" })
);
}
default:
return "";
}
Expand Down Expand Up @@ -210,6 +249,21 @@ describe("legacy Hermes shields compatibility", () => {
expect(commands.some((cmd) => isGuardAction(cmd, "begin-shields-transition"))).toBe(false);
});

it("rejects a pre-capability sealed guard before policy or state mutation", () => {
installExecResponses(PRE_PRIVATE_ROOT_GUARD_HELP);

expect(() =>
shields.shieldsDown("pre-private-root-hermes", {
skipTimer: true,
throwOnError: true,
}),
).toThrow(/predates sealed shields transitions|incomplete|rebuild/i);

expect(runSpy).not.toHaveBeenCalled();
const commands = dockerExecSpy.mock.calls.map(commandFromCall);
expect(commands.some((cmd) => isGuardAction(cmd, "begin-shields-transition"))).toBe(false);
});

it("permits only an explicitly authorized legacy path to use the descriptor-safe top-level unlock", () => {
installExecResponses(OLD_GUARD_HELP);

Expand Down Expand Up @@ -264,6 +318,94 @@ describe("legacy Hermes shields compatibility", () => {
expect(commands.some(isInlinePython)).toBe(false);
});

it("accepts the dashboard-tightened private Hermes root after a sealed unlock", () => {
installExecResponses(CURRENT_GUARD_HELP, "700");

expect(() =>
shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true),
).not.toThrow();

const commands = dockerExecSpy.mock.calls.map(commandFromCall);
expect(commands.some((cmd) => cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER))).toBe(true);
expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(true);
});

it("rejects a private Hermes root in the root-separated topology", () => {
installExecResponses(CURRENT_GUARD_HELP, "700", "regular");

expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow(
/managed non-root topology/,
);

const commands = dockerExecSpy.mock.calls.map(commandFromCall);
expect(commands.some((cmd) => cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER))).toBe(true);
expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false);
});

it("rejects a dangling lifecycle marker as root-separated", () => {
installExecResponses(CURRENT_GUARD_HELP, "700", "dangling-symlink");

expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow(
/managed non-root topology/,
);

const commands = dockerExecSpy.mock.calls.map(commandFromCall);
expect(commands.some((cmd) => cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER))).toBe(true);
expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false);
});

it("rejects an unexpected Hermes topology response before finishing", () => {
installExecResponses(CURRENT_GUARD_HELP, "700", "missing", "unexpected-topology");

expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow(
/Unexpected Hermes workload topology response/,
);

const commands = dockerExecSpy.mock.calls.map(commandFromCall);
expect(commands.some((cmd) => cmd.includes(HERMES_ROOT_LIFECYCLE_MARKER))).toBe(true);
expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false);
});

it("aborts the sealed transaction when the Hermes topology probe fails", () => {
installExecResponses(
CURRENT_GUARD_HELP,
"700",
"missing",
undefined,
new Error("topology probe unavailable"),
);

expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow(
/topology probe unavailable/,
);

const commands = dockerExecSpy.mock.calls.map(commandFromCall);
const guardActions = commands.flatMap((cmd) => {
const guardIndex = cmd.indexOf(HERMES_GUARD);
return guardIndex >= 0 && cmd[guardIndex + 1] !== "--help" ? [cmd[guardIndex + 1]] : [];
});
expect(guardActions).toEqual([
"begin-shields-transition",
"run-state-dir-transition",
"apply-shields-transition",
"prepare-shields-abort",
"run-state-dir-transition",
"abort-shields-transition",
]);
expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false);
});

it("rejects other sandbox-owned Hermes root modes before finishing a sealed unlock", () => {
installExecResponses(CURRENT_GUARD_HELP, "750");

expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow(
/config dir mode/,
);

const commands = dockerExecSpy.mock.calls.map(commandFromCall);
expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(false);
});

it("isolates Hermes guard Python and scrubs every privileged shields exec", () => {
installExecResponses(CURRENT_GUARD_HELP);

Expand Down
4 changes: 3 additions & 1 deletion test/e2e/live/hermes-shields-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ async function expectMutablePosture(sandbox: SandboxClient, cycle: number): Prom
);
assertExitZero(result, `inspect Hermes mutable posture after cycle ${cycle}`);
expect(result.stdout).toContain("755 sandbox:sandbox /sandbox");
expect(result.stdout).toContain(`3770 sandbox:sandbox ${HERMES_DIR}`);
expect(result.stdout).toMatch(
new RegExp(`^(?:700|3770) sandbox:sandbox ${HERMES_DIR.replace(".", "\\.")}$`, "m"),
);
expect(result.stdout).toContain(`640 sandbox:sandbox ${CONFIG_PATH}`);
expect(result.stdout).toContain(`640 sandbox:sandbox ${HERMES_DIR}/.env`);
expect(result.stdout).toContain(`640 sandbox:sandbox ${HERMES_DIR}/.config-hash`);
Expand Down
Loading
Loading