From 63fe66e85e896f7566bd44d40ad50516128c703e Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:11:10 +0100 Subject: [PATCH] fix(runtime): enforce functional containment after closing execution bypasses (ODY-141) --- src/agent_tools/subprocess_tools.py | 60 ++-- src/containment.py | 296 ++++++++++++++------ src/containment_worker.py | 4 +- src/process_ownership.py | 13 +- src/process_reaper.py | 7 +- tests/containment_helpers.py | 10 +- tests/test_agent_bash_tmux_env.py | 6 +- tests/test_containment_contract.py | 6 +- tests/test_containment_enforcement.py | 276 ++++++++++++++++++ tests/test_containment_process_tree.py | 10 +- tests/test_execution_filesystem_boundary.py | 4 + tests/test_native_execution_containment.py | 6 +- tests/test_process_ownership.py | 8 +- tests/test_workspace_artifact_tool_floor.py | 8 +- website/configuration-reference.md | 2 +- 15 files changed, 564 insertions(+), 152 deletions(-) create mode 100644 tests/test_containment_enforcement.py diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 8906db1d4..79a5cec8e 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -388,7 +388,7 @@ def _contained_command( comparable — one is a mount namespace, the other is a regex — and choosing the second silently means an uncontained host execution reads in the 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. :raises containment.ContainmentUnavailable: filesystem containment could @@ -399,7 +399,7 @@ def _contained_command( content, cwd, chdir=chdir, interpreter_prefix=interpreter_prefix, ) # 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 # actually runs: a probe that said yes to a wrapper that declined would be # 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 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 - args = [ - "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 `/.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)) + readonly = [path for path in ("/home", "/mnt") if os.path.isdir(path)] # setup-python installs interpreters under /opt, and local CI virtualenvs # can live under /tmp. Those paths are hidden by the private root/tmpfs. # Expose only the active interpreter environment, read-only, so Python @@ -513,9 +481,14 @@ def _wrap_workspace_namespace( and os.path.isdir(prefix) and has_environment_layout ): - args.extend(_namespace_dir_chain(prefix)) - args.extend(("--ro-bind", prefix, prefix)) - args.extend(("--chdir", chdir, "/bin/bash", "-lc", content)) + readonly.append(prefix) + spec = containment.ContainmentSpec( + 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) @@ -524,7 +497,8 @@ def _owned_spec(cwd: str, env: Optional[dict], timeout: int, readonly_extra: tup readonly = [] for prefix in (sys.prefix, sys.base_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) return containment.agent_spec( 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: return containment.unavailable_tool_result(exc, tool=tool) 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, - "containment": grant.to_dict() if grant else {"contained": False, "executed": False}} + "containment": boundary} boundary = result.grant.to_dict() boundary["executed"] = True diff --git a/src/containment.py b/src/containment.py index 70d6b21c8..64997ee5c 100644 --- a/src/containment.py +++ b/src/containment.py @@ -39,11 +39,10 @@ deployment detail. * :data:`MODE_REPORT_ONLY` — the same shortfall is recorded on the grant as ``unenforced_required``, logged once, and the command runs. -The shipped default is report-only. On macOS and in the shipped Docker image -there is no ``bwrap``, so enforcing filesystem containment by default would turn -every ``bash`` call into a refusal the moment this module is wired up. Starting -report-only makes that landing observable instead of breaking, and flipping the -constant is reversible in a way that breaking every host is not. +The shipped default is enforcing. Hosts without functional namespaces refuse +native agent execution requiring filesystem and process-tree containment. +Report-only remains an explicit internal diagnostic posture, never a tool or +deployment setting. Networking is inherited unless the spec requests isolation. """ from __future__ import annotations @@ -54,6 +53,7 @@ import json import logging import os import shutil +import select import signal import subprocess import sys @@ -81,10 +81,8 @@ logger = logging.getLogger(__name__) MODE_ENFORCING = "enforcing" MODE_REPORT_ONLY = "report_only" -#: Ship report-only; see the module docstring for why this is the reversible -#: direction. Flip to MODE_ENFORCING to make an unestablishable required -#: dimension refuse the command instead of reporting on it. -CONTAINMENT_MODE = MODE_REPORT_ONLY +#: Required containment must be established before model-controlled code runs. +CONTAINMENT_MODE = MODE_ENFORCING # ── Dimensions ────────────────────────────────────────────────────────────── @@ -296,7 +294,23 @@ class Mechanism: 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: @@ -353,6 +367,7 @@ def _rlimit_dimensions(spec: ContainmentSpec) -> set[str]: provided.add(MEMORY) if ( spec.max_processes is not None + and os.geteuid() != 0 # RLIMIT_NPROC does not limit root. and _rlimit_fits("RLIMIT_NPROC", spec.max_processes) ): provided.add(PROCESS_COUNT) @@ -361,7 +376,7 @@ def _rlimit_dimensions(spec: ContainmentSpec) -> set[str]: def _bwrap_provides(spec: ContainmentSpec) -> frozenset[str]: # 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 # ours either way, applied to the bwrap process itself so its descendants # inherit them. @@ -372,16 +387,15 @@ def _bwrap_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 - # clock real for the whole tree. It says nothing about the filesystem: a - # cwd is not a boundary. - return frozenset({PROCESS_TREE, WALL_CLOCK} | _rlimit_dimensions(spec)) + # Groups support escalating teardown, but a descendant can call setsid() + # and escape. They cannot truthfully establish process-tree containment. + return frozenset({WALL_CLOCK} | _rlimit_dimensions(spec)) def _windows_provides(spec: ContainmentSpec) -> frozenset[str]: - # taskkill /T /F walks the child tree, which is the Windows equivalent of - # signalling a group. There is no setrlimit and no namespace. - return frozenset({PROCESS_TREE, WALL_CLOCK}) + # taskkill supports teardown, but is not a Job Object preventing escaped + # descendants. No filesystem or process-tree containment is established. + return frozenset({WALL_CLOCK}) #: 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, "owner": grant.owner, "manager_pid": os.getpid(), - "manager_token": process_ownership.start_token(os.getpid()), + "manager_token": process_ownership.capture(os.getpid())["start_token"], "mechanism": grant.mechanism, "mode": grant.mode, "workspace": grant.workspace, @@ -784,13 +798,16 @@ def unavailable_tool_result(exc: ContainmentUnavailable, *, tool: str) -> dict[s # ── 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. 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. """ 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] = [] parent = os.path.dirname(path) 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``. """ args = [ - "bwrap", "--die-with-parent", "--new-session", + "bwrap", "--die-with-parent", "--new-session", "--unshare-pid", "--tmpfs", "/", "--dir", "/usr", "--ro-bind", "/usr", "/usr", "--symlink", "usr/bin", "/bin", @@ -820,21 +837,22 @@ def _bwrap_prefix(spec: ContainmentSpec) -> list[str]: "--symlink", "usr/bin", "/sbin", "--dir", "/etc", "--ro-bind", "/etc", "/etc", "--dir", "/tmp", "--tmpfs", "/tmp", - "--dev-bind", "/dev", "/dev", "--proc", "/proc", - "--dir", WORKSPACE_MOUNT, "--bind", spec.workspace, WORKSPACE_MOUNT, + "--dev", "/dev", "--proc", "/proc", ] + 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 # a writable parent directory. workspace = os.path.realpath(spec.workspace) 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)) - 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: args.append("--unshare-net") args.extend(("--chdir", WORKSPACE_MOUNT)) @@ -871,7 +889,8 @@ def _rlimit_preexec(grant: ContainmentGrant) -> Optional[Callable[[], None]]: 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 if argv: 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] 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 parts @@ -905,8 +931,8 @@ def _spawn_kwargs(grant: ContainmentGrant) -> dict[str, Any]: # reach it, and teardown walks the tree with taskkill /T. kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) return kwargs - # setsid is what makes PROCESS_TREE real: without it a timeout kill reaches - # the wrapper shell and nothing it backgrounded. + # A separate group makes ordinary tree teardown possible. The PID + # namespace, not setsid, prevents descendants from escaping containment. kwargs["start_new_session"] = True preexec = _rlimit_preexec(grant) if preexec is not None: @@ -978,61 +1004,101 @@ async def run( "backend does not own; it cannot be run locally" ) missing = frozenset(grant.spec.required) - frozenset(grant.enforced) - if missing and grant.mode == MODE_ENFORCING: - raise ContainmentUnavailable(missing, grant.mechanism) + mechanism = next((item for item in MECHANISMS if item.name == grant.mechanism), None) + 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 - launch = _launch_argv(grant, command, argv=argv) + marker = uuid.uuid4().hex if grant.mechanism == "bubblewrap" else None 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, - 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, stderr=asyncio.subprocess.PIPE, cwd=spec.workspace, env=dict(spec.env), **_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: - # Acquisition can precede a failed or cancelled spawn. A grant without - # a child must not become a permanent restart orphan. - release(grant, grace_s=0) + if "proc" not in locals(): + release(grant, grace_s=0) 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 # 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. - 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) # 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 # whether the pid is still this child or something the kernel has since # handed to a stranger; see src/process_ownership.py. - _update_record( - grant.id, - pid=proc.pid, - pgid=pgid, - started_at=time.time(), - start_token=process_ownership.capture(proc.pid)["start_token"], - ) + try: + _update_record(grant.id, pid=proc.pid, pgid=pgid, started_at=time.time(), + start_token=None if marker is not None else process_ownership.capture(proc.pid)["start_token"], + containment_ready=marker is None, execution_started=marker is None) + from src.agent_runtime.journal import mark_operation_started + mark_operation_started("subprocess", pid=proc.pid) + except BaseException: + await _complete_cleanup(_release_awaited(live, proc), propagate_cancel=False) + raise out_buf: list[str] = [] err_buf: list[str] = [] out_budget = [int(spec.max_output_bytes)] err_budget = [int(spec.max_output_bytes)] started = time.time() - readers = [ - asyncio.create_task(_drain(proc.stdout, out_buf, out_budget, output_cb)), - asyncio.create_task(_drain(proc.stderr, err_buf, err_budget, output_cb)), - ] + readers = [asyncio.create_task(_drain(proc.stderr, err_buf, err_budget, output_cb))] + ready = marker is None + execution_started = marker is 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 # 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: - proc.stdin.write(stdin) - await proc.stdin.drain() + if stdin is not None: + proc.stdin.write(stdin) + await proc.stdin.drain() except (BrokenPipeError, ConnectionResetError): pass finally: @@ -1052,34 +1118,42 @@ async def run( progress_task = asyncio.create_task(_progress()) if progress_cb else None timed_out = False 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: await asyncio.wait_for(_wait(), timeout=spec.wall_clock_s) except asyncio.TimeoutError: + if not ready: + raise ContainmentUnavailable(spec.required, grant.mechanism) timed_out = True - outcome = await _release_awaited(live, proc) - except asyncio.CancelledError: - await _release_awaited(live, proc) - raise + except BaseException as exc: + exc.containment_established = ready + exc.containment_executed = execution_started + raise 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(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: + # Clean exit, partial initialization, timeout, and cancellation share + # the same teardown. Repeated cancellation cannot skip escalation. + outcome = await _complete_cleanup(_finish()) + if timed_out: _update_record(grant.id, timed_out=True) 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 ───────────────────────────────────────────────────────────────── # core.platform_compat.kill_process_tree delegates here as well. Native tools, # 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" 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: return release(grant, grace_s=grace_s) 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: await asyncio.wait_for(proc.wait(), timeout=max(grace_s, 0.05)) except (asyncio.TimeoutError, ProcessLookupError): pass 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) escalated = False - if not _tree_gone(pid, pgid): + if not gone(): escalated = True - _signal_tree(pid, pgid, signal.SIGKILL) + send(signal.SIGKILL) try: await asyncio.wait_for(proc.wait(), timeout=1.0) except (asyncio.TimeoutError, ProcessLookupError): pass 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) - outcome = _outcome_for(grant, dead=_tree_gone(pid, pgid), escalated=escalated) + outcome = _outcome_for(grant, dead=gone(), escalated=escalated) _finish_release(grant, outcome) return outcome diff --git a/src/containment_worker.py b/src/containment_worker.py index d0b412f13..196efe8aa 100644 --- a/src/containment_worker.py +++ b/src/containment_worker.py @@ -60,8 +60,8 @@ async def supervise(payload: dict) -> None: output, code = f"background execution failed: {type(exc).__name__}: {exc}\n", 1 report = {"containment": grant.to_dict(), "teardown": record.get("release") or {"dead": False}, "output_truncated": False} - report["containment"]["executed"] = bool(record.get("pid")) - if not record.get("pid"): + report["containment"]["executed"] = bool(record.get("execution_started")) + if not record.get("containment_ready"): report["containment"].update(contained=False, enforced=[]) if isinstance(exc, containment.ContainmentUnavailable): report.update(containment.unavailable_tool_result(exc, tool="bash")) diff --git a/src/process_ownership.py b/src/process_ownership.py index d0e814272..b8977729a 100644 --- a/src/process_ownership.py +++ b/src/process_ownership.py @@ -39,7 +39,7 @@ Token granularity, stated because it bounds the guarantee ======== ============================= =============== Host Source Resolution ======== ============================= =============== -Linux ``/proc//stat`` field 22 ~10 ms (1 tick) +Linux boot ID + stat field 22 ~10 ms (1 tick) macOS ``ps -o lstart=`` 1 s Windows ``GetProcessTimes`` 100 ns ======== ============================= =============== @@ -145,9 +145,18 @@ def _procfs_token(pid: int) -> Optional[str]: _, _, rest = raw.rpartition(")") fields = rest.split() try: - return f"procfs:{fields[_PROC_STAT_STARTTIME_INDEX]}" + ticks = fields[_PROC_STAT_STARTTIME_INDEX] except IndexError: 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]: diff --git a/src/process_reaper.py b/src/process_reaper.py index 762c0bc26..1c5c7dab7 100644 --- a/src/process_reaper.py +++ b/src/process_reaper.py @@ -232,15 +232,18 @@ def reap_legacy_agent_tmux() -> Dict[str, Any]: if process_ownership.verify(pid, identities[pid]) != process_ownership.OWNED: continue spec = containment.ContainmentSpec(workspace=os.getcwd(), env={}, wall_clock_s=1, - required=frozenset({containment.PROCESS_TREE})) + required=frozenset()) grant = containment.ContainmentGrant( 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, spec=spec, pid=pid, pgid=containment._pgid_of(pid), ) containment._write_record(grant) 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])) dead = True for grant, token in tracked: diff --git a/tests/containment_helpers.py b/tests/containment_helpers.py index a816a194c..78f6f07ef 100644 --- a/tests/containment_helpers.py +++ b/tests/containment_helpers.py @@ -14,18 +14,26 @@ def capture_owned_spawn(monkeypatch, tmp_path): async def fake_exec(*argv, **kwargs): captured.update(argv=argv, kwargs=kwargs, command=argv[-1]) 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_eof() stderr = asyncio.StreamReader() stderr.feed_eof() async def wait(): 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, - returncode=0, wait=wait) + stdin=writer, returncode=0, wait=wait) async def release(*args, **kwargs): return containment.ReleaseOutcome(dead=True, escalated=False) 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) return captured diff --git a/tests/test_agent_bash_tmux_env.py b/tests/test_agent_bash_tmux_env.py index d0e7cbdb2..2532a097f 100644 --- a/tests/test_agent_bash_tmux_env.py +++ b/tests/test_agent_bash_tmux_env.py @@ -38,7 +38,11 @@ def test_direct_bash_subprocess_has_closed_stdin(monkeypatch, tmp_path): result = asyncio.run(subprocess_tools.BashTool().execute("echo ok", {})) 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() diff --git a/tests/test_containment_contract.py b/tests/test_containment_contract.py index 390cd743d..cbc678943 100644 --- a/tests/test_containment_contract.py +++ b/tests/test_containment_contract.py @@ -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 -def test_report_only_is_the_shipped_default(): - """Pinned deliberately: merging this must not change behaviour on a host - without bubblewrap. Flipping it is a one-line diff, reviewed as one.""" - assert containment.CONTAINMENT_MODE == containment.MODE_REPORT_ONLY +def test_enforcement_is_the_shipped_default(): + assert containment.CONTAINMENT_MODE == containment.MODE_ENFORCING # ── Spec validation: caller bugs raise in both modes ──────────────────────── diff --git a/tests/test_containment_enforcement.py b/tests/test_containment_enforcement.py new file mode 100644 index 000000000..e569442fa --- /dev/null +++ b/tests/test_containment_enforcement.py @@ -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() == [] diff --git a/tests/test_containment_process_tree.py b/tests/test_containment_process_tree.py index e70fdcac1..8487cfaad 100644 --- a/tests/test_containment_process_tree.py +++ b/tests/test_containment_process_tree.py @@ -56,12 +56,12 @@ def workspace(tmp_path): 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("wall_clock_s", 1) return containment.ContainmentSpec( workspace=workspace, - required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}), + required=frozenset({containment.WALL_CLOCK}), **kwargs, ) @@ -312,7 +312,7 @@ async def test_a_process_count_limit_is_applied_to_the_child(workspace): env={"PATH": "/usr/bin:/bin"}, wall_clock_s=10, required=frozenset({ - containment.PROCESS_TREE, containment.WALL_CLOCK, containment.PROCESS_COUNT, + containment.WALL_CLOCK, containment.PROCESS_COUNT, }), max_processes=64, ) @@ -341,7 +341,7 @@ async def test_a_memory_limit_is_claimed_only_where_it_can_be_applied(workspace) workspace=workspace, env={"PATH": "/usr/bin:/bin"}, wall_clock_s=10, - required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}), + required=frozenset({containment.WALL_CLOCK}), max_memory_bytes=limit, ) 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"}, wall_clock_s=10, required=frozenset({ - containment.PROCESS_TREE, containment.WALL_CLOCK, containment.MEMORY, + containment.WALL_CLOCK, containment.MEMORY, }), max_memory_bytes=2 * 1024 * 1024 * 1024, ) diff --git a/tests/test_execution_filesystem_boundary.py b/tests/test_execution_filesystem_boundary.py index 0c6ef1b4a..7f3482cee 100644 --- a/tests/test_execution_filesystem_boundary.py +++ b/tests/test_execution_filesystem_boundary.py @@ -53,13 +53,16 @@ def _argv(workspace, **kwargs): import shutil as _shutil original = _shutil.which + original_available = containment._bwrap_available try: + containment._bwrap_available = lambda: True _shutil.which = lambda name, *a, **kw: ( "/usr/bin/bwrap" if name == "bwrap" else original(name, *a, **kw) ) wrapped = subprocess_tools._wrap_workspace_namespace("true", workspace, **kwargs) finally: _shutil.which = original + containment._bwrap_available = original_available assert wrapped is not None, "forced bwrap should produce a namespace argv" 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): """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.""" + monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY) monkeypatch.setattr( subprocess_tools, "_wrap_workspace_namespace", lambda *args, **kwargs: None, diff --git a/tests/test_native_execution_containment.py b/tests/test_native_execution_containment.py index 63751cf31..7a195bf80 100644 --- a/tests/test_native_execution_containment.py +++ b/tests/test_native_execution_containment.py @@ -29,8 +29,8 @@ async def test_native_bash_owns_and_releases_its_child(native_boundary): assert result["output"] == "captured" assert result["exit_code"] == 0 assert result["teardown"]["dead"] is True - assert result["containment"]["enforced"] == ["process_tree", "wall_clock"] - assert result["containment"]["unenforced_required"] == ["filesystem"] + assert result["containment"]["enforced"] == ["wall_clock"] + assert result["containment"]["unenforced_required"] == ["filesystem", "process_tree"] assert result["containment"]["network"] == "inherit" 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) result = await subprocess_tools.BashTool().execute("echo hello", {}) 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): diff --git a/tests/test_process_ownership.py b/tests/test_process_ownership.py index f4d8244fa..571ff1247 100644 --- a/tests/test_process_ownership.py +++ b/tests/test_process_ownership.py @@ -217,11 +217,17 @@ def test_the_procfs_token_reads_a_comm_containing_spaces_and_parens(monkeypatch, # each value equals its own field number. fields = " ".join(str(index) for index in range(4, 54)) (procfs / "77" / "stat").write_text(f"77 (my (weird) prog) S {fields}\n") + 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(po, "PROC_ROOT", procfs) 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 ──────────────────────────────────────────────────────── diff --git a/tests/test_workspace_artifact_tool_floor.py b/tests/test_workspace_artifact_tool_floor.py index 38c51f48c..c92bda36a 100644 --- a/tests/test_workspace_artifact_tool_floor.py +++ b/tests/test_workspace_artifact_tool_floor.py @@ -2198,8 +2198,9 @@ def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch, tmp_path): import venv from types import SimpleNamespace - if not shutil.which("bwrap"): - return + from src import containment + if not containment._bwrap_available(): + pytest.skip("functional bubblewrap namespaces unavailable") 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 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.mkdir(parents=True) (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 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.symlink_to("/", target_is_directory=True) # 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 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") assert command is not None args = shlex.split(command) diff --git a/website/configuration-reference.md b/website/configuration-reference.md index 771816b3e..90a569071 100644 --- a/website/configuration-reference.md +++ b/website/configuration-reference.md @@ -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_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_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_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:645` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. |