Files
odysseus/tests/test_containment_process_tree.py
T
Léo 33fa27b4c8 feat(runtime): add the containment boundary and its failure contract
Agent-reachable execution has 25 independent spawn sites and no single
place deciding where a process runs or under what limits. All three
consequences are visible on this SHA. When bwrap is absent the workspace
namespace degrades to a regex that rewrites /workspace to the real path,
and nothing in the tool result says which one you got. No spawn site
passes start_new_session, so a wall-clock kill reaches the wrapper shell
and leaves its backgrounded grandchildren running while reporting the
process killed. Teardown stops at SIGTERM without ever checking death.

src/containment.py gives those paths one boundary. acquire() establishes
containment or refuses -- a string rewrite is not a mechanism it can
select -- and the grant states which dimensions actually hold, which were
best-effort and are missing, and which were required and are missing.
run() enforces the wall clock and the output cap. release() signals the
process group, escalates to SIGKILL, and reports dead only for a group it
observed go empty.

Containment never sees the command: acquire() takes a workspace and
limits, and the command text only reaches run(). Nothing in a request can
widen a boundary it is never shown.

CONTAINMENT_MODE chooses between refusing an unestablishable required
dimension and recording it. It ships report-only, so landing this changes
no behaviour on a host without bwrap -- which is every host today.

No call sites move here; they follow on this branch. The configuration
reference is regenerated because the page records src/constants.py line
numbers and the new path constant shifts two of them.
2026-10-01 17:55:33 +02:00

379 lines
15 KiB
Python

