mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
fix(runtime): enforce functional containment after closing execution bypasses (ODY-141)
This commit is contained in:
@@ -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 `<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))
|
||||
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
|
||||
|
||||
+209
-87
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -39,7 +39,7 @@ Token granularity, stated because it bounds the guarantee
|
||||
======== ============================= ===============
|
||||
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
|
||||
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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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 ────────────────────────
|
||||
|
||||
@@ -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() == []
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user