diff --git a/agents/hermes/runtime-config-guard.py b/agents/hermes/runtime-config-guard.py index e05ca34af19..2ea3f9be3ea 100755 --- a/agents/hermes/runtime-config-guard.py +++ b/agents/hermes/runtime-config-guard.py @@ -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" @@ -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" ) @@ -739,6 +741,65 @@ def _startup_ready_marker_absent() -> bool: 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" + + def _validate_action_readiness(action: str, startup_owner: bool) -> None: installed_current = os.path.abspath(__file__) == INSTALLED_RUNTIME_CONFIG_GUARD try: @@ -1065,6 +1126,22 @@ def _fsync_directory_after_replace(dir_fd: int) -> None: _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 + + def _atomic_replace_preserving_flags( path: str, data: bytes, expected: FileSnapshot ) -> None: @@ -3986,6 +4063,109 @@ def _freeze_shields_directories(state_data: dict[str, object], hermes_dir: str) 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]: @@ -4023,10 +4203,16 @@ 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") + 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" @@ -4084,6 +4270,9 @@ def finish_shields_transition( _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. diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 1daed33c771..813ef6a288e 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -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", diff --git a/src/lib/shields/index.ts b/src/lib/shields/index.ts index 8556619ad56..58cbfb5768f 100644 --- a/src/lib/shields/index.ts +++ b/src/lib/shields/index.ts @@ -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)`); } diff --git a/src/lib/shields/legacy-hermes-compat.test.ts b/src/lib/shields/legacy-hermes-compat.test.ts index 3181d920a6f..70dc2602561 100644 --- a/src/lib/shields/legacy-hermes-compat.test.ts +++ b/src/lib/shields/legacy-hermes-compat.test.ts @@ -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"): @@ -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: @@ -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); diff --git a/test/e2e/live/hermes-shields-config.test.ts b/test/e2e/live/hermes-shields-config.test.ts index 3fec7053517..57fbd8c547d 100644 --- a/test/e2e/live/hermes-shields-config.test.ts +++ b/test/e2e/live/hermes-shields-config.test.ts @@ -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`); diff --git a/test/hermes-runtime-config-guard-topology.test.ts b/test/hermes-runtime-config-guard-topology.test.ts new file mode 100644 index 00000000000..04708b73696 --- /dev/null +++ b/test/hermes-runtime-config-guard-topology.test.ts @@ -0,0 +1,660 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { dockerSpawnSync } from "../src/lib/adapters/docker/exec"; + +const RUNTIME_CONFIG_GUARD = path.join( + import.meta.dirname, + "..", + "agents", + "hermes", + "runtime-config-guard.py", +); +const ROOT_RUNTIME_IMAGE = + "python:3.12-slim@sha256:cab2dbf575e971934a81e4622f5aba17aa7929719bd7e31033a3a83b97fd0464"; +const ROOT_CONTAINER_DRIVER = String.raw` +import json +import os +import sys + +payload = json.load(sys.stdin) +guard_path = "/tmp/runtime-config-guard.py" +with open(guard_path, "x", encoding="utf-8") as handle: + handle.write(payload["guard_source"]) +os.chmod(guard_path, 0o444) +sys.argv = ["-c", guard_path] +exec( + compile(payload["harness"], "", "exec"), + {"__name__": "__main__"}, +) +`; + +function runPythonHarness(source: string) { + return spawnSync("python3", ["-c", source, RUNTIME_CONFIG_GUARD], { + encoding: "utf-8", + timeout: 5000, + }); +} + +function runRootContainerHarness(source: string) { + return dockerSpawnSync( + [ + "run", + "--rm", + "-i", + "--platform", + "linux/amd64", + "--network", + "none", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,size=64m", + ROOT_RUNTIME_IMAGE, + "python3", + "-c", + ROOT_CONTAINER_DRIVER, + ], + { + encoding: "utf-8", + input: JSON.stringify({ + guard_source: readFileSync(RUNTIME_CONFIG_GUARD, "utf-8"), + harness: source, + }), + timeout: 60_000, + }, + ); +} + +const loadGuardModule = String.raw` +import importlib.util +import sys +import types + +yaml = types.ModuleType("yaml") +class YAMLError(Exception): + pass +yaml.YAMLError = YAMLError +yaml.safe_load = lambda _text: {"model": "test"} +sys.modules["yaml"] = yaml + +spec = importlib.util.spec_from_file_location("runtime_config_guard", sys.argv[1]) +guard = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = guard +spec.loader.exec_module(guard) +`; + +describe("Hermes mutable shields root topology", () => { + it("maps a real missing marker to same-UID and rejects marker bypass or drift (#7033)", () => { + const result = runPythonHarness(`${loadGuardModule} +import json +import os +import tempfile +from types import SimpleNamespace + +def capture_topology_error(): + try: + guard._attested_shields_runtime_topology() + except guard.UnsafePathError as exc: + return str(exc) + return "" + +with tempfile.TemporaryDirectory() as tmp: + marker = os.path.join(tmp, "hermes-root-lifecycle") + target = os.path.join(tmp, "marker-target") + guard.HERMES_ROOT_LIFECYCLE_MARKER = marker + guard.__file__ = guard.INSTALLED_RUNTIME_CONFIG_GUARD + guard.os.geteuid = lambda: 0 + guard._startup_ready_marker_absent = lambda: True + guard.pwd.getpwnam = lambda _name: SimpleNamespace(pw_uid=1000) + fallback_calls = [] + guard._openshell_supervised_nonroot_start_is_live = ( + lambda *_args: fallback_calls.append("same-uid-proof") or True + ) + + missing_state = guard._root_lifecycle_marker_state() + missing_topology = guard._attested_shields_runtime_topology() + guard._openshell_supervised_nonroot_start_is_live = ( + lambda *_args: fallback_calls.append("missing-proof") or False + ) + missing_without_proof = guard._attested_shields_runtime_topology() + guard._openshell_supervised_nonroot_start_is_live = ( + lambda *_args: fallback_calls.append("unexpected-bypass-fallback") or True + ) + + with open(target, "wb") as handle: + handle.write(b"root-separated\\n") + os.chmod(target, 0o444) + os.symlink(target, marker) + symlink_error = capture_topology_error() + os.unlink(marker) + + os.symlink(os.path.join(tmp, "missing-target"), marker) + dangling_symlink_error = capture_topology_error() + os.unlink(marker) + + os.link(target, marker) + hardlink_error = capture_topology_error() + os.unlink(marker) + + os.mkdir(marker, 0o700) + directory_error = capture_topology_error() + os.rmdir(marker) + + marker_states = iter(("absent", "root-separated")) + guard._root_lifecycle_marker_state = lambda: next(marker_states) + guard._startup_ready_marker_absent = lambda: True + guard._openshell_supervised_nonroot_start_is_live = lambda *_args: True + marker_drift_error = capture_topology_error() + + guard._root_lifecycle_marker_state = lambda: "absent" + startup_states = iter((True, False)) + guard._startup_ready_marker_absent = lambda: next(startup_states) + startup_drift_error = capture_topology_error() + +print(json.dumps({ + "missing_state": missing_state, + "missing_topology": missing_topology, + "missing_without_proof": missing_without_proof, + "symlink_error": symlink_error, + "dangling_symlink_error": dangling_symlink_error, + "hardlink_error": hardlink_error, + "directory_error": directory_error, + "marker_drift_error": marker_drift_error, + "startup_drift_error": startup_drift_error, + "fallback_calls": fallback_calls, +})) +`); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + dangling_symlink_error: "Hermes root lifecycle marker is unsafe", + directory_error: "Hermes root lifecycle marker is unsafe", + fallback_calls: ["same-uid-proof", "missing-proof"], + hardlink_error: "Hermes root lifecycle marker is unsafe", + marker_drift_error: "Hermes runtime topology changed during attestation", + missing_state: "absent", + missing_topology: "same-uid-nonroot", + missing_without_proof: "unknown", + startup_drift_error: "Hermes runtime topology changed during attestation", + symlink_error: "Hermes root lifecycle marker is unsafe", + }); + }); + + it.skipIf(process.platform !== "linux" || process.getuid?.() !== 0)( + "attests a real root-owned marker and rejects malformed metadata or topology drift (#7033)", + () => { + const result = runPythonHarness(`${loadGuardModule} +import json +import os +import tempfile + +def capture_topology_error(): + try: + guard._attested_shields_runtime_topology() + except guard.UnsafePathError as exc: + return str(exc) + return "" + +with tempfile.TemporaryDirectory() as tmp: + marker = os.path.join(tmp, "hermes-root-lifecycle") + source = os.path.join(tmp, "marker-source") + guard.HERMES_ROOT_LIFECYCLE_MARKER = marker + fallback_calls = [] + guard._openshell_supervised_nonroot_start_is_live = ( + lambda *_args: fallback_calls.append("unexpected-fallback") or True + ) + + def publish(payload=b"root-separated\\n", mode=0o444, uid=0, gid=0): + try: + os.unlink(marker) + except FileNotFoundError: + pass + with open(marker, "wb") as handle: + handle.write(payload) + os.chown(marker, uid, gid) + os.chmod(marker, mode) + + publish() + + guard._pid1_is_nemoclaw_start = lambda: True + guard._process_effective_uid = lambda pid: 0 if pid == 1 else None + valid_state = guard._root_lifecycle_marker_state() + valid_topology = guard._attested_shields_runtime_topology() + + guard._pid1_is_nemoclaw_start = lambda: False + pid1_error = capture_topology_error() + guard._pid1_is_nemoclaw_start = lambda: True + + guard._process_effective_uid = lambda pid: 1000 if pid == 1 else None + pid1_uid_error = capture_topology_error() + guard._process_effective_uid = lambda pid: 0 if pid == 1 else None + + real_geteuid = guard.os.geteuid + guard.os.geteuid = lambda: 1000 + guard_uid_error = capture_topology_error() + guard.os.geteuid = real_geteuid + + publish(b"root-separated\\ntrailing-data") + content_error = capture_topology_error() + + publish(mode=0o644) + mode_error = capture_topology_error() + + publish(uid=12345, gid=12345) + owner_error = capture_topology_error() + + os.unlink(marker) + with open(source, "wb") as handle: + handle.write(b"root-separated\\n") + os.chown(source, 0, 0) + os.chmod(source, 0o444) + os.link(source, marker) + hardlink_error = capture_topology_error() + +print(json.dumps({ + "valid_state": valid_state, + "valid_topology": valid_topology, + "pid1_error": pid1_error, + "pid1_uid_error": pid1_uid_error, + "guard_uid_error": guard_uid_error, + "content_error": content_error, + "mode_error": mode_error, + "owner_error": owner_error, + "hardlink_error": hardlink_error, + "fallback_calls": fallback_calls, +})) +`); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + content_error: "Hermes root lifecycle marker is unsafe", + fallback_calls: [], + guard_uid_error: "Hermes root lifecycle marker does not match the live PID 1 topology", + hardlink_error: "Hermes root lifecycle marker is unsafe", + mode_error: "Hermes root lifecycle marker is unsafe", + owner_error: "Hermes root lifecycle marker is unsafe", + pid1_error: "Hermes root lifecycle marker does not match the live PID 1 topology", + pid1_uid_error: "Hermes root lifecycle marker does not match the live PID 1 topology", + valid_state: "root-separated", + valid_topology: "root-separated", + }); + }, + ); + + it("accepts private mode only for same UID, repairs root separation, and rejects unknown topology (#7033)", () => { + const result = runPythonHarness(`${loadGuardModule} +import json +import os +import stat +import tempfile +from types import SimpleNamespace + +with tempfile.TemporaryDirectory() as tmp: + hermes = os.path.join(tmp, ".hermes") + os.mkdir(hermes, 0o700) + os.chmod(hermes, 0o700) + fd = os.open(hermes, os.O_RDONLY | os.O_DIRECTORY) + try: + initial = os.fstat(fd) + expected = { + "dev": initial.st_dev, + "ino": initial.st_ino, + "uid": initial.st_uid, + "gid": initial.st_gid, + "mode": 0o3770, + } + + guard._attested_shields_runtime_topology = lambda: "same-uid-nonroot" + same_stat, same_posture = guard._reconcile_private_mutable_shields_root( + fd, os.fstat(fd), expected, "mutable" + ) + + os.fchmod(fd, 0o700) + guard._attested_shields_runtime_topology = lambda: "root-separated" + real_fchmod = guard.os.fchmod + real_fstat = guard.os.fstat + root_requested_modes = [] + def linux_fchmod(target_fd, mode): + if target_fd == fd and mode == 0o3770: + root_requested_modes.append(mode) + return + return real_fchmod(target_fd, mode) + def linux_fstat(target_fd): + current = real_fstat(target_fd) + if target_fd != fd or not root_requested_modes: + return current + return SimpleNamespace( + st_dev=current.st_dev, + st_ino=current.st_ino, + st_uid=current.st_uid, + st_gid=current.st_gid, + st_mode=(current.st_mode & ~0o7777) | root_requested_modes[-1], + ) + guard.os.fchmod = linux_fchmod + guard.os.fstat = linux_fstat + try: + root_stat, root_posture = guard._reconcile_private_mutable_shields_root( + fd, guard.os.fstat(fd), expected, "mutable" + ) + finally: + guard.os.fchmod = real_fchmod + guard.os.fstat = real_fstat + + os.fchmod(fd, 0o700) + guard._attested_shields_runtime_topology = lambda: "unknown" + try: + guard._reconcile_private_mutable_shields_root( + fd, os.fstat(fd), expected, "mutable" + ) + except guard.UnsafePathError as exc: + unknown_error = str(exc) + else: + unknown_error = "" + unknown_mode = stat.S_IMODE(os.fstat(fd).st_mode) + finally: + os.close(fd) + +print(json.dumps({ + "same_mode": stat.S_IMODE(same_stat.st_mode), + "same_posture": same_posture, + "root_mode": stat.S_IMODE(root_stat.st_mode), + "root_posture": root_posture, + "root_requested_modes": root_requested_modes, + "unknown_error": unknown_error, + "unknown_mode": unknown_mode, +})) +`); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + root_mode: 0o3770, + root_posture: "root-separated", + root_requested_modes: [0o3770], + same_mode: 0o700, + same_posture: "same-uid-nonroot", + unknown_error: + "refusing shields finish because private mutable .hermes lacks an attested same-UID topology", + unknown_mode: 0o700, + }); + }); + + it("reapplies root-separated mode after a deterministic post-attestation race (#7033)", () => { + const result = runPythonHarness(`${loadGuardModule} +import json +import os +import stat +import tempfile +from types import SimpleNamespace + +with tempfile.TemporaryDirectory() as tmp: + hermes = os.path.join(tmp, ".hermes") + os.mkdir(hermes, 0o700) + os.chmod(hermes, 0o700) + fd = os.open(hermes, os.O_RDONLY | os.O_DIRECTORY) + real_fchmod = guard.os.fchmod + real_fstat = guard.os.fstat + real_fsync = guard.os.fsync + synthetic_mode = 0o700 + requested_modes = [] + events = [] + + def tracked_fchmod(target_fd, mode): + global synthetic_mode + if target_fd == fd: + requested_modes.append(mode) + synthetic_mode = mode + events.append(f"fchmod:{oct(mode)}") + return + return real_fchmod(target_fd, mode) + + def tracked_fstat(target_fd): + current = real_fstat(target_fd) + if target_fd != fd: + return current + events.append(f"fstat:{oct(synthetic_mode)}") + return SimpleNamespace( + st_dev=current.st_dev, + st_ino=current.st_ino, + st_uid=current.st_uid, + st_gid=current.st_gid, + st_mode=(current.st_mode & ~0o7777) | synthetic_mode, + ) + + def tracked_fsync(target_fd): + if target_fd == fd: + events.append("fsync") + return + return real_fsync(target_fd) + + guard.os.fchmod = tracked_fchmod + guard.os.fstat = tracked_fstat + guard.os.fsync = tracked_fsync + try: + initial = guard.os.fstat(fd) + expected = { + "dev": initial.st_dev, + "ino": initial.st_ino, + "uid": initial.st_uid, + "gid": initial.st_gid, + "mode": 0o3770, + } + guard._attested_shields_runtime_topology = lambda: "root-separated" + _early, posture = guard._reconcile_private_mutable_shields_root( + fd, initial, expected, "mutable" + ) + + # Deterministically model the sandbox owner undoing the first repair + # after attestation but before the irreversible commit. + synthetic_mode = 0o700 + raced_mode = stat.S_IMODE(guard.os.fstat(fd).st_mode) + events.clear() + final = guard._enforce_final_shields_root_posture( + fd, expected, "mutable", posture + ) + commit_events = list(events) + synthetic_mode = 0o700 + same_private = guard._enforce_final_shields_root_posture( + fd, expected, "mutable", "same-uid-nonroot" + ) + synthetic_mode = 0o3770 + same_canonical = guard._enforce_final_shields_root_posture( + fd, expected, "mutable", "same-uid-nonroot" + ) + synthetic_mode = 0o750 + try: + guard._enforce_final_shields_root_posture( + fd, expected, "mutable", "same-uid-nonroot" + ) + except guard.UnsafePathError as exc: + same_drift_error = str(exc) + else: + same_drift_error = "" + finally: + guard.os.fchmod = real_fchmod + guard.os.fstat = real_fstat + guard.os.fsync = real_fsync + os.close(fd) + +print(json.dumps({ + "posture": posture, + "raced_mode": raced_mode, + "final_mode": stat.S_IMODE(final.st_mode), + "requested_modes": requested_modes, + "same_private_mode": stat.S_IMODE(same_private.st_mode), + "same_canonical_mode": stat.S_IMODE(same_canonical.st_mode), + "same_drift_error": same_drift_error, + "commit_events": commit_events, +})) +`); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + commit_events: ["fchmod:0o3770", "fsync", "fstat:0o3770"], + final_mode: 0o3770, + posture: "root-separated", + raced_mode: 0o700, + requested_modes: [0o3770, 0o3770], + same_canonical_mode: 0o3770, + same_drift_error: "refusing shields finish because the final .hermes metadata drifted", + same_private_mode: 0o700, + }); + }); + + // source-shape-contract: security -- Executes the shipped guard as root to prove real Linux repair and rollback metadata + it.skipIf(process.platform !== "linux")( + "restores exact locked posture after root-separated repair and later failure (#7033)", + () => { + const result = runRootContainerHarness(`${loadGuardModule} +import json +import os +import stat +import tempfile + +with tempfile.TemporaryDirectory() as tmp: + os.chmod(tmp, 0o700) + sandbox = os.path.join(tmp, "sandbox") + hermes = os.path.join(sandbox, ".hermes") + os.makedirs(hermes) + config = os.path.join(hermes, "config.yaml") + env = os.path.join(hermes, ".env") + strict = os.path.join(tmp, "hermes.config-hash") + state = os.path.join(tmp, "restart-state.json") + lock = os.path.join(tmp, "hermes-config-mutation.lock") + lifecycle_marker = os.path.join(tmp, "hermes-root-lifecycle") + + with open(lifecycle_marker, "wb") as handle: + handle.write(b"root-separated\\n") + os.chown(lifecycle_marker, 0, 0) + os.chmod(lifecycle_marker, 0o444) + guard.HERMES_ROOT_LIFECYCLE_MARKER = lifecycle_marker + guard._pid1_is_nemoclaw_start = lambda: True + guard._process_effective_uid = lambda pid: 0 if pid == 1 else None + + with open(config, "wb") as handle: + handle.write(b"model: test\\n") + with open(env, "wb") as handle: + handle.write(b"SAFE=1\\n") + initial_hash, _config_snapshot, _env_snapshot = guard._hash_text(config, env) + guard._write_hash(strict, initial_hash) + guard.refresh_hashes(hermes, strict, "both") + for name in (config, env, os.path.join(hermes, ".config-hash")): + os.chmod(name, 0o444) + os.chmod(hermes, 0o755) + os.chmod(sandbox, 0o755) + + guard._get_inode_flags = lambda _fd: 0 + guard._set_inode_flags = lambda _fd, _flags: None + guard._sandbox_identity = lambda: (12345, 12345) + token, _original_locked = guard.begin_shields_transition( + hermes, strict, state, "mutable", "locked" + ) + guard._claim_transition_worker = ( + lambda state_path, _token, _purpose: guard._load_restart_state(state_path) + ) + guard.apply_shields_transition(hermes, state, token) + + # Exercise the actual Linux owner capability through a descriptor retained + # before the parent namespace is frozen, rather than simulating chmod. + raced_fd = os.open(hermes, os.O_RDONLY | os.O_DIRECTORY) + child = os.fork() + if child == 0: + os.setgid(12345) + os.setuid(12345) + try: + os.fchmod(raced_fd, 0o700) + except OSError: + os._exit(1) + else: + os._exit(0) + _pid, child_status = os.waitpid(child, 0) + os.close(raced_fd) + if child_status != 0: + raise RuntimeError(f"sandbox chmod child failed: {child_status}") + + real_verify_compat = guard._verify_compat_hash + guard._verify_compat_hash = lambda *_args: (_ for _ in ()).throw( + guard.UnsafePathError("simulated later validation failure") + ) + try: + guard.finish_shields_transition(hermes, strict, state, token) + except guard.UnsafePathError as exc: + finish_error = str(exc) + else: + finish_error = "" + repaired_mode = stat.S_IMODE(os.stat(hermes).st_mode) + applied_state = guard._load_restart_state(state).get("phase") + + guard._verify_compat_hash = real_verify_compat + guard.prepare_shields_abort(hermes, state, token) + aborting_state = guard._load_restart_state(state).get("phase") + guard.abort_shields_transition(hermes, state, token) + lifecycle_stat = os.stat(lifecycle_marker, follow_symlinks=False) + with open(lifecycle_marker, "rb") as handle: + lifecycle_content = handle.read().decode("ascii") + + print(json.dumps({ + "finish_error": finish_error, + "repaired_mode": oct(repaired_mode), + "applied_state": applied_state, + "aborting_state": aborting_state, + "parent_mode": oct(stat.S_IMODE(os.stat(sandbox).st_mode)), + "parent_uid": os.stat(sandbox).st_uid, + "parent_gid": os.stat(sandbox).st_gid, + "hermes_mode": oct(stat.S_IMODE(os.stat(hermes).st_mode)), + "hermes_uid": os.stat(hermes).st_uid, + "hermes_gid": os.stat(hermes).st_gid, + "file_modes": { + name: oct(stat.S_IMODE(os.stat(os.path.join(hermes, name)).st_mode)) + for name in guard.SEALED_FILE_NAMES + }, + "state_exists": os.path.exists(state), + "lock_exists": os.path.exists(lock), + "marker_exists": os.path.exists( + os.path.join(hermes, guard.RESTART_ORPHAN_MARKER_NAME) + ), + "lifecycle_marker": { + "uid": lifecycle_stat.st_uid, + "gid": lifecycle_stat.st_gid, + "mode": oct(stat.S_IMODE(lifecycle_stat.st_mode)), + "nlink": lifecycle_stat.st_nlink, + "content": lifecycle_content, + }, + })) +`); + + expect(result.status, String(result.stderr)).toBe(0); + expect(JSON.parse(String(result.stdout))).toEqual({ + aborting_state: "shields-transition-aborting", + applied_state: "shields-transition-applied", + file_modes: { + ".config-hash": "0o444", + ".env": "0o444", + "config.yaml": "0o444", + }, + finish_error: "simulated later validation failure", + hermes_gid: 0, + hermes_mode: "0o755", + hermes_uid: 0, + lifecycle_marker: { + content: "root-separated\n", + gid: 0, + mode: "0o444", + nlink: 1, + uid: 0, + }, + lock_exists: false, + marker_exists: false, + parent_gid: 12345, + parent_mode: "0o1775", + parent_uid: 0, + repaired_mode: "0o3770", + state_exists: false, + }); + }, + ); +});