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
2 changes: 2 additions & 0 deletions docs/security/tcb-boundary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 30 additions & 9 deletions scripts/managed-gateway-control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
59 changes: 57 additions & 2 deletions test/managed-gateway-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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],
Expand All @@ -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],
Expand Down
Loading