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
13 changes: 12 additions & 1 deletion agents/hermes/runtime-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
61 changes: 52 additions & 9 deletions scripts/openclaw-config-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -912,14 +922,46 @@ 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 (
expected_namespace_inode is not None
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
Expand All @@ -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
)
Expand Down Expand Up @@ -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(
Expand Down
112 changes: 102 additions & 10 deletions test/startup-process-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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")]),
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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))
`;
Expand All @@ -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,
Expand All @@ -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);
});
});
Loading