Skip to content
Merged
191 changes: 190 additions & 1 deletion agents/hermes/runtime-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
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"
NEMOCLAW_RUNTIME_DIR = "/run/nemoclaw"
NEMOCLAW_RUNTIME_DIR_MODE = 0o711
HERMES_RESTART_STATE_FILE = "/run/nemoclaw/hermes-restart-seal.json"
Expand All @@ -67,6 +68,7 @@
{errno.EINVAL, errno.ENOTSUP, errno.EOPNOTSUPP}
)
_DIRECTORY_FSYNC_WARNING_EMITTED = False
_DIRECTORY_METADATA_FSYNC_WARNING_EMITTED = False
INSTALLED_RUNTIME_CONFIG_GUARD = (
"/usr/local/lib/nemoclaw/hermes-runtime-config-guard.py"
)
Expand Down Expand Up @@ -739,6 +741,65 @@
return False


def _root_lifecycle_marker_state() -> str:
try:
opened = _open_regular(HERMES_ROOT_LIFECYCLE_MARKER)
except FileNotFoundError:
return "absent"
except (OSError, UnsafePathError) as exc:
raise UnsafePathError("Hermes root lifecycle marker is unsafe") from exc
try:
marker = opened.snapshot
if (
marker.uid != 0
or marker.gid != 0
or marker.mode != 0o444
or marker.nlink != 1
or not secrets.compare_digest(opened.read_bytes(64), b"root-separated\n")
):
raise UnsafePathError("Hermes root lifecycle marker is unsafe")
finally:
opened.close()
return "root-separated"


def _attested_shields_runtime_topology() -> str:
marker_state = _root_lifecycle_marker_state()
if marker_state == "root-separated":
if (
os.geteuid() == 0
and _pid1_is_nemoclaw_start()
and _process_effective_uid(1) == 0
):
return marker_state
raise UnsafePathError(
"Hermes root lifecycle marker does not match the live PID 1 topology"
)

if (
os.path.abspath(__file__) != INSTALLED_RUNTIME_CONFIG_GUARD
or os.geteuid() != 0
or not _startup_ready_marker_absent()
):
return "unknown"
try:
sandbox_uid = pwd.getpwnam("sandbox").pw_uid
except KeyError:
return "unknown"
if sandbox_uid <= 0:
return "unknown"
if _openshell_supervised_nonroot_start_is_live(0, sandbox_uid):
if (
_root_lifecycle_marker_state() != marker_state
or not _startup_ready_marker_absent()
):
raise UnsafePathError(
"Hermes runtime topology changed during attestation"
)
return "same-uid-nonroot"
return "unknown"
Comment thread
apurvvkumaria marked this conversation as resolved.


def _validate_action_readiness(action: str, startup_owner: bool) -> None:
installed_current = os.path.abspath(__file__) == INSTALLED_RUNTIME_CONFIG_GUARD
try:
Expand Down Expand Up @@ -1065,6 +1126,22 @@
_DIRECTORY_FSYNC_WARNING_EMITTED = True


def _fsync_directory_metadata(dir_fd: int) -> None:
global _DIRECTORY_METADATA_FSYNC_WARNING_EMITTED

try:
os.fsync(dir_fd)
except OSError as exc:
if exc.errno not in DIRECTORY_FSYNC_UNSUPPORTED_ERRNOS:
raise
if not _DIRECTORY_METADATA_FSYNC_WARNING_EMITTED:
print(
"[security] directory fsync is unsupported; the Hermes root metadata update completed without a directory durability barrier",
file=sys.stderr,
)
_DIRECTORY_METADATA_FSYNC_WARNING_EMITTED = True
Comment thread
apurvvkumaria marked this conversation as resolved.
Comment thread
apurvvkumaria marked this conversation as resolved.


def _atomic_replace_preserving_flags(
path: str, data: bytes, expected: FileSnapshot
) -> None:
Expand Down Expand Up @@ -3986,6 +4063,109 @@
os.close(parent_fd)


def _reconcile_private_mutable_shields_root(
hermes_fd: int,
hermes_st: os.stat_result,
hermes_meta: dict[str, object],
mode: str,
) -> tuple[os.stat_result, str]:
if (
mode != "mutable"
or stat.S_IMODE(hermes_st.st_mode) != 0o700
or hermes_st.st_uid != hermes_meta.get("uid")
or hermes_st.st_gid != hermes_meta.get("gid")
or hermes_meta.get("mode") != 0o3770
):
return hermes_st, "exact"

