fix(runtime): enforce functional containment after closing execution bypasses (ODY-141)

This commit is contained in:
Alexandre Teixeira
2026-10-01 22:11:10 +01:00
parent 4efb85ee33
commit 63fe66e85e
15 changed files with 564 additions and 152 deletions
+19 -41
View File
@@ -388,7 +388,7 @@ def _contained_command(
comparable — one is a mount namespace, the other is a regex — and choosing comparable — one is a mount namespace, the other is a regex — and choosing
the second silently means an uncontained host execution reads in the the second silently means an uncontained host execution reads in the
transcript exactly like a contained one. The fallback still happens under transcript exactly like a contained one. The fallback still happens under
report-only mode, which is what ships; the difference is that it is now an explicit report-only diagnostic mode; the difference is that it is now
recorded in the result. recorded in the result.
:raises containment.ContainmentUnavailable: filesystem containment could :raises containment.ContainmentUnavailable: filesystem containment could
@@ -399,7 +399,7 @@ def _contained_command(
content, cwd, chdir=chdir, interpreter_prefix=interpreter_prefix, content, cwd, chdir=chdir, interpreter_prefix=interpreter_prefix,
) )
# The probe's filesystem answer and the wrapper's None/not-None answer rest # The probe's filesystem answer and the wrapper's None/not-None answer rest
# on the same condition (`not IS_WINDOWS and which("bwrap")`), so they agree # on the same functional namespace probe, so they agree
# by construction. `wrapped` is still what decides, because it is what # by construction. `wrapped` is still what decides, because it is what
# actually runs: a probe that said yes to a wrapper that declined would be # actually runs: a probe that said yes to a wrapper that declined would be
# the same false claim in the other direction. # the same false claim in the other direction.
@@ -449,41 +449,9 @@ def _wrap_workspace_namespace(
bubblewrap namespace preserves that public contract for each concurrent bubblewrap namespace preserves that public contract for each concurrent
agent without creating a process-global /workspace symlink. agent without creating a process-global /workspace symlink.
""" """
if IS_WINDOWS or not shutil.which("bwrap"): if IS_WINDOWS or not containment._bwrap_available():
return None return None
args = [ readonly = [path for path in ("/home", "/mnt") if os.path.isdir(path)]
"bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/",
"--dir", "/usr", "--ro-bind", "/usr", "/usr",
"--symlink", "usr/bin", "/bin",
"--symlink", "usr/lib", "/lib",
"--symlink", "usr/lib64", "/lib64",
"--symlink", "usr/bin", "/sbin",
"--dir", "/etc", "--ro-bind", "/etc", "/etc",
# Read-only, not read-write. These two binds exist so a command can
# *read* host material it legitimately needs — a dataset under /mnt, a
# dotfile under /home. Binding them writable gave back most of what
# the namespace was for: a Linux host with working bubblewrap running
# this argv reaches outside the workspace and writes to the user's home
# directory, measured rather than inferred. The workspace bind below is
# the one writable path, which is what "workspace confinement" means.
"--dir", "/home", "--ro-bind", "/home", "/home",
"--dir", "/mnt", "--ro-bind", "/mnt", "/mnt",
"--dir", "/tmp", "--tmpfs", "/tmp",
"--dev-bind", "/dev", "/dev", "--proc", "/proc",
"--dir", WORKSPACE_MOUNT, "--bind", cwd, WORKSPACE_MOUNT,
]
# The workspace stays writable at its real host path as well as at
# /workspace. A command can carry the absolute host path: BashTool's own
# /tmp redirect rewrites `/tmp/` to `<agent_cwd()>/.tmp/` before the
# namespace is built, so the command reaching bwrap already names the real
# path. Before /home and /mnt became read-only those writes landed only
# because the workspace happened to sit under one of them. Binding the
# workspace itself is the narrow version of what that accident provided:
# the same directory by either name, and nothing else writable.
real_cwd = os.path.realpath(cwd)
if real_cwd not in _NAMESPACE_RESERVED_DESTS and len(real_cwd.split(os.sep)) >= 3:
args.extend(_namespace_dir_chain(real_cwd))
args.extend(("--bind", real_cwd, real_cwd))
# setup-python installs interpreters under /opt, and local CI virtualenvs # setup-python installs interpreters under /opt, and local CI virtualenvs
# can live under /tmp. Those paths are hidden by the private root/tmpfs. # can live under /tmp. Those paths are hidden by the private root/tmpfs.
# Expose only the active interpreter environment, read-only, so Python # Expose only the active interpreter environment, read-only, so Python
@@ -513,9 +481,14 @@ def _wrap_workspace_namespace(
and os.path.isdir(prefix) and os.path.isdir(prefix)
and has_environment_layout and has_environment_layout
): ):
args.extend(_namespace_dir_chain(prefix)) readonly.append(prefix)
args.extend(("--ro-bind", prefix, prefix)) spec = containment.ContainmentSpec(
args.extend(("--chdir", chdir, "/bin/bash", "-lc", content)) workspace=cwd, env={}, wall_clock_s=DEFAULT_BASH_TIMEOUT,
readonly_extra=tuple(readonly),
)
args = containment._bwrap_prefix(spec)
args[-1] = chdir
args.extend(("/bin/bash", "-lc", content))
return shlex.join(args) return shlex.join(args)
@@ -524,7 +497,8 @@ def _owned_spec(cwd: str, env: Optional[dict], timeout: int, readonly_extra: tup
readonly = [] readonly = []
for prefix in (sys.prefix, sys.base_prefix): for prefix in (sys.prefix, sys.base_prefix):
prefix = os.path.realpath(prefix) prefix = os.path.realpath(prefix)
if not _namespace_visible_without_bind(prefix) and prefix not in _NAMESPACE_RESERVED_DESTS: visible = any(prefix == root or prefix.startswith(root + os.sep) for root in ("/usr", "/etc"))
if not visible and prefix not in _NAMESPACE_RESERVED_DESTS:
readonly.append(prefix) readonly.append(prefix)
return containment.agent_spec( return containment.agent_spec(
cwd, dict(os.environ if env is None else env), timeout, cwd, dict(os.environ if env is None else env), timeout,
@@ -551,8 +525,12 @@ async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, arg
except containment.ContainmentUnavailable as exc: except containment.ContainmentUnavailable as exc:
return containment.unavailable_tool_result(exc, tool=tool) return containment.unavailable_tool_result(exc, tool=tool)
except (OSError, RuntimeError, ValueError) as exc: except (OSError, RuntimeError, ValueError) as exc:
boundary = grant.to_dict() if grant else {}
boundary["executed"] = bool(getattr(exc, "containment_executed", False))
if not getattr(exc, "containment_established", False):
boundary.update(contained=False, enforced=[])
return {"error": f"{tool}: execution failed: {exc}", "exit_code": 1, return {"error": f"{tool}: execution failed: {exc}", "exit_code": 1,
"containment": grant.to_dict() if grant else {"contained": False, "executed": False}} "containment": boundary}
boundary = result.grant.to_dict() boundary = result.grant.to_dict()
boundary["executed"] = True boundary["executed"] = True
+209 -87
View File
@@ -39,11 +39,10 @@ deployment detail.
* :data:`MODE_REPORT_ONLY` — the same shortfall is recorded on the grant as * :data:`MODE_REPORT_ONLY` — the same shortfall is recorded on the grant as
``unenforced_required``, logged once, and the command runs. ``unenforced_required``, logged once, and the command runs.
The shipped default is report-only. On macOS and in the shipped Docker image The shipped default is enforcing. Hosts without functional namespaces refuse
there is no ``bwrap``, so enforcing filesystem containment by default would turn native agent execution requiring filesystem and process-tree containment.
every ``bash`` call into a refusal the moment this module is wired up. Starting Report-only remains an explicit internal diagnostic posture, never a tool or
report-only makes that landing observable instead of breaking, and flipping the deployment setting. Networking is inherited unless the spec requests isolation.
constant is reversible in a way that breaking every host is not.
""" """
from __future__ import annotations from __future__ import annotations
@@ -54,6 +53,7 @@ import json
import logging import logging
import os import os
import shutil import shutil
import select
import signal import signal
import subprocess import subprocess
import sys import sys
@@ -81,10 +81,8 @@ logger = logging.getLogger(__name__)
MODE_ENFORCING = "enforcing" MODE_ENFORCING = "enforcing"
MODE_REPORT_ONLY = "report_only" MODE_REPORT_ONLY = "report_only"
#: Ship report-only; see the module docstring for why this is the reversible #: Required containment must be established before model-controlled code runs.
#: direction. Flip to MODE_ENFORCING to make an unestablishable required CONTAINMENT_MODE = MODE_ENFORCING
#: dimension refuse the command instead of reporting on it.
CONTAINMENT_MODE = MODE_REPORT_ONLY
# ── Dimensions ────────────────────────────────────────────────────────────── # ── Dimensions ──────────────────────────────────────────────────────────────
@@ -296,7 +294,23 @@ class Mechanism:
def _bwrap_available() -> bool: def _bwrap_available() -> bool:
return not IS_WINDOWS and bool(shutil.which("bwrap")) if IS_WINDOWS:
return False
executable = shutil.which("bwrap")
if not executable:
return False
try:
# Binary installation says nothing about namespace permissions (notably
# under Docker's normal security profile). Only trusted probe code runs.
probe = subprocess.run(
[executable, "--die-with-parent", "--unshare-pid", "--ro-bind", "/", "/",
"--proc", "/proc", "--dev", "/dev", "/bin/true"],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=3, check=False,
)
return probe.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def _posix_group_available() -> bool: def _posix_group_available() -> bool:
@@ -353,6 +367,7 @@ def _rlimit_dimensions(spec: ContainmentSpec) -> set[str]:
provided.add(MEMORY) provided.add(MEMORY)
if ( if (
spec.max_processes is not None spec.max_processes is not None
and os.geteuid() != 0 # RLIMIT_NPROC does not limit root.
and _rlimit_fits("RLIMIT_NPROC", spec.max_processes) and _rlimit_fits("RLIMIT_NPROC", spec.max_processes)
): ):
provided.add(PROCESS_COUNT) provided.add(PROCESS_COUNT)
@@ -361,7 +376,7 @@ def _rlimit_dimensions(spec: ContainmentSpec) -> set[str]:
def _bwrap_provides(spec: ContainmentSpec) -> frozenset[str]: def _bwrap_provides(spec: ContainmentSpec) -> frozenset[str]:
# bwrap gives the private root and the workspace bind (filesystem), a new # bwrap gives the private root and the workspace bind (filesystem), a new
# session plus --die-with-parent (process_tree), and --unshare-net when the # PID namespace plus --die-with-parent (process_tree), and --unshare-net when the
# spec asked for no network. The wall clock and the resource limits are # spec asked for no network. The wall clock and the resource limits are
# ours either way, applied to the bwrap process itself so its descendants # ours either way, applied to the bwrap process itself so its descendants
# inherit them. # inherit them.
@@ -372,16 +387,15 @@ def _bwrap_provides(spec: ContainmentSpec) -> frozenset[str]:
def _posix_group_provides(spec: ContainmentSpec) -> frozenset[str]: def _posix_group_provides(spec: ContainmentSpec) -> frozenset[str]:
# A process group plus setsid makes the kill authoritative and the wall # Groups support escalating teardown, but a descendant can call setsid()
# clock real for the whole tree. It says nothing about the filesystem: a # and escape. They cannot truthfully establish process-tree containment.
# cwd is not a boundary. return frozenset({WALL_CLOCK} | _rlimit_dimensions(spec))
return frozenset({PROCESS_TREE, WALL_CLOCK} | _rlimit_dimensions(spec))
def _windows_provides(spec: ContainmentSpec) -> frozenset[str]: def _windows_provides(spec: ContainmentSpec) -> frozenset[str]:
# taskkill /T /F walks the child tree, which is the Windows equivalent of # taskkill supports teardown, but is not a Job Object preventing escaped
# signalling a group. There is no setrlimit and no namespace. # descendants. No filesystem or process-tree containment is established.
return frozenset({PROCESS_TREE, WALL_CLOCK}) return frozenset({WALL_CLOCK})
#: Strongest first. Selection walks this in order and stops at the first #: Strongest first. Selection walks this in order and stops at the first
@@ -447,7 +461,7 @@ def _write_record(grant: ContainmentGrant) -> None:
"id": grant.id, "id": grant.id,
"owner": grant.owner, "owner": grant.owner,
"manager_pid": os.getpid(), "manager_pid": os.getpid(),
"manager_token": process_ownership.start_token(os.getpid()), "manager_token": process_ownership.capture(os.getpid())["start_token"],
"mechanism": grant.mechanism, "mechanism": grant.mechanism,
"mode": grant.mode, "mode": grant.mode,
"workspace": grant.workspace, "workspace": grant.workspace,
@@ -784,13 +798,16 @@ def unavailable_tool_result(exc: ContainmentUnavailable, *, tool: str) -> dict[s
# ── Launch plumbing ───────────────────────────────────────────────────────── # ── Launch plumbing ─────────────────────────────────────────────────────────
def _dir_chain(path: str) -> list[str]: def _dir_chain(path: str, mounted: tuple[str, ...] = ()) -> list[str]:
"""``--dir`` args for every ancestor of ``path`` inside the private root. """``--dir`` args for every ancestor of ``path`` inside the private root.
bwrap mounts into a tmpfs root, so the destination's parents have to exist bwrap mounts into a tmpfs root, so the destination's parents have to exist
before the bind. Stops at the mount points the argv already creates. before the bind. Stops at the mount points the argv already creates.
""" """
args: list[str] = [] args: list[str] = []
roots = ("/usr", "/etc", *mounted)
if any(path == root or path.startswith(root + os.sep) for root in roots):
return args
parents: list[str] = [] parents: list[str] = []
parent = os.path.dirname(path) parent = os.path.dirname(path)
while parent not in ("/", "", "/tmp", "/etc", "/usr", WORKSPACE_MOUNT): while parent not in ("/", "", "/tmp", "/etc", "/usr", WORKSPACE_MOUNT):
@@ -811,7 +828,7 @@ def _bwrap_prefix(spec: ContainmentSpec) -> list[str]:
workspace is named by the spec, as ``readonly_extra`` or ``writable_extra``. workspace is named by the spec, as ``readonly_extra`` or ``writable_extra``.
""" """
args = [ args = [
"bwrap", "--die-with-parent", "--new-session", "bwrap", "--die-with-parent", "--new-session", "--unshare-pid",
"--tmpfs", "/", "--tmpfs", "/",
"--dir", "/usr", "--ro-bind", "/usr", "/usr", "--dir", "/usr", "--ro-bind", "/usr", "/usr",
"--symlink", "usr/bin", "/bin", "--symlink", "usr/bin", "/bin",
@@ -820,21 +837,22 @@ def _bwrap_prefix(spec: ContainmentSpec) -> list[str]:
"--symlink", "usr/bin", "/sbin", "--symlink", "usr/bin", "/sbin",
"--dir", "/etc", "--ro-bind", "/etc", "/etc", "--dir", "/etc", "--ro-bind", "/etc", "/etc",
"--dir", "/tmp", "--tmpfs", "/tmp", "--dir", "/tmp", "--tmpfs", "/tmp",
"--dev-bind", "/dev", "/dev", "--proc", "/proc", "--dev", "/dev", "--proc", "/proc",
"--dir", WORKSPACE_MOUNT, "--bind", spec.workspace, WORKSPACE_MOUNT,
] ]
mounted: tuple[str, ...] = ()
for flag, paths in (("--ro-bind", spec.readonly_extra), ("--bind", spec.writable_extra)):
for path in sorted(paths, key=lambda value: (value.count(os.sep), value)):
args.extend(_dir_chain(path, mounted))
args.extend((flag, path, path))
mounted += (path,)
# Mount workspace last so a read-only ancestor never hides its writable bind.
args.extend(("--dir", WORKSPACE_MOUNT, "--bind", spec.workspace, WORKSPACE_MOUNT))
# Preserve absolute workspace paths in generated scripts without exposing # Preserve absolute workspace paths in generated scripts without exposing
# a writable parent directory. # a writable parent directory.
workspace = os.path.realpath(spec.workspace) workspace = os.path.realpath(spec.workspace)
if workspace not in _RESERVED_BIND_DESTS and workspace not in {"/usr", "/etc"}: if workspace not in _RESERVED_BIND_DESTS and workspace not in {"/usr", "/etc"}:
args.extend(_dir_chain(workspace)) args.extend(_dir_chain(workspace, mounted))
args.extend(("--bind", workspace, workspace)) args.extend(("--bind", workspace, workspace))
for path in spec.readonly_extra:
args.extend(_dir_chain(path))
args.extend(("--ro-bind", path, path))
for path in spec.writable_extra:
args.extend(_dir_chain(path))
args.extend(("--bind", path, path))
if spec.network == NETWORK_NONE: if spec.network == NETWORK_NONE:
args.append("--unshare-net") args.append("--unshare-net")
args.extend(("--chdir", WORKSPACE_MOUNT)) args.extend(("--chdir", WORKSPACE_MOUNT))
@@ -871,7 +889,8 @@ def _rlimit_preexec(grant: ContainmentGrant) -> Optional[Callable[[], None]]:
return _apply return _apply
def _launch_argv(grant: ContainmentGrant, command: Any, *, argv: bool) -> list[str]: def _launch_argv(grant: ContainmentGrant, command: Any, *, argv: bool,
ready_marker: Optional[str] = None) -> list[str]:
spec = grant.spec spec = grant.spec
if argv: if argv:
parts = [str(part) for part in command] parts = [str(part) for part in command]
@@ -894,6 +913,13 @@ def _launch_argv(grant: ContainmentGrant, command: Any, *, argv: bool) -> list[s
) )
parts = [shell, "-c", text] parts = [shell, "-c", text]
if grant.mechanism == "bubblewrap": if grant.mechanism == "bubblewrap":
if ready_marker is not None:
# This trusted wrapper runs only after all bwrap setup succeeds.
# A launch-time bind/security failure must not claim containment.
parts = ["/bin/sh", "-c",
'printf "%s\\n" "$1"; IFS= read -r ody_ack || exit 125; '
'[ "$ody_ack" = "$1" ] || exit 125; shift; exec "$@"',
"ody-boundary", ready_marker, *parts]
return _bwrap_prefix(spec) + parts return _bwrap_prefix(spec) + parts
return parts return parts
@@ -905,8 +931,8 @@ def _spawn_kwargs(grant: ContainmentGrant) -> dict[str, Any]:
# reach it, and teardown walks the tree with taskkill /T. # reach it, and teardown walks the tree with taskkill /T.
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
return kwargs return kwargs
# setsid is what makes PROCESS_TREE real: without it a timeout kill reaches # A separate group makes ordinary tree teardown possible. The PID
# the wrapper shell and nothing it backgrounded. # namespace, not setsid, prevents descendants from escaping containment.
kwargs["start_new_session"] = True kwargs["start_new_session"] = True
preexec = _rlimit_preexec(grant) preexec = _rlimit_preexec(grant)
if preexec is not None: if preexec is not None:
@@ -978,61 +1004,101 @@ async def run(
"backend does not own; it cannot be run locally" "backend does not own; it cannot be run locally"
) )
missing = frozenset(grant.spec.required) - frozenset(grant.enforced) missing = frozenset(grant.spec.required) - frozenset(grant.enforced)
if missing and grant.mode == MODE_ENFORCING: mechanism = next((item for item in MECHANISMS if item.name == grant.mechanism), None)
raise ContainmentUnavailable(missing, grant.mechanism) provided = mechanism.provides(grant.spec) if mechanism is not None else frozenset()
missing |= frozenset(grant.spec.required) - provided
overclaimed = frozenset(grant.enforced) - provided
if (missing or overclaimed) and grant.mode == MODE_ENFORCING:
raise ContainmentUnavailable(missing | overclaimed, grant.mechanism)
spec = grant.spec spec = grant.spec
launch = _launch_argv(grant, command, argv=argv) marker = uuid.uuid4().hex if grant.mechanism == "bubblewrap" else None
try: try:
proc = await asyncio.create_subprocess_exec( launch = _launch_argv(grant, command, argv=argv, ready_marker=marker)
spawning = asyncio.create_task(asyncio.create_subprocess_exec(
*launch, *launch,
stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL, stdin=asyncio.subprocess.PIPE if stdin is not None or marker is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=spec.workspace, cwd=spec.workspace,
env=dict(spec.env), env=dict(spec.env),
**_spawn_kwargs(grant), **_spawn_kwargs(grant),
) ))
try:
proc = await asyncio.shield(spawning)
except asyncio.CancelledError:
# Cancellation must not detach an OS spawn already in progress.
# Recover its handle before propagating cancellation to the caller.
try:
proc = await _complete_cleanup(spawning, propagate_cancel=False)
except Exception:
release(grant, grace_s=0)
else:
live = replace(grant, pid=proc.pid, pgid=None if IS_WINDOWS else proc.pid)
await _complete_cleanup(_release_awaited(live, proc), propagate_cancel=False)
raise
except BaseException: except BaseException:
# Acquisition can precede a failed or cancelled spawn. A grant without if "proc" not in locals():
# a child must not become a permanent restart orphan. release(grant, grace_s=0)
release(grant, grace_s=0)
raise raise
from src.agent_runtime.journal import mark_operation_started
mark_operation_started("subprocess", pid=proc.pid)
# start_new_session makes the child its own group leader, so the group id # start_new_session makes the child its own group leader, so the group id
# is the child's pid. Captured here rather than at teardown: once the leader # is the child's pid. Captured here rather than at teardown: once the leader
# exits, getpgid can no longer tell us which group its children are in. # exits, getpgid can no longer tell us which group its children are in.
pgid = None if IS_WINDOWS else (_pgid_of(proc.pid) or proc.pid) pgid = None if IS_WINDOWS else proc.pid
live = replace(grant, pid=proc.pid, pgid=pgid) live = replace(grant, pid=proc.pid, pgid=pgid)
# The start token is what makes this record signallable by a *later* # The start token is what makes this record signallable by a *later*
# process. Without it a restart reaper holds a pid and no way to tell # process. Without it a restart reaper holds a pid and no way to tell
# whether the pid is still this child or something the kernel has since # whether the pid is still this child or something the kernel has since
# handed to a stranger; see src/process_ownership.py. # handed to a stranger; see src/process_ownership.py.
_update_record( try:
grant.id, _update_record(grant.id, pid=proc.pid, pgid=pgid, started_at=time.time(),
pid=proc.pid, start_token=None if marker is not None else process_ownership.capture(proc.pid)["start_token"],
pgid=pgid, containment_ready=marker is None, execution_started=marker is None)
started_at=time.time(), from src.agent_runtime.journal import mark_operation_started
start_token=process_ownership.capture(proc.pid)["start_token"], mark_operation_started("subprocess", pid=proc.pid)
) except BaseException:
await _complete_cleanup(_release_awaited(live, proc), propagate_cancel=False)
raise
out_buf: list[str] = [] out_buf: list[str] = []
err_buf: list[str] = [] err_buf: list[str] = []
out_budget = [int(spec.max_output_bytes)] out_budget = [int(spec.max_output_bytes)]
err_budget = [int(spec.max_output_bytes)] err_budget = [int(spec.max_output_bytes)]
started = time.time() started = time.time()
readers = [ readers = [asyncio.create_task(_drain(proc.stderr, err_buf, err_budget, output_cb))]
asyncio.create_task(_drain(proc.stdout, out_buf, out_budget, output_cb)), ready = marker is None
asyncio.create_task(_drain(proc.stderr, err_buf, err_budget, output_cb)), execution_started = marker is None
]
async def _wait() -> None: async def _wait() -> None:
nonlocal ready, execution_started
if marker is not None:
expected = (marker + "\n").encode("ascii")
try:
receipt = await proc.stdout.readexactly(len(expected))
except (asyncio.IncompleteReadError, OSError):
receipt = b""
if receipt != expected:
raise ContainmentUnavailable(spec.required, grant.mechanism)
# The trusted child is waiting for acknowledgment, so model code
# cannot exit/recycle the leader before we record its identity.
_update_record(grant.id, start_token=process_ownership.capture(proc.pid)["start_token"])
if hasattr(os, "pidfd_open") and hasattr(signal, "pidfd_send_signal"):
try:
proc._ody_pidfd = os.pidfd_open(proc.pid)
except OSError:
raise ContainmentUnavailable(spec.required, grant.mechanism) from None
ready = True
_update_record(grant.id, containment_ready=True, execution_started=True)
execution_started = True
proc.stdin.write(expected)
await proc.stdin.drain()
readers.append(asyncio.create_task(_drain(proc.stdout, out_buf, out_budget, output_cb)))
# Pipe backpressure is execution time too. Feeding a child that never # Pipe backpressure is execution time too. Feeding a child that never
# reads stdin must remain inside the same timeout/cancellation scope. # reads stdin must remain inside the same timeout/cancellation scope.
if stdin is not None and proc.stdin is not None: if (stdin is not None or marker is not None) and proc.stdin is not None:
try: try:
proc.stdin.write(stdin) if stdin is not None:
await proc.stdin.drain() proc.stdin.write(stdin)
await proc.stdin.drain()
except (BrokenPipeError, ConnectionResetError): except (BrokenPipeError, ConnectionResetError):
pass pass
finally: finally:
@@ -1052,34 +1118,42 @@ async def run(
progress_task = asyncio.create_task(_progress()) if progress_cb else None progress_task = asyncio.create_task(_progress()) if progress_cb else None
timed_out = False timed_out = False
outcome: Optional[ReleaseOutcome] = None outcome: Optional[ReleaseOutcome] = None
async def _finish() -> ReleaseOutcome:
try:
return await _release_awaited(live, proc)
finally:
if progress_task is not None:
progress_task.cancel()
try:
await progress_task
except (asyncio.CancelledError, Exception):
pass
for task in readers:
try:
await asyncio.wait_for(asyncio.shield(task), timeout=1)
except (asyncio.TimeoutError, Exception):
out_budget[0] = -1
task.cancel()
await asyncio.gather(task, return_exceptions=True)
pidfd = getattr(proc, "_ody_pidfd", None)
if pidfd is not None:
os.close(pidfd)
try: try:
try: try:
await asyncio.wait_for(_wait(), timeout=spec.wall_clock_s) await asyncio.wait_for(_wait(), timeout=spec.wall_clock_s)
except asyncio.TimeoutError: except asyncio.TimeoutError:
if not ready:
raise ContainmentUnavailable(spec.required, grant.mechanism)
timed_out = True timed_out = True
outcome = await _release_awaited(live, proc) except BaseException as exc:
except asyncio.CancelledError: exc.containment_established = ready
await _release_awaited(live, proc) exc.containment_executed = execution_started
raise raise
finally: finally:
if progress_task is not None: # Clean exit, partial initialization, timeout, and cancellation share
progress_task.cancel() # the same teardown. Repeated cancellation cannot skip escalation.
try: outcome = await _complete_cleanup(_finish())
await progress_task if timed_out:
except (asyncio.CancelledError, Exception):
pass
for task in readers:
try:
await asyncio.wait_for(task, timeout=1)
except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
task.cancel()
if not timed_out:
# Even a clean exit goes through teardown: a command that backgrounded
# something leaves the group populated, and leaving it running is the
# leak this boundary exists to close.
outcome = await _release_awaited(live, proc)
else:
_update_record(grant.id, timed_out=True) _update_record(grant.id, timed_out=True)
return ContainmentResult( return ContainmentResult(
@@ -1093,6 +1167,23 @@ async def run(
) )
async def _complete_cleanup(awaitable, *, propagate_cancel: bool = True):
"""Finish ownership cleanup despite further cancellation, then propagate it."""
task = asyncio.ensure_future(awaitable)
cancelled = False
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
if task.cancelled():
raise
cancelled = True
result = task.result()
if cancelled and propagate_cancel:
raise asyncio.CancelledError
return result
# ── release ───────────────────────────────────────────────────────────────── # ── release ─────────────────────────────────────────────────────────────────
# core.platform_compat.kill_process_tree delegates here as well. Native tools, # core.platform_compat.kill_process_tree delegates here as well. Native tools,
# detached jobs and compatibility callers share escalation and death probes. # detached jobs and compatibility callers share escalation and death probes.
@@ -1429,32 +1520,63 @@ async def _release_awaited(
has entirely exited — turning every timeout into a false "survivors" has entirely exited — turning every timeout into a false "survivors"
report. report.
""" """
writer = getattr(proc, "stdin", None)
if writer is not None:
writer.close()
if hasattr(writer, "wait_closed"):
try:
await asyncio.wait_for(writer.wait_closed(), timeout=1)
except (OSError, asyncio.TimeoutError):
pass
if IS_WINDOWS: if IS_WINDOWS:
return release(grant, grace_s=grace_s) return release(grant, grace_s=grace_s)
pid, pgid = grant.pid, grant.pgid pid, pgid = grant.pid, grant.pgid
_signal_tree(pid, pgid, signal.SIGTERM) if grant.mechanism == "bubblewrap" and proc.returncode is not None:
# Namespace-owner death already destroyed the namespace. Its numeric
# PID/PGID may now be reused; no further signal is necessary or safe.
await proc.wait()
outcome = _outcome_for(grant, dead=True, escalated=False)
_finish_release(grant, outcome)
return outcome
pidfd = getattr(proc, "_ody_pidfd", None)
def gone():
if pidfd is not None:
return bool(select.select([pidfd], [], [], 0)[0])
return _tree_gone(pid, pgid)
def send(sig):
if pidfd is not None:
try:
# Killing the bwrap owner destroys its private PID namespace,
# including descendants that changed session/group. A pidfd
# keeps a recycled numeric PID out of the signal path.
signal.pidfd_send_signal(pidfd, sig)
except OSError:
pass
else:
_signal_tree(pid, pgid, sig)
send(signal.SIGTERM)
try: try:
await asyncio.wait_for(proc.wait(), timeout=max(grace_s, 0.05)) await asyncio.wait_for(proc.wait(), timeout=max(grace_s, 0.05))
except (asyncio.TimeoutError, ProcessLookupError): except (asyncio.TimeoutError, ProcessLookupError):
pass pass
deadline = time.monotonic() + max(grace_s, 0.0) deadline = time.monotonic() + max(grace_s, 0.0)
while time.monotonic() < deadline and not _tree_gone(pid, pgid): while time.monotonic() < deadline and not gone():
await asyncio.sleep(_DEATH_POLL_S) await asyncio.sleep(_DEATH_POLL_S)
escalated = False escalated = False
if not _tree_gone(pid, pgid): if not gone():
escalated = True escalated = True
_signal_tree(pid, pgid, signal.SIGKILL) send(signal.SIGKILL)
try: try:
await asyncio.wait_for(proc.wait(), timeout=1.0) await asyncio.wait_for(proc.wait(), timeout=1.0)
except (asyncio.TimeoutError, ProcessLookupError): except (asyncio.TimeoutError, ProcessLookupError):
pass pass
deadline = time.monotonic() + 1.0 deadline = time.monotonic() + 1.0
while time.monotonic() < deadline and not _tree_gone(pid, pgid): while time.monotonic() < deadline and not gone():
await asyncio.sleep(_DEATH_POLL_S) await asyncio.sleep(_DEATH_POLL_S)
outcome = _outcome_for(grant, dead=_tree_gone(pid, pgid), escalated=escalated) outcome = _outcome_for(grant, dead=gone(), escalated=escalated)
_finish_release(grant, outcome) _finish_release(grant, outcome)
return outcome return outcome
+2 -2
View File
@@ -60,8 +60,8 @@ async def supervise(payload: dict) -> None:
output, code = f"background execution failed: {type(exc).__name__}: {exc}\n", 1 output, code = f"background execution failed: {type(exc).__name__}: {exc}\n", 1
report = {"containment": grant.to_dict(), "teardown": record.get("release") or {"dead": False}, report = {"containment": grant.to_dict(), "teardown": record.get("release") or {"dead": False},
"output_truncated": False} "output_truncated": False}
report["containment"]["executed"] = bool(record.get("pid")) report["containment"]["executed"] = bool(record.get("execution_started"))
if not record.get("pid"): if not record.get("containment_ready"):
report["containment"].update(contained=False, enforced=[]) report["containment"].update(contained=False, enforced=[])
if isinstance(exc, containment.ContainmentUnavailable): if isinstance(exc, containment.ContainmentUnavailable):
report.update(containment.unavailable_tool_result(exc, tool="bash")) report.update(containment.unavailable_tool_result(exc, tool="bash"))
+11 -2
View File
@@ -39,7 +39,7 @@ Token granularity, stated because it bounds the guarantee
======== ============================= =============== ======== ============================= ===============
Host Source Resolution Host Source Resolution
======== ============================= =============== ======== ============================= ===============
Linux ``/proc/<pid>/stat`` field 22 ~10 ms (1 tick) Linux boot ID + stat field 22 ~10 ms (1 tick)
macOS ``ps -o lstart=`` 1 s macOS ``ps -o lstart=`` 1 s
Windows ``GetProcessTimes`` 100 ns Windows ``GetProcessTimes`` 100 ns
======== ============================= =============== ======== ============================= ===============
@@ -145,9 +145,18 @@ def _procfs_token(pid: int) -> Optional[str]:
_, _, rest = raw.rpartition(")") _, _, rest = raw.rpartition(")")
fields = rest.split() fields = rest.split()
try: try:
return f"procfs:{fields[_PROC_STAT_STARTTIME_INDEX]}" ticks = fields[_PROC_STAT_STARTTIME_INDEX]
except IndexError: except IndexError:
raise InspectionUnavailable(f"/proc/{pid}/stat (unexpected layout)") from None raise InspectionUnavailable(f"/proc/{pid}/stat (unexpected layout)") from None
try:
boot = (PROC_ROOT / "sys/kernel/random/boot_id").read_text(encoding="ascii").strip()
except OSError as exc:
raise InspectionUnavailable(f"boot identity ({exc})") from exc
if not boot:
raise InspectionUnavailable("boot identity (empty)")
# A persisted PID/start-tick pair can recur after reboot. Bind it to the
# boot as well; older receipts cannot authorize a signal on a new boot.
return f"procfs:{boot}:{ticks}"
def _ps_token(pid: int) -> Optional[str]: def _ps_token(pid: int) -> Optional[str]:
+5 -2
View File
@@ -232,15 +232,18 @@ def reap_legacy_agent_tmux() -> Dict[str, Any]:
if process_ownership.verify(pid, identities[pid]) != process_ownership.OWNED: if process_ownership.verify(pid, identities[pid]) != process_ownership.OWNED:
continue continue
spec = containment.ContainmentSpec(workspace=os.getcwd(), env={}, wall_clock_s=1, spec = containment.ContainmentSpec(workspace=os.getcwd(), env={}, wall_clock_s=1,
required=frozenset({containment.PROCESS_TREE})) required=frozenset())
grant = containment.ContainmentGrant( grant = containment.ContainmentGrant(
id=uuid.uuid4().hex[:12], mechanism="process_group", workspace=spec.workspace, id=uuid.uuid4().hex[:12], mechanism="process_group", workspace=spec.workspace,
enforced=frozenset({containment.PROCESS_TREE}), degraded=(), unenforced_required=(), enforced=frozenset(), degraded=(containment.FILESYSTEM, containment.PROCESS_TREE), unenforced_required=(),
owner=f"legacy-tmux:{session_id}", mode=containment.MODE_ENFORCING, owner=f"legacy-tmux:{session_id}", mode=containment.MODE_ENFORCING,
spec=spec, pid=pid, pgid=containment._pgid_of(pid), spec=spec, pid=pid, pgid=containment._pgid_of(pid),
) )
containment._write_record(grant) containment._write_record(grant)
containment._update_record(grant.id, lifetime="cleanup", start_token=identities[pid]) containment._update_record(grant.id, lifetime="cleanup", start_token=identities[pid])
receipt = containment._load_records().get(grant.id, {})
if receipt.get("start_token") != identities[pid] or receipt.get("lifetime") != "cleanup":
raise RuntimeError("legacy tmux cleanup receipt was not persisted")
tracked.append((grant, identities[pid])) tracked.append((grant, identities[pid]))
dead = True dead = True
for grant, token in tracked: for grant, token in tracked:
+9 -1
View File
@@ -14,18 +14,26 @@ def capture_owned_spawn(monkeypatch, tmp_path):
async def fake_exec(*argv, **kwargs): async def fake_exec(*argv, **kwargs):
captured.update(argv=argv, kwargs=kwargs, command=argv[-1]) captured.update(argv=argv, kwargs=kwargs, command=argv[-1])
stdout = asyncio.StreamReader() stdout = asyncio.StreamReader()
if "ody-boundary" in argv:
stdout.feed_data((argv[argv.index("ody-boundary") + 1] + "\n").encode())
stdout.feed_data(b"ok") stdout.feed_data(b"ok")
stdout.feed_eof() stdout.feed_eof()
stderr = asyncio.StreamReader() stderr = asyncio.StreamReader()
stderr.feed_eof() stderr.feed_eof()
async def wait(): async def wait():
return 0 return 0
async def drain():
return None
writer = SimpleNamespace(write=lambda data: None, drain=drain,
close=lambda: captured.update(stdin_closed=True))
return SimpleNamespace(pid=99999999, stdout=stdout, stderr=stderr, return SimpleNamespace(pid=99999999, stdout=stdout, stderr=stderr,
returncode=0, wait=wait) stdin=writer, returncode=0, wait=wait)
async def release(*args, **kwargs): async def release(*args, **kwargs):
return containment.ReleaseOutcome(dead=True, escalated=False) return containment.ReleaseOutcome(dead=True, escalated=False)
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
if hasattr(containment.os, "pidfd_open"):
monkeypatch.delattr(containment.os, "pidfd_open")
monkeypatch.setattr(containment, "_release_awaited", release) monkeypatch.setattr(containment, "_release_awaited", release)
return captured return captured
+5 -1
View File
@@ -38,7 +38,11 @@ def test_direct_bash_subprocess_has_closed_stdin(monkeypatch, tmp_path):
result = asyncio.run(subprocess_tools.BashTool().execute("echo ok", {})) result = asyncio.run(subprocess_tools.BashTool().execute("echo ok", {}))
assert result["exit_code"] == 0 assert result["exit_code"] == 0
assert captured["kwargs"]["stdin"] is asyncio.subprocess.DEVNULL if "ody-boundary" in captured["argv"]:
assert captured["kwargs"]["stdin"] is asyncio.subprocess.PIPE
assert captured["stdin_closed"] is True
else:
assert captured["kwargs"]["stdin"] is asyncio.subprocess.DEVNULL
assert not (tmp_path / ".tmp").exists() assert not (tmp_path / ".tmp").exists()
+2 -4
View File
@@ -349,10 +349,8 @@ def test_the_two_modes_differ_only_in_whether_the_shortfall_refuses(monkeypatch,
assert frozenset(reported.unenforced_required) == caught.value.missing assert frozenset(reported.unenforced_required) == caught.value.missing
def test_report_only_is_the_shipped_default(): def test_enforcement_is_the_shipped_default():
"""Pinned deliberately: merging this must not change behaviour on a host assert containment.CONTAINMENT_MODE == containment.MODE_ENFORCING
without bubblewrap. Flipping it is a one-line diff, reviewed as one."""
assert containment.CONTAINMENT_MODE == containment.MODE_REPORT_ONLY
# ── Spec validation: caller bugs raise in both modes ──────────────────────── # ── Spec validation: caller bugs raise in both modes ────────────────────────
+276
View File
@@ -0,0 +1,276 @@
"""Final enforcement gate: real namespaces, no fallback, and owned cancellation."""
import asyncio
import os
import subprocess
import sys
from dataclasses import replace
import pytest
from src import containment, tool_execution
from src.agent_tools import subprocess_tools
@pytest.fixture
def workspace(tmp_path, monkeypatch):
path = tmp_path / "workspace"
path.mkdir()
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(path))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
return path
@pytest.fixture
def namespaces(workspace):
if not containment._bwrap_available():
pytest.skip("functional bubblewrap PID/mount namespaces unavailable")
return workspace
def test_installed_but_nonfunctional_bwrap_is_not_available(monkeypatch):
calls = []
monkeypatch.setattr(containment, "IS_WINDOWS", False)
monkeypatch.setattr(containment.shutil, "which", lambda name: "/usr/bin/bwrap")
def blocked(argv, **kwargs):
calls.append((argv, kwargs))
return subprocess.CompletedProcess(argv, 1, b"", b"Operation not permitted")
monkeypatch.setattr(containment.subprocess, "run", blocked)
assert containment._bwrap_available() is False
assert calls[0][0][-1] == "/bin/true"
assert "--unshare-pid" in calls[0][0]
@pytest.mark.parametrize("tool,source", [
(subprocess_tools.BashTool, "echo forbidden"),
(subprocess_tools.PythonTool, "print(1 + 1)"),
])
async def test_shipped_mode_refuses_without_namespaces(tool, source, workspace, monkeypatch):
assert containment.CONTAINMENT_MODE == containment.MODE_ENFORCING
monkeypatch.setattr(containment, "MECHANISMS", tuple(
item for item in containment.MECHANISMS if item.name != "bubblewrap"
))
async def forbidden(*args, **kwargs):
pytest.fail("uncontained model command spawned")
monkeypatch.setattr(asyncio, "create_subprocess_exec", forbidden)
result = await tool().execute(source, {})
assert "containment unavailable" in result["error"]
assert result["containment"]["executed"] is False
assert result["containment"]["contained"] is False
assert {"filesystem", "process_tree"} <= set(result["containment"]["unenforced_required"])
def test_process_groups_and_taskkill_do_not_claim_tree_containment(workspace):
spec = containment.agent_spec(str(workspace), dict(os.environ), 5)
assert containment.PROCESS_TREE not in containment._posix_group_provides(spec)
assert containment.PROCESS_TREE not in containment._windows_provides(spec)
async def test_fully_overclaimed_group_grant_cannot_spawn(workspace, monkeypatch):
spec = containment.agent_spec(str(workspace), {}, 5)
forged = containment.ContainmentGrant(
id="forged-all", mechanism="process_group", workspace=str(workspace),
enforced=containment.DEFAULT_REQUIRED, degraded=(), unenforced_required=(),
owner="test", mode=containment.MODE_ENFORCING, spec=spec,
)
async def forbidden(*args, **kwargs):
pytest.fail("overclaimed grant reached a host spawn")
monkeypatch.setattr(asyncio, "create_subprocess_exec", forbidden)
with pytest.raises(containment.ContainmentUnavailable):
await containment.run(forged, "echo forbidden")
def test_home_interpreter_is_bound_read_only(workspace, monkeypatch):
monkeypatch.setattr(sys, "prefix", "/home/test/venv")
spec = subprocess_tools._owned_spec(str(workspace), {}, 5)
assert "/home/test/venv" in spec.readonly_extra
argv = containment._bwrap_prefix(spec)
index = argv.index("/home/test/venv")
assert argv[index - 1] == "--ro-bind"
assert "--unshare-pid" in argv
assert "--dev-bind" not in argv
assert "--unshare-net" not in argv
async def test_actual_namespace_hides_host_pid_tree_and_sibling(namespaces):
sibling = namespaces.parent / "host-secret.txt"
sibling.write_text("original")
host_namespace = os.readlink("/proc/self/ns/pid")
result = await subprocess_tools.PythonTool().execute(
"import os\n"
"print(os.readlink('/proc/self/ns/pid'))\n"
f"print(os.path.exists({str(sibling)!r}))\n"
f"try:\n open({str(sibling)!r}, 'w').write('changed')\n"
"except OSError:\n pass\n", {},
)
assert result["exit_code"] == 0, result
lines = result["output"].splitlines()
assert lines[0] != host_namespace
assert lines[1] == "False"
assert sibling.read_text() == "original"
assert result["containment"]["contained"] is True
assert result["teardown"]["dead"] is True
async def test_default_namespace_preserves_loopback_sidecars(namespaces):
async def reply(reader, writer):
writer.write(b"sidecar\n")
await writer.drain()
writer.close()
await writer.wait_closed()
server = await asyncio.start_server(reply, "127.0.0.1", 0)
async with server:
port = server.sockets[0].getsockname()[1]
result = await subprocess_tools.PythonTool().execute(
f"import socket\ns=socket.create_connection(('127.0.0.1', {port}), timeout=2)\n"
"print(s.recv(100).decode().strip())\ns.close()", {},
)
assert result["output"] == "sidecar", result
assert result["containment"]["network"] == "inherit"
assert "network" not in result["containment"]["enforced"]
async def test_namespace_handshake_closes_model_stdin(namespaces):
result = await subprocess_tools.BashTool().execute(
"if read value; then echo unexpected; else echo closed; fi", {},
)
assert result["output"] == "closed", result
assert result["teardown"]["dead"] is True
async def test_partial_initialization_reaps_before_model_code_starts(namespaces, monkeypatch):
from src.agent_runtime import journal
effect = namespaces / "must-not-exist"
def fail(*args, **kwargs):
raise RuntimeError("initialization failed")
monkeypatch.setattr(journal, "mark_operation_started", fail)
result = await subprocess_tools.PythonTool().execute(
f"open({str(effect)!r}, 'w').write('effect')", {},
)
assert result["exit_code"] == 1
assert result["containment"]["executed"] is False
assert result["containment"]["contained"] is False
assert not effect.exists()
assert containment.active_grants() == []
async def test_explicit_network_isolation_is_established_or_refused(namespaces):
spec = replace(subprocess_tools._owned_spec(str(namespaces), dict(os.environ), 5),
network=containment.NETWORK_NONE,
required=containment.DEFAULT_REQUIRED | {containment.NETWORK})
host_namespace = os.readlink("/proc/self/ns/net")
grant = containment.acquire(spec, owner="network-hook")
try:
result = await containment.run(grant, [sys.executable, "-c",
"import os; print(os.readlink('/proc/self/ns/net'))"], argv=True)
except containment.ContainmentUnavailable:
assert containment.active_grants() == []
else:
assert result.exit_code == 0
assert result.stdout.strip() != host_namespace
assert containment.NETWORK in result.grant.enforced
assert result.release.dead
@pytest.mark.parametrize("exit_parent", [False, True])
async def test_setsid_daemon_cannot_survive_namespace_death(namespaces, exit_parent):
heartbeat = namespaces / "heartbeat"
code = (
"import os,signal,time\n"
"pid=os.fork()\n"
"if pid:\n"
+ (" time.sleep(.2); os._exit(0)\n" if exit_parent else " time.sleep(60); os._exit(0)\n")
+ "os.setsid()\nsignal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
"while True:\n"
f" open({str(heartbeat)!r}, 'w').write(str(time.monotonic_ns()))\n"
" time.sleep(.01)\n"
)
spec = subprocess_tools._owned_spec(str(namespaces), dict(os.environ), 1)
result = await containment.run(containment.acquire(spec, owner="setsid"),
[sys.executable, "-c", code], argv=True)
assert heartbeat.exists(), result.stderr
assert result.timed_out is (not exit_parent)
assert result.release.dead is True
last = heartbeat.read_text()
await asyncio.sleep(.15)
assert heartbeat.read_text() == last
assert containment.active_grants() == []
async def test_failed_namespace_initialization_does_not_claim_containment(workspace, monkeypatch):
from types import SimpleNamespace
monkeypatch.setattr(containment, "MECHANISMS", (containment.Mechanism(
"bubblewrap", 30, lambda: True, lambda spec: containment.DEFAULT_REQUIRED,
),))
async def fail(*args, **kwargs):
stdout, stderr = asyncio.StreamReader(), asyncio.StreamReader()
stdout.feed_eof()
stderr.feed_data(b"bwrap: bind failed\n")
stderr.feed_eof()
async def wait():
return 1
return SimpleNamespace(pid=99999999, stdout=stdout, stderr=stderr, returncode=1, wait=wait)
async def release(grant, proc, **kwargs):
outcome = containment.ReleaseOutcome(dead=True, escalated=False)
containment._finish_release(grant, outcome)
return outcome
monkeypatch.setattr(asyncio, "create_subprocess_exec", fail)
monkeypatch.setattr(containment, "_release_awaited", release)
result = await subprocess_tools.PythonTool().execute("print('never')", {})
assert "containment unavailable" in result["error"]
assert result["containment"]["executed"] is False
assert result["containment"]["enforced"] == []
assert containment.active_grants() == []
@pytest.mark.skipif(os.name == "nt", reason="real POSIX process")
async def test_cancellation_during_spawn_recovers_and_reaps_handle(workspace, monkeypatch):
monkeypatch.setattr(containment, "MECHANISMS", tuple(
item for item in containment.MECHANISMS if item.name == "process_group"
))
real_spawn = asyncio.create_subprocess_exec
spawned, return_handle = asyncio.Event(), asyncio.Event()
children = []
async def delayed_spawn(*args, **kwargs):
proc = await real_spawn(*args, **kwargs)
children.append(proc)
spawned.set()
await return_handle.wait()
return proc
monkeypatch.setattr(asyncio, "create_subprocess_exec", delayed_spawn)
spec = containment.ContainmentSpec(str(workspace), dict(os.environ), 10,
required={containment.WALL_CLOCK})
task = asyncio.create_task(containment.run(containment.acquire(spec, owner="spawn-cancel"),
[sys.executable, "-c", "import time; time.sleep(60)"], argv=True))
await asyncio.wait_for(spawned.wait(), 3)
task.cancel()
await asyncio.sleep(.01)
task.cancel()
return_handle.set()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, 8)
assert children[0].returncode is not None
assert containment.active_grants() == []
@pytest.mark.skipif(os.name == "nt", reason="real POSIX process")
async def test_repeated_cancellation_cannot_interrupt_kill_escalation(workspace, monkeypatch):
monkeypatch.setattr(containment, "MECHANISMS", tuple(
item for item in containment.MECHANISMS if item.name == "process_group"
))
ready = workspace / "ready"
spec = containment.ContainmentSpec(str(workspace), dict(os.environ), 10,
required={containment.WALL_CLOCK})
task = asyncio.create_task(containment.run(containment.acquire(spec, owner="cancel"),
[sys.executable, "-c", "import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); "
f"open({str(ready)!r},'w').write('ready'); time.sleep(60)"], argv=True))
for _ in range(100):
if ready.exists():
break
await asyncio.sleep(.02)
assert ready.exists()
task.cancel()
await asyncio.sleep(.1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, 8)
assert containment.active_grants() == []
+5 -5
View File
@@ -56,12 +56,12 @@ def workspace(tmp_path):
def tree_spec(workspace, **kwargs): def tree_spec(workspace, **kwargs):
"""A spec requiring exactly what a process group can give.""" """Exercise group teardown without claiming prevention of session escape."""
kwargs.setdefault("env", {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}) kwargs.setdefault("env", {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"})
kwargs.setdefault("wall_clock_s", 1) kwargs.setdefault("wall_clock_s", 1)
return containment.ContainmentSpec( return containment.ContainmentSpec(
workspace=workspace, workspace=workspace,
required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}), required=frozenset({containment.WALL_CLOCK}),
**kwargs, **kwargs,
) )
@@ -312,7 +312,7 @@ async def test_a_process_count_limit_is_applied_to_the_child(workspace):
env={"PATH": "/usr/bin:/bin"}, env={"PATH": "/usr/bin:/bin"},
wall_clock_s=10, wall_clock_s=10,
required=frozenset({ required=frozenset({
containment.PROCESS_TREE, containment.WALL_CLOCK, containment.PROCESS_COUNT, containment.WALL_CLOCK, containment.PROCESS_COUNT,
}), }),
max_processes=64, max_processes=64,
) )
@@ -341,7 +341,7 @@ async def test_a_memory_limit_is_claimed_only_where_it_can_be_applied(workspace)
workspace=workspace, workspace=workspace,
env={"PATH": "/usr/bin:/bin"}, env={"PATH": "/usr/bin:/bin"},
wall_clock_s=10, wall_clock_s=10,
required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}), required=frozenset({containment.WALL_CLOCK}),
max_memory_bytes=limit, max_memory_bytes=limit,
) )
grant = containment.acquire(spec, owner="session-9") grant = containment.acquire(spec, owner="session-9")
@@ -369,7 +369,7 @@ def test_an_unenforceable_required_limit_refuses_instead_of_crashing_the_spawn(w
env={"PATH": "/usr/bin:/bin"}, env={"PATH": "/usr/bin:/bin"},
wall_clock_s=10, wall_clock_s=10,
required=frozenset({ required=frozenset({
containment.PROCESS_TREE, containment.WALL_CLOCK, containment.MEMORY, containment.WALL_CLOCK, containment.MEMORY,
}), }),
max_memory_bytes=2 * 1024 * 1024 * 1024, max_memory_bytes=2 * 1024 * 1024 * 1024,
) )
@@ -53,13 +53,16 @@ def _argv(workspace, **kwargs):
import shutil as _shutil import shutil as _shutil
original = _shutil.which original = _shutil.which
original_available = containment._bwrap_available
try: try:
containment._bwrap_available = lambda: True
_shutil.which = lambda name, *a, **kw: ( _shutil.which = lambda name, *a, **kw: (
"/usr/bin/bwrap" if name == "bwrap" else original(name, *a, **kw) "/usr/bin/bwrap" if name == "bwrap" else original(name, *a, **kw)
) )
wrapped = subprocess_tools._wrap_workspace_namespace("true", workspace, **kwargs) wrapped = subprocess_tools._wrap_workspace_namespace("true", workspace, **kwargs)
finally: finally:
_shutil.which = original _shutil.which = original
containment._bwrap_available = original_available
assert wrapped is not None, "forced bwrap should produce a namespace argv" assert wrapped is not None, "forced bwrap should produce a namespace argv"
return shlex.split(wrapped) return shlex.split(wrapped)
@@ -174,6 +177,7 @@ def test_fallback_reports_that_filesystem_containment_did_not_hold(
def test_the_fallback_mechanism_is_not_named_like_a_mechanism(workspace, monkeypatch): def test_the_fallback_mechanism_is_not_named_like_a_mechanism(workspace, monkeypatch):
"""A string rewrite reported as "bubblewrap" or "none" is the same silence """A string rewrite reported as "bubblewrap" or "none" is the same silence
with extra steps. It gets its own name so a reader cannot mistake it.""" with extra steps. It gets its own name so a reader cannot mistake it."""
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr( monkeypatch.setattr(
subprocess_tools, "_wrap_workspace_namespace", subprocess_tools, "_wrap_workspace_namespace",
lambda *args, **kwargs: None, lambda *args, **kwargs: None,
+3 -3
View File
@@ -29,8 +29,8 @@ async def test_native_bash_owns_and_releases_its_child(native_boundary):
assert result["output"] == "captured" assert result["output"] == "captured"
assert result["exit_code"] == 0 assert result["exit_code"] == 0
assert result["teardown"]["dead"] is True assert result["teardown"]["dead"] is True
assert result["containment"]["enforced"] == ["process_tree", "wall_clock"] assert result["containment"]["enforced"] == ["wall_clock"]
assert result["containment"]["unenforced_required"] == ["filesystem"] assert result["containment"]["unenforced_required"] == ["filesystem", "process_tree"]
assert result["containment"]["network"] == "inherit" assert result["containment"]["network"] == "inherit"
assert containment.active_grants() == [] assert containment.active_grants() == []
@@ -42,7 +42,7 @@ async def test_native_bash_refuses_before_spawn_when_required_boundary_missing(m
monkeypatch.setattr(asyncio, "create_subprocess_exec", forbidden) monkeypatch.setattr(asyncio, "create_subprocess_exec", forbidden)
result = await subprocess_tools.BashTool().execute("echo hello", {}) result = await subprocess_tools.BashTool().execute("echo hello", {})
assert result["containment"]["executed"] is False assert result["containment"]["executed"] is False
assert result["containment"]["unenforced_required"] == ["filesystem"] assert result["containment"]["unenforced_required"] == ["filesystem", "process_tree"]
async def test_failed_spawn_releases_unstarted_grant(native_boundary, monkeypatch): async def test_failed_spawn_releases_unstarted_grant(native_boundary, monkeypatch):
+7 -1
View File
@@ -217,11 +217,17 @@ def test_the_procfs_token_reads_a_comm_containing_spaces_and_parens(monkeypatch,
# each value equals its own field number. # each value equals its own field number.
fields = " ".join(str(index) for index in range(4, 54)) fields = " ".join(str(index) for index in range(4, 54))
(procfs / "77" / "stat").write_text(f"77 (my (weird) prog) S {fields}\n") (procfs / "77" / "stat").write_text(f"77 (my (weird) prog) S {fields}\n")
boot_path = procfs / "sys/kernel/random/boot_id"
boot_path.parent.mkdir(parents=True)
boot_path.write_text("first-boot\n")
monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs) monkeypatch.setattr(platform_compat, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "PROC_ROOT", procfs) monkeypatch.setattr(po, "PROC_ROOT", procfs)
monkeypatch.setattr(po, "IS_WINDOWS", False) monkeypatch.setattr(po, "IS_WINDOWS", False)
assert po.start_token(77) == "procfs:22" assert po.start_token(77) == "procfs:first-boot:22"
token = po.start_token(77)
boot_path.write_text("second-boot\n")
assert po.start_token(77) != token
# ── The process tree ──────────────────────────────────────────────────────── # ── The process tree ────────────────────────────────────────────────────────
+6 -2
View File
@@ -2198,8 +2198,9 @@ def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch, tmp_path):
import venv import venv
from types import SimpleNamespace from types import SimpleNamespace
if not shutil.which("bwrap"): from src import containment
return if not containment._bwrap_available():
pytest.skip("functional bubblewrap namespaces unavailable")
from pathlib import Path from pathlib import Path
@@ -2238,6 +2239,7 @@ def test_workspace_namespace_mounts_only_a_nested_python_environment(monkeypatch
from src.agent_tools import subprocess_tools from src.agent_tools import subprocess_tools
monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap") monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap")
monkeypatch.setattr(subprocess_tools.containment, "_bwrap_available", lambda: True)
environment = tmp_path / "nested" / "venv" environment = tmp_path / "nested" / "venv"
environment.mkdir(parents=True) environment.mkdir(parents=True)
(environment / "pyvenv.cfg").write_text("home = /usr/bin\n") (environment / "pyvenv.cfg").write_text("home = /usr/bin\n")
@@ -2259,6 +2261,7 @@ def test_workspace_namespace_rejects_broad_or_symlinked_python_prefixes(monkeypa
from src.agent_tools import subprocess_tools from src.agent_tools import subprocess_tools
monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap") monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap")
monkeypatch.setattr(subprocess_tools.containment, "_bwrap_available", lambda: True)
linked_root = tmp_path / "linked-root" linked_root = tmp_path / "linked-root"
linked_root.symlink_to("/", target_is_directory=True) linked_root.symlink_to("/", target_is_directory=True)
# Compared against the argv with no interpreter prefix at all: an unsafe # Compared against the argv with no interpreter prefix at all: an unsafe
@@ -2290,6 +2293,7 @@ def test_workspace_namespace_preserves_the_64_bit_dynamic_loader(monkeypatch):
from src.agent_tools import subprocess_tools from src.agent_tools import subprocess_tools
monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap") monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/usr/bin/bwrap")
monkeypatch.setattr(subprocess_tools.containment, "_bwrap_available", lambda: True)
command = subprocess_tools._wrap_workspace_namespace("echo ok", "/tmp/workspace") command = subprocess_tools._wrap_workspace_namespace("echo ok", "/tmp/workspace")
assert command is not None assert command is not None
args = shlex.split(command) args = shlex.split(command)
+1 -1
View File
@@ -75,7 +75,7 @@ The source tree reads **108** `ODYSSEUS_*` variables: 78 an operator may want to
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15361` | How many video frames one tool result may contribute. Clamped to 1-8. | | `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15361` | How many video frames one tool result may contribute. Clamped to 1-8. |
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15329` | How many images one tool result may contribute to the model turn. Clamped to 1-8. | | `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15329` | How many images one tool result may contribute to the model turn. Clamped to 1-8. |
| `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. | | `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. |
| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_tools/subprocess_tools.py:857` (+1 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. | | `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_tools/subprocess_tools.py:835` (+1 more) | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. |
| `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:919` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. | | `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:919` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. |
| `ODYSSEUS_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:645` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. | | `ODYSSEUS_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:645` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. |