Files
odysseus/tests/test_process_ownership.py
T
Léo c004a26d46 fix(runtime): verify process identity before any teardown signal
A recorded pid is a claim, not a handle. The containment grant store, the
background-job store and the Cookbook task list all outlive the process that
wrote them — deliberately, so a restart keeps a job and its result — and the
kernel reuses pids. Any teardown driven off one of those records can therefore
land on a process we never started. ODY-86 was exactly this, and the Cookbook
survivor sweep still terminated any process whose full command line matched a
tracked one, which is the same mistake spelled differently.

Identity is (pid, start token). The token comes from /proc/<pid>/stat on Linux,
ps -o lstart= on macOS and the BSDs, and GetProcessTimes on Windows; the kernel
will not hand a pid to a process that started earlier, so comparing the token
recorded at launch against the token read now answers "is this still ours"
without a handle or a supervisor. verify() returns owned, gone, foreign or
unverifiable, and only owned permits a signal.

Keeping "unverifiable" out of the other two is the point. Process inspection
has broken off Linux four times here — ODY-70, -86, -94, -99 — every time
because an absent mechanism read as a successful answer. Folding it into "ours"
signals strangers; folding it into "gone" abandons live processes. It is a
containment failure and every caller treats it as one.

Wired into the three places that signal:

- containment.release() gates a grant recovered from the durable store, and
  leaves an in-process teardown alone, where the caller holds the child and no
  identity question arises. The verdict lands on the record, so "why is this
  grant still here" is answerable afterwards.

- A startup reaper. Nothing read either store before, so a crashed run left
  every grant permanently active and every job permanently running, and the
  first thing to touch such a record was a teardown aimed at a reassigned pid.
  The two stores get opposite treatment: an orphaned grant has no caller left
  and is torn down, while a detached job is documented to survive a restart and
  is only corrected, never killed.

- The Cookbook sweep takes its ownership from the tmux pane's process tree,
  captured before the kill destroys the only link between a surviving model
  server and the session that started it. A process that merely matches the
  tracked command line is now reported rather than killed: the Cookbook
  composed that command line, so an identical one is just as likely to be a
  server the user started by hand. The sweep also runs on hosts with no procfs
  instead of silently skipping, and says so when it could not look at all.
2026-10-01 18:55:50 +02:00

328 lines
12 KiB
Python

