From a5342e428791480a20902ce564c6f8e01ac65f61 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 16 Jul 2026 09:53:46 -0700 Subject: [PATCH 1/3] fix(sandbox): ignore stable zombies during gateway discovery Signed-off-by: Prekshi Vyas --- docs/security/tcb-boundary.mdx | 1 + scripts/managed-gateway-control.py | 21 ++++++++++++++---- test/managed-gateway-control.test.ts | 33 +++++++++++++++++++++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/security/tcb-boundary.mdx b/docs/security/tcb-boundary.mdx index 642b5d7023e..67464330a43 100644 --- a/docs/security/tcb-boundary.mdx +++ b/docs/security/tcb-boundary.mdx @@ -101,6 +101,7 @@ A PID namespace inode would not distinguish PID reuse inside the same container The managed controller uses a stronger process key because OpenShell remains PID 1 while nonroot children can be replaced. Its key includes PID, start identity, parent PID, user IDs, namespace PID and inode, command line, process state, and procfs device and inode metadata. +It excludes an empty-command-line process only when two descriptor-pinned captures prove the same zombie state; an empty command line for any live state remains a terminal discovery refusal. It verifies listener ownership in the same network namespace and uses a pidfd for signalling where the platform supports it. For managed Hermes replacement, the controller binds the exact-exit authorization to the gateway process ID and start identity plus its own process ID and start identity. The nonroot supervisor accepts that authorization only while the same root controller with the fixed installed command shape remains live. diff --git a/scripts/managed-gateway-control.py b/scripts/managed-gateway-control.py index 8a0e6a47745..505664eb6b5 100755 --- a/scripts/managed-gateway-control.py +++ b/scripts/managed-gateway-control.py @@ -614,9 +614,18 @@ def _parse_status(raw: bytes, pid: int) -> tuple[tuple[int, int, int, int], int] return uid_values, namespace_values[-1] -def _parse_cmdline(raw: bytes) -> tuple[bytes, ...]: +def _parse_cmdline(raw: bytes, state: str) -> tuple[bytes, ...]: values = tuple(value for value in raw.split(b"\0") if value) - if not values or sum(len(value) for value in values) > MAX_PROC_FILE_BYTES: + if sum(len(value) for value in values) > MAX_PROC_FILE_BYTES: + raise ControlError("SUPERVISOR_UNAVAILABLE") + if not values: + # Linux exposes an empty cmdline after a process becomes a zombie. A + # twice-captured zombie cannot be a live supervisor or gateway, and all + # candidate matchers exclude state=Z while safely handling empty argv. + # Keep an empty cmdline terminal for every live state so a malformed or + # unreadable live process still makes discovery fail closed. + if state == "Z": + return () raise ControlError("SUPERVISOR_UNAVAILABLE") return values @@ -656,11 +665,15 @@ def capture(self, pid: int) -> ProcessIdentity: before = os.fstat(pid_fd) first_stat = _parse_stat(_read_at(pid_fd, "stat")) first_status = _parse_status(_read_at(pid_fd, "status"), pid) - first_cmdline = _parse_cmdline(_read_at(pid_fd, "cmdline")) + first_cmdline = _parse_cmdline( + _read_at(pid_fd, "cmdline"), first_stat[0] + ) first_namespace = _namespace_inode(pid_fd) second_stat = _parse_stat(_read_at(pid_fd, "stat")) second_status = _parse_status(_read_at(pid_fd, "status"), pid) - second_cmdline = _parse_cmdline(_read_at(pid_fd, "cmdline")) + second_cmdline = _parse_cmdline( + _read_at(pid_fd, "cmdline"), second_stat[0] + ) second_namespace = _namespace_inode(pid_fd) after = os.fstat(pid_fd) if ( diff --git a/test/managed-gateway-control.test.ts b/test/managed-gateway-control.test.ts index 1b1b1e18e98..9fbec0f1704 100644 --- a/test/managed-gateway-control.test.ts +++ b/test/managed-gateway-control.test.ts @@ -39,12 +39,13 @@ def write_process( cmdline, environ=b"PATH=/usr/bin\0", listener_inode=None, + state="S", ): process_root = os.path.join(proc_root, str(pid)) os.makedirs(os.path.join(process_root, "ns")) os.makedirs(os.path.join(process_root, "fd")) os.symlink("../net", os.path.join(process_root, "net")) - fields = ["S", str(parent_pid)] + (["0"] * 17) + [str(start_time)] + fields = [state, str(parent_pid)] + (["0"] * 17) + [str(start_time)] with open(os.path.join(process_root, "stat"), "w", encoding="ascii") as stream: stream.write(f"{pid} (managed) {' '.join(fields)}\n") with open(os.path.join(process_root, "status"), "w", encoding="ascii") as stream: @@ -89,6 +90,16 @@ with tempfile.TemporaryDirectory() as root: 0, b"/opt/openshell/bin/openshell-sandbox\0--managed\0", ) + write_process( + proc_root, + namespace_path, + 39, + 200, + 1, + 1000, + b"", + state="Z", + ) write_process( proc_root, namespace_path, @@ -141,10 +152,12 @@ with tempfile.TemporaryDirectory() as root: os.chmod(boundary_path, 0o755) with control.ProcReader(proc_root) as reader: + zombie = reader.capture(39) supervisor = control._discover_supervisor(reader) hermes = control._agent_spec("hermes", reader, supervisor) candidates = control._gateway_candidates(reader, supervisor, hermes) initial_proof = { + "stable_zombie": [zombie.state, len(zombie.cmdline)], "supervisor": [supervisor.pid, supervisor.start_time, supervisor.parent_pid], "gateway": [candidates[0].pid, candidates[0].start_time, candidates[0].parent_pid], "healthy": control._gateway_healthy(reader, candidates[0], hermes), @@ -241,6 +254,21 @@ with tempfile.TemporaryDirectory() as root: finally: reader.capture = real_capture remove_process(proc_root, 46) + write_process( + proc_root, + namespace_path, + 46, + 667, + 1, + 1000, + b"", + ) + try: + control._discover_supervisor(reader) + empty_live_process = "accepted" + except control.ControlError as error: + empty_live_process = error.code + remove_process(proc_root, 46) write_process( proc_root, namespace_path, @@ -713,6 +741,7 @@ with tempfile.TemporaryDirectory() as root: "missing_supervisor": missing_supervisor, "appearing_supervisor": appearing_supervisor, "unreadable_process": unreadable_process, + "empty_live_process": empty_live_process, "duplicate_supervisor": duplicate_supervisor, "duplicate": duplicate, "signals": sent, @@ -757,6 +786,7 @@ describe("managed gateway root control", () => { expect(result.status, result.stderr).toBe(0); expect(JSON.parse(result.stdout)).toEqual({ initial: { + stable_zombie: ["Z", 0], supervisor: [40, "222", 1], gateway: [41, "333", 40], healthy: true, @@ -785,6 +815,7 @@ describe("managed gateway root control", () => { missing_supervisor: "SUPERVISOR_NOT_RUNNING", appearing_supervisor: "SUPERVISOR_UNAVAILABLE", unreadable_process: "SUPERVISOR_UNAVAILABLE", + empty_live_process: "SUPERVISOR_UNAVAILABLE", duplicate_supervisor: "SUPERVISOR_UNAVAILABLE", duplicate: "SUPERVISOR_UNAVAILABLE", signals: [15, 9], From 3474cf20e7a27ca602c104bf2b9014484a50cb9b Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 16 Jul 2026 11:36:39 -0700 Subject: [PATCH 2/3] fix(sandbox): reject threaded zombie leaders Co-authored-by: Prekshi Vyas Signed-off-by: Apurv Kumaria --- docs/security/tcb-boundary.mdx | 3 ++- scripts/managed-gateway-control.py | 36 +++++++++++++++++----------- test/managed-gateway-control.test.ts | 26 +++++++++++++++++++- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/docs/security/tcb-boundary.mdx b/docs/security/tcb-boundary.mdx index 67464330a43..e273098c7a5 100644 --- a/docs/security/tcb-boundary.mdx +++ b/docs/security/tcb-boundary.mdx @@ -101,7 +101,8 @@ A PID namespace inode would not distinguish PID reuse inside the same container The managed controller uses a stronger process key because OpenShell remains PID 1 while nonroot children can be replaced. Its key includes PID, start identity, parent PID, user IDs, namespace PID and inode, command line, process state, and procfs device and inode metadata. -It excludes an empty-command-line process only when two descriptor-pinned captures prove the same zombie state; an empty command line for any live state remains a terminal discovery refusal. +It excludes an empty-command-line process only when two descriptor-pinned captures both report zombie state and exactly one thread. +Every other empty command line, including a zombie capture that reports additional threads, causes discovery to fail closed. It verifies listener ownership in the same network namespace and uses a pidfd for signalling where the platform supports it. For managed Hermes replacement, the controller binds the exact-exit authorization to the gateway process ID and start identity plus its own process ID and start identity. The nonroot supervisor accepts that authorization only while the same root controller with the fixed installed command shape remains live. diff --git a/scripts/managed-gateway-control.py b/scripts/managed-gateway-control.py index 505664eb6b5..9ff2b2477bb 100755 --- a/scripts/managed-gateway-control.py +++ b/scripts/managed-gateway-control.py @@ -581,18 +581,24 @@ def _namespace_inode(pid_fd: int) -> int | None: os.close(fd) -def _parse_stat(raw: bytes) -> tuple[str, int, str]: +def _parse_stat(raw: bytes) -> tuple[str, int, str, int]: try: text = raw.decode("ascii") suffix = text.rsplit(") ", 1)[1].split() state = suffix[0] parent_pid = int(suffix[1], 10) + thread_count = int(suffix[17], 10) start_time = suffix[19] except (IndexError, UnicodeDecodeError, ValueError) as exc: raise ControlError("SUPERVISOR_UNAVAILABLE") from exc - if not start_time.isascii() or not start_time.isdigit() or len(state) != 1: + if ( + not start_time.isascii() + or not start_time.isdigit() + or len(state) != 1 + or thread_count < 1 + ): raise ControlError("SUPERVISOR_UNAVAILABLE") - return state, parent_pid, start_time + return state, parent_pid, start_time, thread_count def _parse_status(raw: bytes, pid: int) -> tuple[tuple[int, int, int, int], int]: @@ -614,17 +620,19 @@ def _parse_status(raw: bytes, pid: int) -> tuple[tuple[int, int, int, int], int] return uid_values, namespace_values[-1] -def _parse_cmdline(raw: bytes, state: str) -> tuple[bytes, ...]: +def _parse_cmdline( + raw: bytes, state: str, thread_count: int +) -> tuple[bytes, ...]: values = tuple(value for value in raw.split(b"\0") if value) if sum(len(value) for value in values) > MAX_PROC_FILE_BYTES: raise ControlError("SUPERVISOR_UNAVAILABLE") if not values: - # Linux exposes an empty cmdline after a process becomes a zombie. A - # twice-captured zombie cannot be a live supervisor or gateway, and all - # candidate matchers exclude state=Z while safely handling empty argv. - # Keep an empty cmdline terminal for every live state so a malformed or - # unreadable live process still makes discovery fail closed. - if state == "Z": + # Linux exposes an empty cmdline after a process becomes a zombie, but + # a zombie thread-group leader can retain live sibling threads. Accept + # only a single-thread zombie; all candidate matchers exclude state=Z + # while safely handling empty argv. Keep every other empty cmdline + # terminal so discovery remains fail closed. + if state == "Z" and thread_count == 1: return () raise ControlError("SUPERVISOR_UNAVAILABLE") return values @@ -666,18 +674,18 @@ def capture(self, pid: int) -> ProcessIdentity: first_stat = _parse_stat(_read_at(pid_fd, "stat")) first_status = _parse_status(_read_at(pid_fd, "status"), pid) first_cmdline = _parse_cmdline( - _read_at(pid_fd, "cmdline"), first_stat[0] + _read_at(pid_fd, "cmdline"), first_stat[0], first_stat[3] ) first_namespace = _namespace_inode(pid_fd) second_stat = _parse_stat(_read_at(pid_fd, "stat")) second_status = _parse_status(_read_at(pid_fd, "status"), pid) second_cmdline = _parse_cmdline( - _read_at(pid_fd, "cmdline"), second_stat[0] + _read_at(pid_fd, "cmdline"), second_stat[0], second_stat[3] ) second_namespace = _namespace_inode(pid_fd) after = os.fstat(pid_fd) if ( - first_stat[1:] != second_stat[1:] + first_stat[1:3] != second_stat[1:3] or (first_stat[0] == "Z") != (second_stat[0] == "Z") or first_status != second_status or first_cmdline != second_cmdline @@ -686,7 +694,7 @@ def capture(self, pid: int) -> ProcessIdentity: or before.st_ino != after.st_ino ): raise ControlError("SUPERVISOR_UNAVAILABLE") - state, parent_pid, start_time = second_stat + state, parent_pid, start_time, _thread_count = second_stat uids, namespace_pid = second_status return ProcessIdentity( pid=pid, diff --git a/test/managed-gateway-control.test.ts b/test/managed-gateway-control.test.ts index 9fbec0f1704..bfe5924330c 100644 --- a/test/managed-gateway-control.test.ts +++ b/test/managed-gateway-control.test.ts @@ -40,12 +40,17 @@ def write_process( environ=b"PATH=/usr/bin\0", listener_inode=None, state="S", + thread_count=1, ): process_root = os.path.join(proc_root, str(pid)) os.makedirs(os.path.join(process_root, "ns")) os.makedirs(os.path.join(process_root, "fd")) os.symlink("../net", os.path.join(process_root, "net")) - fields = [state, str(parent_pid)] + (["0"] * 17) + [str(start_time)] + fields = ( + [state, str(parent_pid)] + + (["0"] * 15) + + [str(thread_count), "0", str(start_time)] + ) with open(os.path.join(process_root, "stat"), "w", encoding="ascii") as stream: stream.write(f"{pid} (managed) {' '.join(fields)}\n") with open(os.path.join(process_root, "status"), "w", encoding="ascii") as stream: @@ -162,6 +167,23 @@ with tempfile.TemporaryDirectory() as root: "gateway": [candidates[0].pid, candidates[0].start_time, candidates[0].parent_pid], "healthy": control._gateway_healthy(reader, candidates[0], hermes), } + write_process( + proc_root, + namespace_path, + 38, + 199, + 1, + 1000, + b"", + state="Z", + thread_count=2, + ) + try: + control._discover_supervisor(reader) + zombie_leader_with_live_sibling = "accepted" + except control.ControlError as error: + zombie_leader_with_live_sibling = error.code + remove_process(proc_root, 38) state_key_behavior = [ replace(candidates[0], state="R").stable_key() == candidates[0].stable_key(), @@ -732,6 +754,7 @@ with tempfile.TemporaryDirectory() as root: print(json.dumps({ "initial": initial_proof, + "zombie_leader_with_live_sibling": zombie_leader_with_live_sibling, "state_key_behavior": state_key_behavior, "mixed_namespace_rejected": mixed_namespace_rejected, "transient_supervisor_retry": transient_supervisor_retry, @@ -791,6 +814,7 @@ describe("managed gateway root control", () => { gateway: [41, "333", 40], healthy: true, }, + zombie_leader_with_live_sibling: "SUPERVISOR_UNAVAILABLE", state_key_behavior: [true, false], mixed_namespace_rejected: true, transient_supervisor_retry: [40, 2], From 198543ef069e063723a2dbc0a65e17b5a235a824 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Thu, 16 Jul 2026 12:04:57 -0700 Subject: [PATCH 3/3] test(sandbox): fix PID reuse fixture shape Signed-off-by: Prekshi Vyas --- test/managed-gateway-control.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/managed-gateway-control.test.ts b/test/managed-gateway-control.test.ts index bfe5924330c..8a90e12140a 100644 --- a/test/managed-gateway-control.test.ts +++ b/test/managed-gateway-control.test.ts @@ -395,7 +395,7 @@ with tempfile.TemporaryDirectory() as root: control._terminate_gateway(reader, expected_gateway) with open(os.path.join(proc_root, "41", "stat"), "w", encoding="ascii") as stream: - fields = ["S", "40"] + (["0"] * 17) + ["999"] + fields = ["S", "40"] + (["0"] * 15) + ["1", "0", "999"] stream.write(f"41 (managed) {' '.join(fields)}\n") try: control._terminate_gateway(reader, expected_gateway)