"""Teardown through the containment boundary, against real processes.
The headline case is the one that fails on an unmodified baseline: a command
that backgrounds a grandchild and then times out leaves the grandchild running,
while the tool result claims "process killed". These tests pin that the boundary
signals the whole process group and verifies death before reporting it.
POSIX only — the Windows path walks the tree with ``taskkill /T /F`` and has no
host here to run on, which is stated in the PR rather than skipped silently.
"""
import os
import signal
import subprocess
import sys
import time
from dataclasses import replace
import pytest
from core.platform_compat import pid_alive
from src import containment
pytestmark = pytest.mark.skipif(
sys.platform.startswith("win"), reason="POSIX process groups; Windows path untested here"
)
@pytest.fixture(autouse=True)
def _isolated_store(tmp_path, monkeypatch):
store = tmp_path / "containment_grants.json"
monkeypatch.setattr(containment, "_store_path", lambda: store)
return store
@pytest.fixture(autouse=True)
def _real_process_group_mechanism(monkeypatch):
"""Pin the mechanism to the real POSIX process group.
Not a fake: this is the mechanism shipped in ``MECHANISMS``, selected by
name so the test behaves the same on a host that happens to have bubblewrap
installed. Filesystem containment is bubblewrap's job and is not what these
tests are about.
"""
selected = [item for item in containment.MECHANISMS if item.name == "process_group"]
assert selected, "process_group mechanism disappeared from MECHANISMS"
monkeypatch.setattr(containment, "MECHANISMS", tuple(selected))
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_ENFORCING)
@pytest.fixture
def workspace(tmp_path):
path = tmp_path / "ws"
path.mkdir()
return str(path)
def tree_spec(workspace, **kwargs):
"""A spec requiring exactly what a process group can give."""
kwargs.setdefault("env", {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"})
kwargs.setdefault("wall_clock_s", 1)
return containment.ContainmentSpec(
workspace=workspace,
required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}),
**kwargs,
)
def read_pid(path, *, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
text = path.read_text(encoding="utf-8").strip()
except OSError:
text = ""
if text.isdigit():
return int(text)
time.sleep(0.02)
raise AssertionError(f"{path} never received a pid")
def gone(pid, *, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not pid_alive(pid):
return True
time.sleep(0.02)
return not pid_alive(pid)
# ── The regression this lane exists to close ────────────────────────────────
async def test_a_timeout_leaves_no_surviving_grandchild(tmp_path, workspace):
"""A backgrounded grandchild does not survive the wall-clock kill.
On a baseline spawn site the wrapper shell is killed with ``proc.kill()``
and the grandchild keeps running, unowned and unreaped, while the tool
result says the process was killed.
"""
pidfile = tmp_path / "grandchild.pid"
command = f"bash -c 'sleep 60 & echo $! > {pidfile}'; sleep 60"
grant = containment.acquire(tree_spec(workspace), owner="session-1")
result = await containment.run(grant, command)
grandchild = read_pid(pidfile)
assert result.timed_out is True
assert gone(grandchild), f"grandchild {grandchild} survived the timeout kill"
assert result.release is not None
assert result.release.dead is True
assert result.release.survivors == ()
async def test_the_timeout_outcome_is_observed_not_asserted(tmp_path, workspace):
"""``dead`` reflects a verified empty process group, not a signal that was sent."""
pidfile = tmp_path / "child.pid"
command = f"echo $$ > {pidfile}; sleep 60"
grant = containment.acquire(tree_spec(workspace), owner="session-1")
result = await containment.run(grant, command)
leader = read_pid(pidfile)
assert result.timed_out is True
assert result.release.dead is True
assert gone(leader)
assert containment._group_present(result.grant.pgid) is False
async def test_a_clean_exit_tears_down_anything_left_behind(tmp_path, workspace):
"""A command that returns while leaving a background process does not leak it.
The leftover closes its inherited pipes (``>/dev/null 2>&1``) so the command
really does complete: a background process still holding the output pipes
keeps the grant open until the wall clock, which is the previous test's case
rather than this one's.
"""
pidfile = tmp_path / "leftover.pid"
command = f"sleep 60 >/dev/null 2>&1 & echo $! > {pidfile}; exit 0"
grant = containment.acquire(tree_spec(workspace, wall_clock_s=10), owner="session-1")
result = await containment.run(grant, command)
leftover = read_pid(pidfile)
assert result.timed_out is False
assert result.exit_code == 0
assert gone(leftover), f"background process {leftover} outlived its grant"
assert result.release.dead is True
# ── Escalation ──────────────────────────────────────────────────────────────
def test_release_escalates_to_sigkill_and_reports_only_verified_death(tmp_path, workspace):
"""A SIGTERM-ignoring tree is escalated, and ``dead`` is set only once gone."""
# The child announces itself only after installing the handler. Without that
# the test races process startup and sometimes measures a child that was
# still using the default SIGTERM disposition.
ready = tmp_path / "ignoring-sigterm"
code = (
"import signal, time\n"
"signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
f"open({str(ready)!r}, 'w').write('x')\n"
"time.sleep(60)\n"
)
proc = subprocess.Popen(
[sys.executable, "-c", code],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
try:
deadline = time.monotonic() + 10
while time.monotonic() < deadline and not ready.exists():
time.sleep(0.02)
assert ready.exists(), "child never installed its SIGTERM handler"
grant = containment.acquire(tree_spec(workspace), owner="session-1")
grant = replace(grant, pid=proc.pid, pgid=os.getpgid(proc.pid))
outcome = containment.release(grant, grace_s=0.3)
proc.wait(timeout=5)
assert outcome.escalated is True
assert outcome.dead is True
assert outcome.survivors == ()
finally:
if proc.poll() is None: # pragma: no cover - only on an unexpected failure
proc.kill()
proc.wait(timeout=5)
def test_release_does_not_escalate_a_cooperative_tree(workspace):
proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(60)"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
try:
grant = containment.acquire(tree_spec(workspace), owner="session-1")
grant = replace(grant, pid=proc.pid, pgid=os.getpgid(proc.pid))
outcome = containment.release(grant, grace_s=2.0)
proc.wait(timeout=5)
assert outcome.dead is True
assert outcome.escalated is False
finally:
if proc.poll() is None: # pragma: no cover
proc.kill()
proc.wait(timeout=5)
def test_a_surviving_tree_keeps_its_record_active(workspace, monkeypatch):
"""A record moves to released only on verified death.
With teardown unable to signal anything, ``release`` must report
``dead=False`` with the survivors named, and must not mark the grant
released — the inverse of marking a job killed without checking.
"""
proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(60)"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
try:
grant = containment.acquire(tree_spec(workspace), owner="session-1")
grant = replace(grant, pid=proc.pid, pgid=os.getpgid(proc.pid))
monkeypatch.setattr(containment, "_signal_tree", lambda *args, **kwargs: None)
outcome = containment.release(grant, grace_s=0.2)
assert outcome.dead is False
assert outcome.escalated is True
assert proc.pid in outcome.survivors
active = {record["id"] for record in containment.active_grants()}
assert grant.id in active
assert pid_alive(proc.pid) is True
finally:
proc.kill()
proc.wait(timeout=5)
def test_release_never_signals_the_servers_own_process_group(workspace, monkeypatch):
"""If setsid had not applied, killpg would take the server down with the child.
Driven by handing teardown our own group id, which is exactly the state a
failed setsid would leave behind.
"""
sent = []
monkeypatch.setattr(os, "killpg", lambda pgid, sig: sent.append((pgid, sig)))
monkeypatch.setattr(os, "kill", lambda pid, sig: sent.append(("pid", pid, sig)))
containment._signal_tree(os.getpid(), os.getpgid(0), signal.SIGTERM)
assert all(entry[0] != os.getpgid(0) for entry in sent), sent
assert sent == [("pid", os.getpid(), signal.SIGTERM)]
def test_our_own_group_is_never_reported_as_a_childs_group():
assert containment._group_present(os.getpgid(0)) is False
# ── run(): the contained happy path ─────────────────────────────────────────
async def test_run_returns_output_exit_code_and_a_released_record(workspace):
grant = containment.acquire(tree_spec(workspace, wall_clock_s=10), owner="session-9")
result = await containment.run(grant, "echo contained; exit 3")
assert result.stdout == "contained\n"
assert result.exit_code == 3
assert result.timed_out is False
assert result.output_truncated is False
assert result.grant.pid is not None
assert containment.active_grants() == []
async def test_run_executes_in_the_workspace_and_with_the_declared_env_only(workspace):
grant = containment.acquire(
tree_spec(workspace, wall_clock_s=10, env={"PATH": "/usr/bin:/bin", "MARK": "yes"}),
owner="session-9",
)
result = await containment.run(grant, 'pwd; echo "MARK=$MARK"; echo "HOME=${HOME:-unset}"')
assert result.exit_code == 0
assert os.path.realpath(workspace) == os.path.realpath(result.stdout.splitlines()[0])
assert "MARK=yes" in result.stdout
# The child env is exactly what the spec declared, never an implicit
# inherit, so the server's own environment does not leak into it.
assert "HOME=unset" in result.stdout
async def test_run_caps_output_and_says_so(workspace):
grant = containment.acquire(
tree_spec(workspace, wall_clock_s=10, max_output_bytes=16), owner="session-9",
)
result = await containment.run(grant, "printf 'x%.0s' $(seq 1 500); echo")
assert result.output_truncated is True
assert len(result.stdout.encode("utf-8")) <= 16
async def test_run_accepts_an_argv_command_without_a_shell(workspace):
grant = containment.acquire(tree_spec(workspace, wall_clock_s=10), owner="session-9")
result = await containment.run(
grant, [sys.executable, "-c", "print('argv path')"], argv=True,
)
assert result.exit_code == 0
assert result.stdout.strip() == "argv path"
async def test_run_feeds_stdin_when_given(workspace):
grant = containment.acquire(tree_spec(workspace, wall_clock_s=10), owner="session-9")
result = await containment.run(grant, "cat", stdin=b"piped\n")
assert result.exit_code == 0
assert result.stdout == "piped\n"
@pytest.mark.parametrize("command, argv", [(" ", False), ([], True)])
async def test_run_rejects_an_empty_command(workspace, command, argv):
grant = containment.acquire(tree_spec(workspace, wall_clock_s=10), owner="session-9")
with pytest.raises(ValueError, match="empty"):
await containment.run(grant, command, argv=argv)
# ── Resource limits, where the platform provides them ───────────────────────
async def test_a_process_count_limit_is_applied_to_the_child(workspace):
"""RLIMIT_NPROC is set in the child, so the ceiling is real where it is claimed."""
spec = containment.ContainmentSpec(
workspace=workspace,
env={"PATH": "/usr/bin:/bin"},
wall_clock_s=10,
required=frozenset({
containment.PROCESS_TREE, containment.WALL_CLOCK, containment.PROCESS_COUNT,
}),
max_processes=64,
)
grant = containment.acquire(spec, owner="session-9")
assert containment.PROCESS_COUNT in grant.enforced
result = await containment.run(
grant,
[sys.executable, "-c",
"import resource; print(resource.getrlimit(resource.RLIMIT_NPROC))"],
argv=True,
)
assert result.exit_code == 0
assert result.stdout.strip() == "(64, 64)"
async def test_a_memory_limit_is_claimed_only_where_it_can_be_applied(workspace):
"""The claim and the reality agree, on whichever platform this runs.
macOS reports an infinite RLIMIT_AS hard limit and then refuses to lower it,
so `memory` must come back unenforced there rather than enforced-and-crashing.
Written to assert the consistency rather than the platform, so it is a real
test on Linux and a real test here.
"""
limit = 2 * 1024 * 1024 * 1024
spec = containment.ContainmentSpec(
workspace=workspace,
env={"PATH": "/usr/bin:/bin"},
wall_clock_s=10,
required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}),
max_memory_bytes=limit,
)
grant = containment.acquire(spec, owner="session-9")
probe = [sys.executable, "-c",
"import resource; print(resource.getrlimit(resource.RLIMIT_AS)[0])"]
result = await containment.run(grant, probe, argv=True)
assert result.exit_code == 0, result.stderr
if containment.MEMORY in grant.enforced:
assert result.stdout.strip() == str(limit)
else:
assert containment.MEMORY in grant.degraded
assert result.stdout.strip() != str(limit)
def test_an_unenforceable_required_limit_refuses_instead_of_crashing_the_spawn(workspace):
"""A limit this platform cannot apply is refused at acquire, not in preexec_fn.
Skipped where the platform *can* apply it, since then there is nothing to
refuse.
"""
if containment._ADDRESS_SPACE_LIMIT_SUPPORTED:
pytest.skip("this platform can lower RLIMIT_AS, so there is no shortfall")
spec = containment.ContainmentSpec(
workspace=workspace,
env={"PATH": "/usr/bin:/bin"},
wall_clock_s=10,
required=frozenset({
containment.PROCESS_TREE, containment.WALL_CLOCK, containment.MEMORY,
}),
max_memory_bytes=2 * 1024 * 1024 * 1024,
)
with pytest.raises(containment.ContainmentUnavailable) as caught:
containment.acquire(spec, owner="session-9")
assert caught.value.missing == frozenset({containment.MEMORY})