mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
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.
379 lines
15 KiB
Python
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})
|