topology = _attested_shields_runtime_topology()
if topology == "same-uid-nonroot":
# The pinned OpenShell supervisor/entrypoint proof establishes that the
# non-root entrypoint and every child it can launch share the sandbox
# uid. A private sandbox-owned root remains traversable to that gateway.
confirmed = os.fstat(hermes_fd)
if (
not _same_inode(confirmed, hermes_meta)
or confirmed.st_uid != hermes_meta.get("uid")
or confirmed.st_gid != hermes_meta.get("gid")
or stat.S_IMODE(confirmed.st_mode) != 0o700
):
raise UnsafePathError(
"refusing shields finish because the private same-UID Hermes root drifted during attestation"
)
return confirmed, "same-uid-nonroot"
if topology == "root-separated":
# The root entrypoint launches Hermes as the dedicated gateway uid in
# the sandbox group. Restore the descriptor-pinned set-id/sticky root
# before committing the transaction so that gateway can traverse it.
os.fchmod(hermes_fd, 0o3770)
repaired = os.fstat(hermes_fd)
if (
not _same_inode(repaired, hermes_meta)
or repaired.st_uid != hermes_meta.get("uid")
or repaired.st_gid != hermes_meta.get("gid")
or stat.S_IMODE(repaired.st_mode) != 0o3770
):
raise UnsafePathError(
"refusing shields finish because the root-separated Hermes root could not be restored"
)
return repaired, "root-separated"
raise UnsafePathError(
"refusing shields finish because private mutable .hermes lacks an attested same-UID topology"
)


def _enforce_final_shields_root_posture(
hermes_fd: int,
hermes_meta: dict[str, object],
mode: str,
posture: str,
) -> os.stat_result:
expected_mode = hermes_meta.get("mode")
if posture == "root-separated":
if mode != "mutable" or expected_mode != 0o3770:
raise UnsafePathError(
"refusing shields finish because the root-separated Hermes posture is inconsistent"
)
# The sandbox owner can chmod its root after the initial topology check.
# Repair the pinned descriptor again at the commit boundary, make the
# metadata update durable where directory fsync is supported, and only
# trust the fresh stat collected after both operations.
os.fchmod(hermes_fd, 0o3770)
_fsync_directory_metadata(hermes_fd)
allowed_modes = (0o3770,)
elif posture == "same-uid-nonroot":
if mode != "mutable" or expected_mode != 0o3770:
raise UnsafePathError(
"refusing shields finish because the same-UID Hermes posture is inconsistent"
)
# Both modes are intentional for a same-UID mutable runtime: 03770 is
# the canonical posture, while 0700 remains traversable by the gateway.
allowed_modes = (0o700, 0o3770)
elif posture == "exact":
if not isinstance(expected_mode, int):
raise UnsafePathError(
"refusing shields finish because the expected Hermes mode is malformed"
)
allowed_modes = (expected_mode,)
else:
raise UnsafePathError(
"refusing shields finish because the Hermes root posture is unknown"
)

current = os.fstat(hermes_fd)
if (
not _same_inode(current, hermes_meta)
or current.st_uid != hermes_meta.get("uid")
or current.st_gid != hermes_meta.get("gid")
or stat.S_IMODE(current.st_mode) not in allowed_modes
):
raise UnsafePathError(
"refusing shields finish because the final .hermes metadata drifted"
)
return current