"""Process identity: the four verdicts, and that the fourth is never permissive.
Split in two. The verdict tests use **real processes**, because the claim under
test is about the kernel's behaviour — a pid that has been reaped, a pid that was
never issued, a pid whose start time differs from the one recorded — and a fake
process table cannot be wrong about that in the same ways. The mechanism tests
substitute the inspection layer, so both the procfs branch and the ``ps`` branch
are exercised on whichever kind of host happens to be running them.
The invariant worth most here is negative: :data:`process_ownership.OWNED` is the
only verdict that permits a signal, and nothing — a missing token, an absent
mechanism, a probe that raised — may produce it by default. Process inspection
has broken off Linux four times in this tree (ODY-70, -86, -94, -99), every time
because an absent mechanism read as a successful answer.
"""
import os
import subprocess
import pytest
from core import platform_compat
from src import process_ownership as po
@pytest.fixture
def sleeper():
"""A real, short-lived child in its own session. Always reaped."""
procs = []
def _spawn(argv=("sleep", "30")):
proc = subprocess.Popen(list(argv), start_new_session=True)
procs.append(proc)
return proc
yield _spawn
for proc in procs:
try:
proc.kill()
proc.wait(timeout=5)
except Exception:
pass
# ── Verdicts, against real processes ────────────────────────────────────────
def test_a_live_process_with_its_own_token_is_owned(sleeper):
proc = sleeper()
token = po.start_token(proc.pid)
assert token
assert po.verify(proc.pid, token) == po.OWNED
def test_the_same_pid_with_a_different_token_is_foreign(sleeper):
"""The whole point: a pid is a slot, and the token says who is in it."""
proc = sleeper()
token = po.start_token(proc.pid)
assert po.verify(proc.pid, str(token) + "-not-this-one") == po.FOREIGN
def test_a_reaped_process_is_gone(sleeper):
proc = sleeper()
token = po.start_token(proc.pid)
proc.kill()
proc.wait(timeout=5)
assert po.verify(proc.pid, token) == po.GONE
def test_a_pid_that_was_never_issued_is_gone():
# Above any plausible pid_max, so this cannot collide with a real process.
assert po.verify(2 ** 30, "token:anything") == po.GONE
def test_a_missing_token_is_unverifiable_and_never_owned(sleeper):
"""A record that captured no identity cannot acquire one afterwards.
This is the pre-upgrade record, and the reason it must not be OWNED is that
treating "we did not write it down" as "it is ours" is what makes a recycled
pid lethal.
"""
proc = sleeper()
assert po.verify(proc.pid, None) == po.UNVERIFIABLE
assert po.verify(proc.pid, "") == po.UNVERIFIABLE
assert po.UNVERIFIABLE not in po.SIGNALLABLE
def test_only_owned_permits_a_signal():
assert po.SIGNALLABLE == frozenset({po.OWNED})
def test_a_falsy_pid_is_gone_rather_than_unverifiable():
"""Nothing to identify and nothing to signal; the record is just empty."""
assert po.verify(None, "token:x") == po.GONE
assert po.verify(0, "token:x") == po.GONE
def test_capture_always_returns_both_fields(sleeper):
proc = sleeper()
captured = po.capture(proc.pid)
assert set(captured) == {"pid", "start_token"}
assert captured["pid"] == proc.pid
assert po.verify(captured["pid"], captured["start_token"]) == po.OWNED
def test_this_process_verifies_as_itself():
assert po.verify(os.getpid(), po.start_token(os.getpid())) == po.OWNED
# ── An unavailable mechanism is a failure, not a default ────────────────────
def test_no_inspection_mechanism_yields_unverifiable(monkeypatch, sleeper):
proc = sleeper()
token = po.start_token(proc.pid)
monkeypatch.setattr(po, "inspection_mechanism", lambda: po.MECHANISM_NONE)
# Not GONE (which would abandon a live process) and not OWNED (which would
# license a signal at an unidentified one).
assert po.verify(proc.pid, token) == po.UNVERIFIABLE
def test_no_mechanism_makes_start_token_raise_rather_than_return_none(monkeypatch):
"""None means "no such process". A question we could not ask is not that."""
monkeypatch.setattr(po, "inspection_mechanism", lambda: po.MECHANISM_NONE)
with pytest.raises(po.InspectionUnavailable):
po.start_token(os.getpid())
def test_a_probe_that_raises_is_unverifiable_not_owned(monkeypatch, sleeper):
proc = sleeper()
def _broken(_pid):
raise po.InspectionUnavailable("deliberately broken probe")
monkeypatch.setattr(po, "start_token", _broken)
assert po.verify(proc.pid, "token:whatever") == po.UNVERIFIABLE
def test_capture_records_no_token_rather_than_failing(monkeypatch):
"""A host that cannot identify its children must still be able to launch.
The record then reads UNVERIFIABLE forever, which is the honest outcome:
the launch is allowed, and the later teardown refuses.
"""
monkeypatch.setattr(po, "inspection_mechanism", lambda: po.MECHANISM_NONE)
captured = po.capture(4242)
assert captured == {"pid": 4242, "start_token": None}
assert po.verify(4242, captured["start_token"]) == po.UNVERIFIABLE
def test_process_table_raises_without_any_mechanism(monkeypatch):
monkeypatch.setattr(po, "inspection_mechanism", lambda: po.MECHANISM_NONE)
with pytest.raises(po.InspectionUnavailable):
po.process_table()
def test_the_procfs_table_guards_its_own_scan(monkeypatch, tmp_path):
"""Guarded in the function that scans, not only in its caller.
tests/test_procfs_scan_guard.py pins this structurally; this pins the
behaviour, so calling the branch directly on a procfs-less host raises
instead of FileNotFoundError.
"""
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "absent")
with pytest.raises(po.InspectionUnavailable):
po._procfs_process_table()
# ── Mechanism selection ─────────────────────────────────────────────────────
def test_procfs_is_preferred_where_it_exists(monkeypatch, tmp_path):
procfs = tmp_path / "proc"
procfs.mkdir()
monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "IS_WINDOWS", False)
assert po.inspection_mechanism() == po.MECHANISM_PROCFS
def test_ps_covers_hosts_with_no_procfs(monkeypatch, tmp_path):
"""macOS and the BSDs. The reason this module is not another /proc scan."""
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "absent")
monkeypatch.setattr(po, "IS_WINDOWS", False)
monkeypatch.setattr(po.shutil, "which", lambda name: "/bin/ps" if name == "ps" else None)
assert po.inspection_mechanism() == po.MECHANISM_PS
assert po.inspection_available()
def test_a_host_with_neither_reports_none(monkeypatch, tmp_path):
monkeypatch.setattr(platform_compat, "PROC_ROOT", tmp_path / "absent")
monkeypatch.setattr(po, "IS_WINDOWS", False)
monkeypatch.setattr(po.shutil, "which", lambda _name: None)
assert po.inspection_mechanism() == po.MECHANISM_NONE
assert not po.inspection_available()
def test_the_procfs_token_reads_a_comm_containing_spaces_and_parens(monkeypatch, tmp_path):
"""``/proc/<pid>/stat`` field 2 is attacker-adjacent: it is the executable name.
A process called ``my (weird) prog`` would shift every field after it if the
parser split on whitespace, which would silently read the wrong number as the
start time and make every verdict wrong.
"""
procfs = tmp_path / "proc"
(procfs / "77").mkdir(parents=True)
# "77 (comm) S" are fields 1-3, so the filler starts numbering at 4 and
# each value equals its own field number.
fields = " ".join(str(index) for index in range(4, 54))
(procfs / "77" / "stat").write_text(f"77 (my (weird) prog) S {fields}\n")
monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "IS_WINDOWS", False)
assert po.start_token(77) == "procfs:22"
# ── The process tree ────────────────────────────────────────────────────────
def test_descendants_walks_a_real_tree(sleeper):
"""The grandchild case: a shell that backgrounds work and the work itself."""
proc = sleeper(("bash", "-c", "sleep 30 & sleep 30"))
# Wait for the shell to have actually forked, without sleeping on a clock:
# poll the table until the children appear or the attempts run out.
found = []
for _attempt in range(100):
found = po.descendants([proc.pid])
if len(found) >= 3:
break
assert proc.pid in found
assert len(found) >= 3, f"expected the shell and its two children, got {found}"
def test_descendants_includes_the_root_even_with_no_children(sleeper):
proc = sleeper()
assert po.descendants([proc.pid]) == [proc.pid]
def test_descendants_takes_a_single_table_snapshot(monkeypatch):
"""One table in, one answer out — reparenting cannot hide a process.
Walking the tree with a fresh query per level lets a child be reparented
between queries and drop out of the result, which for a teardown means a
process nobody signals.
"""
rows = {
10: po.ProcessInfo(pid=10, ppid=1, command="root"),
11: po.ProcessInfo(pid=11, ppid=10, command="child"),
12: po.ProcessInfo(pid=12, ppid=11, command="grandchild"),
13: po.ProcessInfo(pid=13, ppid=1, command="unrelated"),
}
def _explode():
raise AssertionError("descendants must use the table it was given")
monkeypatch.setattr(po, "process_table", _explode)
assert po.descendants([10], table=rows) == [10, 11, 12]
def test_descendants_terminates_on_a_parent_cycle():
"""A table can report a cycle; the walk must not spin on it."""
rows = {
20: po.ProcessInfo(pid=20, ppid=21, command="a"),
21: po.ProcessInfo(pid=21, ppid=20, command="b"),
}
assert sorted(po.descendants([20], table=rows)) == [20, 21]
def test_the_procfs_table_reads_the_parent_pid(monkeypatch, tmp_path):
"""The procfs branch of the tree walk, exercised on a host without procfs.
macOS runs this suite and takes the ``ps`` branch, so without a substituted
``/proc`` the Linux parse — which is what the deployed image uses — would be
covered by nothing.
"""
procfs = tmp_path / "proc"
for pid, ppid in ((10, 1), (11, 10)):
(procfs / str(pid)).mkdir(parents=True)
(procfs / str(pid) / "cmdline").write_bytes(f"proc-{pid}\0--flag\0".encode())
filler = " ".join(str(index) for index in range(5, 54))
(procfs / str(pid) / "stat").write_text(f"{pid} (proc) S {ppid} {filler}\n")
monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "IS_WINDOWS", False)
table = po.process_table()
assert table[11].ppid == 10
assert table[10].command == "proc-10 --flag"
assert po.descendants([10], table=table) == [10, 11]
def test_a_procfs_row_with_an_unreadable_stat_keeps_its_command_line(
monkeypatch, tmp_path
):
"""A kernel thread or a pid that exits mid-walk still matters to a
command-line match; dropping the row entirely would hide it."""
procfs = tmp_path / "proc"
(procfs / "12").mkdir(parents=True)
(procfs / "12" / "cmdline").write_bytes(b"orphan-cmd\0")
monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "IS_WINDOWS", False)
table = po.process_table()
assert table[12].command == "orphan-cmd"
assert table[12].ppid == 0
def test_command_lines_sees_this_process():
table = po.command_lines()
assert os.getpid() in table
assert table[os.getpid()]