diff --git a/agents/hermes/runtime-config-guard.py b/agents/hermes/runtime-config-guard.py index 62669cbe7cd..3c5e7e60fee 100755 --- a/agents/hermes/runtime-config-guard.py +++ b/agents/hermes/runtime-config-guard.py @@ -302,7 +302,17 @@ def _proc_pid_namespace_inode(proc_pid_fd: int) -> int | None: def _cmdline_is_nemoclaw_start(raw: bytes) -> bool: - return any(argument in NEMOCLAW_START_ARGV for argument in raw.split(b"\0")) + normalized = raw.rstrip(b"\0") + arguments = tuple(normalized.split(b"\0")) if normalized else () + # Docker appends CMD arguments after ENTRYPOINT. Authenticate the canonical + # startup script position while allowing those opaque trailing arguments. + direct = bool(arguments) and arguments[0] in NEMOCLAW_START_ARGV + bash = ( + len(arguments) >= 2 + and arguments[0] in {b"bash", b"/bin/bash", b"/usr/bin/bash"} + and arguments[1] in NEMOCLAW_START_ARGV + ) + return direct or bash def _cmdline_is_openshell_supervisor(raw: bytes) -> bool: @@ -635,6 +645,7 @@ def _pinned_process_matches_supervised_nonroot_start( and second_status[0] == expected_effective_uid and first_status[1][-1] == numeric_pid and second_status[1][-1] == numeric_pid + and first_cmdline == second_cmdline and _cmdline_is_nemoclaw_start(first_cmdline) and _cmdline_is_nemoclaw_start(second_cmdline) and namespace_matches diff --git a/scripts/openclaw-config-guard.py b/scripts/openclaw-config-guard.py index b5ebfdba59a..fb5dedfd54e 100755 --- a/scripts/openclaw-config-guard.py +++ b/scripts/openclaw-config-guard.py @@ -621,7 +621,17 @@ def _release_mutation_mutex(mutex: MutationMutex) -> None: def _cmdline_is_nemoclaw_start(raw: bytes) -> bool: - return any(argument in NEMOCLAW_START_ARGV for argument in raw.split(b"\0")) + normalized = raw.rstrip(b"\0") + arguments = tuple(normalized.split(b"\0")) if normalized else () + # Docker appends CMD arguments after ENTRYPOINT. Authenticate the canonical + # startup script position while allowing those opaque trailing arguments. + direct = bool(arguments) and arguments[0] in NEMOCLAW_START_ARGV + bash = ( + len(arguments) >= 2 + and arguments[0] in {b"bash", b"/bin/bash", b"/usr/bin/bash"} + and arguments[1] in NEMOCLAW_START_ARGV + ) + return direct or bash def _cmdline_is_openshell_supervisor(raw: bytes) -> bool: @@ -912,7 +922,10 @@ def _pinned_process_matches_supervised_nonroot_start( second_namespace_inode = _proc_pid_namespace_inode(proc_pid_fd) pinned_after = os.fstat(proc_pid_fd) expected_namespace_inode = supervisor_identity[1] - namespace_matches = ( + # A readable inode proves whether the child shares the supervisor's PID + # namespace. Landlock may hide one or both namespace links, so retain + # stable equality as the fail-closed evidence available in that case. + same_namespace_matches = ( expected_namespace_inode is None and first_namespace_inode == second_namespace_inode ) or ( @@ -920,6 +933,35 @@ def _pinned_process_matches_supervised_nonroot_start( and first_namespace_inode == expected_namespace_inode and second_namespace_inode == expected_namespace_inode ) + nested_namespace_matches = ( + first_namespace_inode == second_namespace_inode + and ( + expected_namespace_inode is None + or ( + first_namespace_inode is not None + and first_namespace_inode != expected_namespace_inode + ) + ) + ) + same_namespace_pid_matches = ( + first_status is not None + and second_status is not None + and first_status[1][-1] == numeric_pid + and second_status[1][-1] == numeric_pid + ) + nested_namespace_pid_matches = ( + first_status is not None + and second_status is not None + and first_status[1][-1] == 1 + and second_status[1][-1] == 1 + ) + # In a nested workload PID namespace, the direct child is kernel-owned + # PID 1 there even though procfs names it by its outer numeric PID. + # The remaining pinned start-time, UID, PPID, cmdline, and fd checks + # apply identically to both supported topologies below. + topology_matches = ( + same_namespace_matches and same_namespace_pid_matches + ) or (nested_namespace_matches and nested_namespace_pid_matches) return bool( first_start_time is not None and second_start_time is not None @@ -930,11 +972,10 @@ def _pinned_process_matches_supervised_nonroot_start( and second_status is not None and first_status[0] == expected_effective_uid and second_status[0] == expected_effective_uid - and first_status[1][-1] == numeric_pid - and second_status[1][-1] == numeric_pid + and first_cmdline == second_cmdline and _cmdline_is_nemoclaw_start(first_cmdline) and _cmdline_is_nemoclaw_start(second_cmdline) - and namespace_matches + and topology_matches and pinned_before.st_dev == pinned_after.st_dev and pinned_before.st_ino == pinned_after.st_ino ) @@ -1313,10 +1354,12 @@ def _validate_action_readiness( ) ): # OpenShell is the container PID 1 and launches the configured - # image command as one non-root child in the same PID namespace. - # That degraded topology cannot publish root-owned readiness - # markers, so authenticate the stable supervisor/child pair while - # refusing any stale or malformed marker left by a strict startup. + # image command as one non-root child, either in the supervisor's + # PID namespace or as PID 1 in a nested workload PID namespace. + # When Landlock hides namespace inode links, the stable direct-child + # and NSpid evidence selects the same two topologies. They cannot + # publish root-owned readiness markers, so authenticate the stable + # supervisor/child pair while refusing stale or malformed markers. return if installed_current: raise GuardError( diff --git a/test/startup-process-identity.test.ts b/test/startup-process-identity.test.ts index cbe0f42bf88..9692b5a3f1f 100644 --- a/test/startup-process-identity.test.ts +++ b/test/startup-process-identity.test.ts @@ -86,6 +86,9 @@ def supervised_scenario( namespace_path = os.path.join(root, "shared") with open(namespace_path, "wb") as stream: stream.write(b"shared") + nested_namespace_path = os.path.join(root, "nested") + with open(nested_namespace_path, "wb") as stream: + stream.write(b"nested") write_process( proc_root, 1, @@ -97,13 +100,15 @@ def supervised_scenario( parent_pid=0, ) for process in processes: - pid, start_time, cmdline, effective_uid, inner_pid, parent_pid = process + pid, start_time, cmdline, effective_uid, inner_pid, parent_pid, *namespace = process + if namespace not in ([], ["nested"]): + raise AssertionError(f"unsupported namespace selector: {namespace!r}") write_process( proc_root, pid, start_time, cmdline, - namespace_path, + nested_namespace_path if namespace == ["nested"] else namespace_path, effective_uid=effective_uid, inner_pid=inner_pid, parent_pid=parent_pid, @@ -134,7 +139,14 @@ def supervised_scenario( guard._proc_pid_namespace_inode = original_namespace_reader entrypoint = b"bash\0/usr/local/bin/nemoclaw-start\0" +direct_entrypoint = b"/usr/local/bin/nemoclaw-start\0" +entrypoint_with_command = b"bash\0/usr/local/bin/nemoclaw-start\0true\0" +direct_entrypoint_with_command = b"/usr/local/bin/nemoclaw-start\0true\0" spoof = b"bash\0/tmp/nemoclaw-start-spoof\0" +argv_spoof = b"python3\0/tmp/evil.py\0/usr/local/bin/nemoclaw-start\0" +noncanonical_bash = b"/tmp/bash\0/usr/local/bin/nemoclaw-start\0" +misplaced_start = b"bash\0/tmp/evil.sh\0/usr/local/bin/nemoclaw-start\0" +empty_argument_spoof = b"bash\0\0/usr/local/bin/nemoclaw-start\0" proof = { "remapped": scenario([(412, "424242", entrypoint, "trusted")]), "stale": scenario([(412, "999999", entrypoint, "trusted")]), @@ -155,6 +167,24 @@ proof.update({ "openshell_supervised": supervised_scenario([ (412, "424242", entrypoint, 1000, 412, 1), ]), + "openshell_supervised_direct": supervised_scenario([ + (412, "424242", direct_entrypoint, 1000, 412, 1), + ]), + "openshell_supervised_command": supervised_scenario([ + (412, "424242", entrypoint_with_command, 1000, 412, 1), + ]), + "openshell_supervised_direct_command": supervised_scenario([ + (412, "424242", direct_entrypoint_with_command, 1000, 412, 1), + ]), + "openshell_noncanonical_bash": supervised_scenario([ + (412, "424242", noncanonical_bash, 1000, 412, 1), + ]), + "openshell_misplaced_start": supervised_scenario([ + (412, "424242", misplaced_start, 1000, 412, 1), + ]), + "openshell_empty_argument_spoof": supervised_scenario([ + (412, "424242", empty_argument_spoof, 1000, 412, 1), + ]), "openshell_landlock_all_namespaces_denied": supervised_scenario([ (412, "424242", entrypoint, 1000, 412, 1), ], namespace_access=False), @@ -170,12 +200,30 @@ proof.update({ "openshell_nested_child": supervised_scenario([ (412, "424242", entrypoint, 1000, 1, 1), ]), + "openshell_nested_pid_namespace": supervised_scenario([ + (412, "424242", entrypoint, 1000, 1, 1, "nested"), + ]), + "openshell_cross_namespace_outer_pid": supervised_scenario([ + (412, "424242", entrypoint, 1000, 412, 1, "nested"), + ]), + "openshell_nested_landlock_all_namespaces_denied": supervised_scenario([ + (412, "424242", entrypoint, 1000, 1, 1, "nested"), + ], namespace_access=False), + "openshell_nested_landlock_supervisor_namespace_denied": supervised_scenario([ + (412, "424242", entrypoint, 1000, 1, 1, "nested"), + ], namespace_access="child_only"), "openshell_non_direct_child": supervised_scenario([ (412, "424242", entrypoint, 1000, 412, 77), ]), "openshell_spoof": supervised_scenario([ (412, "424242", spoof, 1000, 412, 1), ]), + "openshell_argv_spoof": supervised_scenario([ + (412, "424242", argv_spoof, 1000, 412, 1), + ]), + "openshell_nested_argv_spoof": supervised_scenario([ + (412, "424242", argv_spoof, 1000, 1, 1, "nested"), + ]), "openshell_duplicate": supervised_scenario([ (412, "424242", entrypoint, 1000, 412, 1), (413, "525252", entrypoint, 1000, 413, 1), @@ -186,6 +234,12 @@ proof.update({ "openshell_wrong_required_child": supervised_scenario([ (412, "424242", entrypoint, 1000, 412, 1), ], required_pid=413), + "openshell_nested_required_child": supervised_scenario([ + (412, "424242", entrypoint, 1000, 1, 1, "nested"), + ], required_pid=412), + "openshell_nested_wrong_required_child": supervised_scenario([ + (412, "424242", entrypoint, 1000, 1, 1, "nested"), + ], required_pid=413), }) print(json.dumps(proof)) `; @@ -195,15 +249,29 @@ const GUARDS = [ ["Hermes", path.resolve("agents/hermes/runtime-config-guard.py")], ] as const; -describe.each(GUARDS)("%s startup process identity", (_name, guardPath) => { - it("authenticates exactly one root namespace init and rejects stale or spoofed identities (#2426)", () => { - const result = spawnSync("python3", ["-c", IDENTITY_HARNESS, guardPath], { - encoding: "utf-8", - timeout: 5000, - }); +function runIdentityHarness(guardPath: string) { + const result = spawnSync("python3", ["-c", IDENTITY_HARNESS, guardPath], { + encoding: "utf-8", + timeout: 5000, + }); + + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout); +} - expect(result.status, result.stderr).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ +describe.each(GUARDS)("%s startup process identity", (name, guardPath) => { + it("authenticates exactly one root namespace init and rejects stale or spoofed identities (#2426)", () => { + const { + openshell_argv_spoof: _openshellArgvSpoof, + openshell_nested_argv_spoof: _openshellNestedArgvSpoof, + openshell_supervised_command: _openshellSupervisedCommand, + openshell_supervised_direct_command: _openshellSupervisedDirectCommand, + openshell_noncanonical_bash: _openshellNoncanonicalBash, + openshell_misplaced_start: _openshellMisplacedStart, + openshell_empty_argument_spoof: _openshellEmptyArgumentSpoof, + ...proof + } = runIdentityHarness(guardPath); + expect(proof).toEqual({ remapped: true, stale: false, spoof: false, @@ -213,16 +281,40 @@ describe.each(GUARDS)("%s startup process identity", (_name, guardPath) => { duplicate: false, bounded: false, openshell_supervised: true, + openshell_supervised_direct: true, openshell_landlock_all_namespaces_denied: true, openshell_landlock_supervisor_namespace_denied: true, openshell_wrong_supervisor: false, openshell_root_child: false, openshell_nested_child: false, + // #6565 reproduces nested PID namespaces only for OpenClaw. Hermes keeps + // its independently tested same-namespace topology until it has a + // Hermes-specific reproduction or acceptance requirement. + openshell_nested_pid_namespace: name === "OpenClaw", + openshell_cross_namespace_outer_pid: false, + openshell_nested_landlock_all_namespaces_denied: name === "OpenClaw", + openshell_nested_landlock_supervisor_namespace_denied: name === "OpenClaw", openshell_non_direct_child: false, openshell_spoof: false, openshell_duplicate: false, openshell_required_child: true, openshell_wrong_required_child: false, + openshell_nested_required_child: name === "OpenClaw", + openshell_nested_wrong_required_child: false, }); }); }); + +describe.each(GUARDS)("%s exact startup argv", (_name, guardPath) => { + it("rejects a trusted script path smuggled in an unrelated argv (#6565)", () => { + const proof = runIdentityHarness(guardPath); + + expect(proof.openshell_argv_spoof).toBe(false); + expect(proof.openshell_nested_argv_spoof).toBe(false); + expect(proof.openshell_supervised_command).toBe(true); + expect(proof.openshell_supervised_direct_command).toBe(true); + expect(proof.openshell_noncanonical_bash).toBe(false); + expect(proof.openshell_misplaced_start).toBe(false); + expect(proof.openshell_empty_argument_spoof).toBe(false); + }); +});