def finish_shields_transition(
hermes_dir: str, hash_file: str, state_file: str, lock_token: str
) -> tuple[str, bool]:
Expand Down Expand Up @@ -4023,10 +4203,16 @@
hermes_st = os.fstat(hermes_fd)
if not _same_inode(hermes_st, hermes_meta):
raise UnsafePathError("refusing shields finish because .hermes changed")
hermes_st, root_posture = _reconcile_private_mutable_shields_root(
hermes_fd, hermes_st, hermes_meta, mode
)
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")
or (
stat.S_IMODE(hermes_st.st_mode) != hermes_meta.get("mode")
and root_posture != "same-uid-nonroot"
)
):
raise UnsafePathError(
"refusing shields finish because .hermes metadata drifted"
Expand Down Expand Up @@ -4084,6 +4270,9 @@
_verify_compat_hash(hash_file, os.path.join(hermes_dir, ".config-hash"))
os.fchmod(parent_fd, parent_meta["mode"])
_set_inode_flags(parent_fd, int(state_data.get("parent_flags", 0)))
_enforce_final_shields_root_posture(
hermes_fd, hermes_meta, mode, root_posture
)
_remove_restart_orphan_marker(hermes_fd)
# Parent ownership is the last persistent metadata change. Seal rejects
# set-id parent modes, so this chown cannot clear a prepared mode bit.
Expand Down
5 changes: 5 additions & 0 deletions ci/source-shape-test-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@
"test": "requires classifier review and integrity evidence when the OpenClaw build pin changes",
"category": "security"
},
{
"file": "test/hermes-runtime-config-guard-topology.test.ts",
"test": "restores exact locked posture after root-separated repair and later failure (#7033)",
"category": "security"
},
{
"file": "test/inference-options-docs.test.ts",
"test": "keeps a per-model task-fit comparison table for curated onboarding models",
Expand Down
15 changes: 14 additions & 1 deletion src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1728,7 +1728,20 @@ function unlockAgentConfigUnderMutationLock(
target.configDir,
]);
const [mode, owner] = dirPerms.split(" ");
if (mode !== dirMode) issues.push(`config dir mode=${mode} (expected ${dirMode})`);
// A 0700 Hermes root is provisional here. The token-bound guard finish
// preserves it only for an attested same-UID topology, repairs and
// verifies 03770 for a root-separated topology, and fails closed for an
// unknown topology.
const validDirMode =
mode === dirMode ||
(target.agentName === "hermes" && mode === "700" && transaction !== null);
if (!validDirMode) {
const expectedDirModes =
target.agentName === "hermes" && transaction !== null
? `${dirMode}, or provisional 700 pending sealed guard topology attestation`
: dirMode;
issues.push(`config dir mode=${mode} (expected ${expectedDirModes})`);
}
if (owner !== "sandbox:sandbox") {
issues.push(`config dir owner=${owner} (expected sandbox:sandbox)`);
}
Expand Down
56 changes: 54 additions & 2 deletions src/lib/shields/legacy-hermes-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe("legacy Hermes shields compatibility", () => {
fs.rmSync(homeDir, { recursive: true, force: true });
});

function installExecResponses(help: string): void {
function installExecResponses(help: string, hermesDirMode = "3770", finishError?: Error): void {
dockerExecSpy.mockImplementation((cmd: string[]) => {
switch (true) {
case cmd.includes(HERMES_GUARD) && cmd.includes("--help"):
Expand All @@ -137,8 +137,12 @@ describe("legacy Hermes shields compatibility", () => {
return `lock_token=${LOCK_TOKEN} original_locked=1`;
case isGuardAction(cmd, "apply-shields-transition"):
return "shields_mode=mutable chattr_applied=0";
case isGuardAction(cmd, "finish-shields-transition") && finishError !== undefined:
throw finishError;
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)}`;
default:
Expand Down Expand Up @@ -264,6 +268,54 @@ describe("legacy Hermes shields compatibility", () => {
expect(commands.some(isInlinePython)).toBe(false);
});

it("delegates a private Hermes root to the sealed guard before completing 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) => isGuardAction(cmd, "finish-shields-transition"))).toBe(true);
});

it("rolls back when the sealed guard cannot attest a private Hermes root", () => {
installExecResponses(
CURRENT_GUARD_HELP,
"700",
new Error("private mutable .hermes lacks an attested same-UID topology"),
);

expect(() => shields.unlockAgentConfig("current-hermes", hermesTarget(), true, true)).toThrow(
/attested same-UID topology/,
);

const commands = dockerExecSpy.mock.calls.map(commandFromCall);
expect(commands.some((cmd) => isGuardAction(cmd, "finish-shields-transition"))).toBe(true);
const prepareIndex = commands.findIndex((cmd) => isGuardAction(cmd, "prepare-shields-abort"));
const restoreIndex = commands.findIndex(
(cmd) =>
isGuardAction(cmd, "run-state-dir-transition") &&
cmd.includes("--state-action") &&
cmd.includes("lock"),
);
const abortIndex = commands.findIndex((cmd) => isGuardAction(cmd, "abort-shields-transition"));
expect(prepareIndex).toBeGreaterThan(-1);
expect(restoreIndex).toBeGreaterThan(prepareIndex);
expect(abortIndex).toBeGreaterThan(restoreIndex);
});

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"),
);
Comment thread
apurvvkumaria marked this conversation as resolved.
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