diff --git a/docs/security/tcb-boundary.mdx b/docs/security/tcb-boundary.mdx index 642b5d7023e..e273098c7a5 100644 --- a/docs/security/tcb-boundary.mdx +++ b/docs/security/tcb-boundary.mdx @@ -101,6 +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 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 8a0e6a47745..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,9 +620,20 @@ 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, thread_count: int +) -> 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, 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 @@ -656,15 +673,19 @@ 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_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_cmdline = _parse_cmdline( + _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 @@ -673,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 1b1b1e18e98..8a90e12140a 100644 --- a/test/managed-gateway-control.test.ts +++ b/test/managed-gateway-control.test.ts @@ -39,12 +39,18 @@ def write_process( cmdline, 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 = ["S", 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: @@ -89,6 +95,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,14 +157,33 @@ 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), } + 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(), @@ -241,6 +276,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, @@ -345,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) @@ -704,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, @@ -713,6 +764,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,10 +809,12 @@ 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, }, + zombie_leader_with_live_sibling: "SUPERVISOR_UNAVAILABLE", state_key_behavior: [true, false], mixed_namespace_rejected: true, transient_supervisor_retry: [40, 2], @@ -785,6 +839,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],