fix(runtime): fold native execution into shared containment (ODY-152)

This commit is contained in:
Alexandre Teixeira
2026-10-01 20:59:49 +01:00
parent 1d0944d47d
commit 9bb2424e65
10 changed files with 332 additions and 155 deletions
+22 -31
View File
@@ -107,51 +107,42 @@ def pid_alive(pid: Optional[int]) -> bool:
PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid)
)
if not handle:
return False
return kernel32.GetLastError() != 87 # ERROR_INVALID_PARAMETER: PID absent
try:
code = wintypes.DWORD()
if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
return code.value == STILL_ACTIVE
return False
return True # A failed probe does not establish death.
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
return True
except (OSError, ProcessLookupError):
except ProcessLookupError:
return False
except OSError:
return True # EPERM and other inspection failures are not ESRCH.
def kill_process_tree(pid: Optional[int]) -> None:
"""Terminate ``pid`` and all of its descendants.
def kill_process_tree(pid: Optional[int]):
"""Use the runtime's shared escalating teardown and return verified death.
POSIX: signal the whole process group (``killpg``), falling back to a plain
``kill`` if the pid isn't a group leader.
Windows: ``taskkill /T /F`` walks and kills the child tree (there is no
process-group signalling).
Callers retaining durable PIDs must validate their recorded identity before
calling this compatibility entry point. Native grants retain identity at
spawn and use containment.release directly.
"""
if not pid:
return
if IS_WINDOWS:
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
pass
return
import signal
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except Exception:
try:
os.kill(pid, signal.SIGTERM)
except Exception:
pass
from src import containment
if not pid or int(pid) <= 0:
return containment.ReleaseOutcome(dead=True, escalated=False)
spec = containment.ContainmentSpec(workspace=os.getcwd(), env={}, wall_clock_s=1,
required=frozenset())
grant = containment.ContainmentGrant(
id="", mechanism="windows_tree" if IS_WINDOWS else "process_group",
workspace=spec.workspace, enforced=frozenset(), degraded=(),
unenforced_required=(), owner="compatibility", mode=containment.CONTAINMENT_MODE,
spec=spec, pid=int(pid), pgid=containment._pgid_of(int(pid)),
)
return containment.release(grant)
# ── Shell / executable resolution ───────────────────────────────────────────
+65 -38
View File
@@ -811,6 +811,66 @@ async def _run_subprocess_streaming(
timed_out,
)
def _owned_spec(cwd: str, env: Optional[dict], timeout: int) -> containment.ContainmentSpec:
"""Server-defined boundary shared by the native execution tools."""
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:
readonly.append(prefix)
return containment.agent_spec(
cwd, dict(os.environ if env is None else env), timeout,
readonly_extra=tuple(dict.fromkeys(readonly)),
)
async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, argv: bool = False) -> dict:
from src.tool_execution import agent_cwd, _truncate
grant = None
try:
grant = containment.acquire(
_owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout),
owner=str(ctx.get("session_id") or ctx.get("owner") or tool),
)
if containment.FILESYSTEM not in grant.enforced:
if argv:
command = [*command[:-1], _replace_workspace_alias(command[-1], grant.workspace)]
else:
command = _replace_workspace_alias(command, grant.workspace)
result = await containment.run(grant, command, argv=argv, progress_cb=ctx.get("progress_cb"))
except containment.ContainmentUnavailable as exc:
return containment.unavailable_tool_result(exc, tool=tool)
except (OSError, RuntimeError, ValueError) as exc:
return {"error": f"{tool}: execution failed: {exc}", "exit_code": 1,
"containment": grant.to_dict() if grant else {"contained": False, "executed": False}}
boundary = result.grant.to_dict()
boundary["executed"] = True
teardown = result.release.to_dict() if result.release else {"dead": False}
output = result.stdout.rstrip()
if result.stderr.rstrip():
output = (output + "\nSTDERR: " + result.stderr.rstrip()).strip()
truncated = result.output_truncated or len(output) > MAX_OUTPUT_CHARS
common = {"containment": boundary, "teardown": teardown, "output_truncated": truncated}
if not teardown["dead"]:
return {**common, "error": f"{tool}: process teardown could not verify death",
"failure_kind": "process_teardown_failed", "exit_code": 1,
"stdout": _truncate(result.stdout, MAX_OUTPUT_CHARS),
"stderr": _truncate(result.stderr, MAX_OUTPUT_CHARS)}
if result.timed_out:
return {**common, "error": f"{tool}: timed out after {timeout}s; process tree terminated",
"exit_code": 124, "stdout": _truncate(result.stdout, MAX_OUTPUT_CHARS),
"stderr": _truncate(result.stderr, MAX_OUTPUT_CHARS)}
if tool == "python":
child_failure = _python_child_runtime_failure(result.stdout, result.stderr, result.exit_code)
if child_failure:
return {**common, "error": _truncate("python: a child operation failed despite a zero Python exit status:\n" + child_failure, MAX_OUTPUT_CHARS),
"exit_code": 1, "stderr": _truncate(result.stderr, MAX_OUTPUT_CHARS)}
return {**common, "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
"exit_code": result.exit_code if result.exit_code is not None else 1}
class BashTool:
async def execute(self, content: str, ctx: dict) -> dict:
from src.tool_execution import agent_cwd, _truncate
@@ -861,14 +921,14 @@ class BashTool:
if "/tmp/" in content:
isolated_tmp = _isolated_tmp_dir(agent_cwd())
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
try:
content, boundary, _confined = _contained_command(content, agent_cwd())
except containment.ContainmentUnavailable as exc:
return containment.unavailable_tool_result(exc, tool="bash")
progress_cb = ctx.get("progress_cb")
_subproc_env = ctx.get("subproc_env")
session_id = ctx.get("session_id")
if not IS_WINDOWS and session_id and shutil.which("tmux"):
try:
content, boundary, _confined = _contained_command(content, agent_cwd())
except containment.ContainmentUnavailable as exc:
return containment.unavailable_tool_result(exc, tool="bash")
stdout, stderr, rc, timed_out = await _run_tmux_bash(
content,
session_id=str(session_id),
@@ -897,40 +957,7 @@ class BashTool:
"containment": boundary,
}
try:
if IS_WINDOWS:
proc = await _create_bash_subprocess(
content,
cwd=agent_cwd(),
env=_subproc_env,
)
else:
# Preserve the existing captured POSIX path; the structural
# helper is primarily needed to avoid cmd.exe on Windows.
proc = await asyncio.create_subprocess_shell(
content,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
cwd=agent_cwd(),
)
except RuntimeError as exc:
return {"error": str(exc), "exit_code": 1, "containment": boundary}
mark_operation_started('subprocess', pid=proc.pid)
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc,
timeout=DEFAULT_BASH_TIMEOUT,
progress_cb=progress_cb,
)
if timed_out:
return {"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), "containment": boundary}
output = stdout.rstrip()
err = stderr.rstrip()
if err:
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
output = _truncate(output, MAX_OUTPUT_CHARS)
return {"output": output or "(no output)", "exit_code": rc or 0, "containment": boundary}
return await _run_owned_command(content, ctx, tool="bash", timeout=DEFAULT_BASH_TIMEOUT)
class HostShellTool:
async def execute(self, content: str, ctx: dict) -> dict:
+55 -29
View File
@@ -238,6 +238,8 @@ class ContainmentGrant:
"unenforced_required": list(self.unenforced_required),
"contained": self.contained,
"external": self.external,
"requested": sorted(self.spec.requested),
"network": self.spec.network,
}
@@ -815,6 +817,12 @@ def _bwrap_prefix(spec: ContainmentSpec) -> list[str]:
"--dev-bind", "/dev", "/dev", "--proc", "/proc",
"--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(("--bind", workspace, workspace))
for path in spec.readonly_extra:
args.extend(_dir_chain(path))
args.extend(("--ro-bind", path, path))
@@ -873,6 +881,8 @@ def _launch_argv(grant: ContainmentGrant, command: Any, *, argv: bool) -> list[s
else:
shell = find_bash()
if not shell:
if IS_WINDOWS:
raise RuntimeError("Git Bash is required for the Bash tool on Windows; install Git for Windows.")
raise RuntimeError(
"containment: no POSIX shell available to run a shell command"
)
@@ -912,7 +922,7 @@ async def _drain(stream, buffer: list[str], budget: list[int]) -> None:
if stream is None:
return
while True:
line = await stream.readline()
line = await stream.read(65536)
if not line:
break
if budget[0] < 0:
@@ -956,15 +966,23 @@ async def run(
spec = grant.spec
launch = _launch_argv(grant, command, argv=argv)
proc = await asyncio.create_subprocess_exec(
*launch,
stdin=asyncio.subprocess.PIPE if stdin 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.create_subprocess_exec(
*launch,
stdin=asyncio.subprocess.PIPE if stdin 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),
)
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)
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.
@@ -991,16 +1009,18 @@ async def run(
asyncio.create_task(_drain(proc.stdout, out_buf, out_budget)),
asyncio.create_task(_drain(proc.stderr, err_buf, err_budget)),
]
if stdin is not None and proc.stdin is not None:
try:
proc.stdin.write(stdin)
await proc.stdin.drain()
except Exception:
pass
try:
proc.stdin.close()
except Exception:
pass
async def _wait() -> None:
# 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:
try:
proc.stdin.write(stdin)
await proc.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
pass
finally:
proc.stdin.close()
await proc.wait()
async def _progress() -> None:
while True:
@@ -1016,7 +1036,7 @@ async def run(
outcome: Optional[ReleaseOutcome] = None
try:
try:
await asyncio.wait_for(proc.wait(), timeout=spec.wall_clock_s)
await asyncio.wait_for(_wait(), timeout=spec.wall_clock_s)
except asyncio.TimeoutError:
timed_out = True
outcome = await _release_awaited(live, proc)
@@ -1056,12 +1076,8 @@ async def run(
# ── release ─────────────────────────────────────────────────────────────────
# These primitives duplicate the escalating teardown that PR #46 adds to
# core/platform_compat.kill_process_tree. They are here because this branch is
# cut from a lab SHA that predates it, and containment cannot ship a teardown
# that only sends SIGTERM. When #46 lands, release() should delegate to that
# function and the helpers below should go — carrying two copies of a
# process-group kill is exactly the divergence this module exists to end.
# core.platform_compat.kill_process_tree delegates here as well. Native tools,
# detached jobs and compatibility callers share escalation and death probes.
def _own_pgid() -> int:
try:
return os.getpgid(0)
@@ -1097,8 +1113,10 @@ def _group_present(pgid: Optional[int]) -> bool:
try:
os.killpg(pgid, 0)
return True
except (OSError, ProcessLookupError):
except ProcessLookupError:
return False
except OSError:
return True # EPERM is a live group we cannot signal, not verified death.
def _signal_tree(pid: Optional[int], pgid: Optional[int], sig: int) -> None:
@@ -1188,7 +1206,11 @@ def _ownership_gate(
"""
verdict = process_ownership.verify(pid, token)
if verdict == process_ownership.OWNED:
return None
if IS_WINDOWS or not pgid or _pgid_of(pid) == pgid:
return None
# A valid leader identity does not establish ownership of an arbitrary
# recorded process group. Refuse a stale or inconsistent PGID.
verdict = process_ownership.UNVERIFIABLE
if verdict == process_ownership.GONE:
# The leader is gone. Its group may still hold processes it
@@ -1268,6 +1290,10 @@ def release(grant: ContainmentGrant, *, grace_s: float = 2.0) -> ReleaseOutcome:
pgid = int(pgid) if pgid else None
except (TypeError, ValueError):
pgid = None
if pid <= 0:
pid = 0
if pgid is not None and pgid <= 0:
pgid = None
grant = replace(grant, pid=pid or None, pgid=pgid)
if not pid and not _group_present(pgid):
+6
View File
@@ -73,6 +73,12 @@ def reap_containment_grants() -> Dict[str, Any]:
continue
verdict = process_ownership.verify_record(record)
if verdict == process_ownership.GONE:
if containment._group_present(record.get("pgid")):
# Leader death does not prove tree death. Without a surviving
# identity we cannot signal the group, so retain the evidence.
report["failed"] += 1
logger.error("process_reaper: grant %s leader is gone but group survives", grant_id)
continue
containment.forget(grant_id)
report["already_gone"] += 1
continue
+31
View File
@@ -0,0 +1,31 @@
"""Captured spawns exercise the production runner without signalling fake PIDs."""
import asyncio
from types import SimpleNamespace
from src import containment
def capture_owned_spawn(monkeypatch, tmp_path):
captured = {}
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid)
async def fake_exec(*argv, **kwargs):
captured.update(argv=argv, kwargs=kwargs, command=argv[-1])
stdout = asyncio.StreamReader()
stdout.feed_data(b"ok")
stdout.feed_eof()
stderr = asyncio.StreamReader()
stderr.feed_eof()
async def wait():
return 0
return SimpleNamespace(pid=99999999, stdout=stdout, stderr=stderr,
returncode=0, wait=wait)
async def release(*args, **kwargs):
return containment.ReleaseOutcome(dead=True, escalated=False)
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
monkeypatch.setattr(containment, "_release_awaited", release)
return captured
+5 -27
View File
@@ -188,25 +188,14 @@ def test_direct_bash_subprocess_has_closed_stdin(monkeypatch, tmp_path):
from src.agent_tools import subprocess_tools
from src import tool_execution
captured = {}
sentinel = SimpleNamespace(pid=12345)
async def fake_create(command, **kwargs):
captured.update(kwargs)
return sentinel
async def fake_stream(proc, **_kwargs):
assert proc is sentinel
return "ok", "", 0, False
monkeypatch.setattr(asyncio, "create_subprocess_shell", fake_create)
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
from tests.containment_helpers import capture_owned_spawn
captured = capture_owned_spawn(monkeypatch, tmp_path)
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
result = asyncio.run(subprocess_tools.BashTool().execute("echo ok", {}))
assert result["exit_code"] == 0
assert captured["stdin"] is asyncio.subprocess.DEVNULL
assert captured["kwargs"]["stdin"] is asyncio.subprocess.DEVNULL
assert not (tmp_path / ".tmp").exists()
@@ -258,19 +247,8 @@ def test_bash_allows_unicode_ffmpeg_drawtext_with_explicit_fontfile(monkeypatch,
from src.agent_tools import subprocess_tools
from src import tool_execution
captured = {}
sentinel = SimpleNamespace(pid=12345)
async def fake_create(command, **kwargs):
captured["command"] = command
return sentinel
async def fake_stream(proc, **_kwargs):
assert proc is sentinel
return "ok", "", 0, False
monkeypatch.setattr(asyncio, "create_subprocess_shell", fake_create)
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
from tests.containment_helpers import capture_owned_spawn
captured = capture_owned_spawn(monkeypatch, tmp_path)
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
command = (
+18 -29
View File
@@ -6,6 +6,8 @@ import pytest
from types import SimpleNamespace
from src.agent_tools import subprocess_tools
from src import containment
from tests.containment_helpers import capture_owned_spawn
@pytest.mark.asyncio
@@ -84,26 +86,17 @@ async def test_windows_bash_applies_the_subprocess_env(monkeypatch):
@pytest.mark.asyncio
async def test_windows_bash_tool_passes_ctx_env_through_to_the_child(monkeypatch):
captured = {}
async def test_windows_bash_tool_passes_ctx_env_through_to_the_child(monkeypatch, tmp_path):
captured = capture_owned_spawn(monkeypatch, tmp_path)
env = {"PATH": r"C:\Odysseus\venv\Scripts", "VIRTUAL_ENV": r"C:\Odysseus\venv"}
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(
subprocess_tools, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe"
)
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: r"D:\Workspaces\Project")
async def fake_exec(*argv, **kwargs):
captured["argv"] = argv
captured["kwargs"] = kwargs
return SimpleNamespace(pid=4242)
async def fake_stream(_process, **_kwargs):
return "ok", "", 0, False
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
monkeypatch.setattr(containment, "IS_WINDOWS", True)
monkeypatch.setattr(containment, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe")
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: str(tmp_path))
result = await subprocess_tools.BashTool().execute(
"pwd",
@@ -134,9 +127,13 @@ async def test_windows_bash_without_git_bash_fails_clearly(monkeypatch):
@pytest.mark.asyncio
async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypatch):
async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypatch, tmp_path):
capture_owned_spawn(monkeypatch, tmp_path)
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
monkeypatch.setattr(containment, "IS_WINDOWS", True)
monkeypatch.setattr(containment, "find_bash", lambda: None)
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: str(tmp_path))
result = await subprocess_tools.BashTool().execute(
"pwd",
@@ -148,11 +145,13 @@ async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypat
@pytest.mark.asyncio
async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
captured = {}
workspace = r"D:\Workspaces\Project with spaces"
async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch, tmp_path):
captured = capture_owned_spawn(monkeypatch, tmp_path)
workspace = str(tmp_path)
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
monkeypatch.setattr(containment, "IS_WINDOWS", True)
monkeypatch.setattr(containment, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe")
monkeypatch.setattr(
subprocess_tools.shutil,
"which",
@@ -163,17 +162,7 @@ async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
async def fail_tmux(*_args, **_kwargs):
pytest.fail("native Windows must not enter the POSIX tmux path")
async def fake_create(command, **kwargs):
captured["command"] = command
captured["kwargs"] = kwargs
return SimpleNamespace(pid=12345)
async def fake_stream(_process, **_kwargs):
return "ok", "", 0, False
monkeypatch.setattr(subprocess_tools, "_run_tmux_bash", fail_tmux)
monkeypatch.setattr(subprocess_tools, "_create_bash_subprocess", fake_create)
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
result = await subprocess_tools.BashTool().execute(
"pwd",
@@ -185,7 +174,7 @@ async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
# Every bash result now carries the execution boundary it actually got.
# Asserting dict equality here would make that field impossible to add
# without touching a test about tmux, so the shape is asserted instead.
assert result["containment"]["reported_dimensions"] == ["filesystem"]
assert result["containment"]["network"] == "inherit"
assert captured["command"] == "pwd"
assert captured["kwargs"]["cwd"] == workspace
+106
View File
@@ -0,0 +1,106 @@
"""Native execution must use the shared boundary and report actual teardown."""
import asyncio
import os
import sys
import pytest
from src import containment, tool_execution
from src.agent_tools import subprocess_tools
@pytest.fixture(autouse=True)
def native_boundary(tmp_path, monkeypatch):
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "MECHANISMS", tuple(
m for m in containment.MECHANISMS if m.name == "process_group"
))
return tmp_path
@pytest.mark.skipif(os.name == "nt", reason="real POSIX group teardown")
async def test_native_bash_owns_and_releases_its_child(native_boundary):
result = await subprocess_tools.BashTool().execute(
"if read answer; then echo unexpected; else printf '%s' \"$ODY_TEST_ENV\"; fi",
{"subproc_env": {"PATH": "/usr/bin:/bin", "ODY_TEST_ENV": "captured"}},
)
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"]["network"] == "inherit"
assert containment.active_grants() == []
async def test_native_bash_refuses_before_spawn_when_required_boundary_missing(monkeypatch):
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_ENFORCING)
async def forbidden(*args, **kwargs):
pytest.fail("refused command reached spawn")
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"]
async def test_failed_spawn_releases_unstarted_grant(native_boundary, monkeypatch):
async def fail(*args, **kwargs):
raise OSError("spawn failed")
monkeypatch.setattr(asyncio, "create_subprocess_exec", fail)
result = await subprocess_tools.BashTool().execute("echo hello", {})
assert result["exit_code"] == 1
assert containment.active_grants() == []
@pytest.mark.skipif(os.name == "nt", reason="real POSIX process")
async def test_long_line_is_drained_and_truncation_reported(native_boundary):
spec = containment.ContainmentSpec(
workspace=str(native_boundary), env=dict(os.environ), wall_clock_s=5,
required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}),
max_output_bytes=100,
)
result = await containment.run(containment.acquire(spec, owner="long-line"),
[sys.executable, "-c", "print('x' * 200000)"], argv=True)
assert result.exit_code == 0
assert result.stdout == "x" * 100
assert result.output_truncated is True
assert result.release.dead is True
@pytest.mark.skipif(os.name == "nt", reason="real POSIX process")
async def test_output_exactly_at_cap_is_complete(native_boundary):
spec = containment.ContainmentSpec(
workspace=str(native_boundary), env=dict(os.environ), wall_clock_s=5,
required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}), max_output_bytes=100,
)
result = await containment.run(containment.acquire(spec, owner="exact-cap"),
[sys.executable, "-c", "import sys; sys.stdout.write('x' * 100)"], argv=True)
assert len(result.stdout) == 100
assert result.output_truncated is False
def test_permission_denied_is_not_verified_death(monkeypatch):
from core import platform_compat
def denied(*args):
raise PermissionError("EPERM")
monkeypatch.setattr(platform_compat, "IS_WINDOWS", False)
monkeypatch.setattr(os, "kill", denied)
monkeypatch.setattr(os, "killpg", denied)
monkeypatch.setattr(containment, "_own_pgid", lambda: 1)
assert platform_compat.pid_alive(987654) is True
assert containment._group_present(987654) is True
@pytest.mark.skipif(os.name == "nt", reason="real POSIX process")
async def test_blocked_stdin_is_inside_wall_clock(native_boundary):
spec = containment.ContainmentSpec(
workspace=str(native_boundary), env=dict(os.environ), wall_clock_s=1,
required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}),
)
result = await asyncio.wait_for(containment.run(
containment.acquire(spec, owner="blocked-stdin"), "sleep 60", stdin=b"x" * 2000000,
), timeout=8)
assert result.timed_out is True
assert result.release.dead is True
+23
View File
@@ -165,6 +165,7 @@ def test_a_gone_leader_with_a_live_group_is_reported_not_killed(
def test_a_verified_grant_is_torn_down_normally(grant_store, monkeypatch):
seed_grant()
verdicts(monkeypatch, {4242: process_ownership.OWNED})
monkeypatch.setattr(containment, "_pgid_of", lambda pid: 4242)
signals = []
monkeypatch.setattr(
containment, "_signal_tree",
@@ -179,6 +180,28 @@ def test_a_verified_grant_is_torn_down_normally(grant_store, monkeypatch):
assert outcome.ownership == ""
def test_verified_leader_does_not_authorize_a_different_group(grant_store, monkeypatch):
seed_grant(pgid=9999)
verdicts(monkeypatch, {4242: process_ownership.OWNED})
monkeypatch.setattr(containment, "_pgid_of", lambda pid: 4242)
monkeypatch.setattr(containment, "_signal_tree", lambda *args: pytest.fail("foreign group signalled"))
outcome = containment.reap_record(grant_store()["grant-1"])
assert outcome.dead is False
assert outcome.ownership == process_ownership.UNVERIFIABLE
def test_reaper_retains_a_group_after_its_leader_dies(grant_store, monkeypatch):
from src import process_reaper
seed_grant()
verdicts(monkeypatch, {4242: process_ownership.GONE})
monkeypatch.setattr(containment, "_group_present", lambda pgid: True)
monkeypatch.setattr(containment, "_signal_tree", lambda *args: pytest.fail("unidentified group signalled"))
report = process_reaper.reap_containment_grants()
assert report["failed"] == 1
assert report["already_gone"] == 0
assert "grant-1" in grant_store()
def test_an_in_process_grant_is_not_subjected_to_the_gate(monkeypatch, tmp_path):
"""A grant carrying its own pid belongs to the caller holding it.
+1 -1
View File
@@ -75,7 +75,7 @@ The source tree reads **108** `ODYSSEUS_*` variables: 78 an operator may want to
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15361` | How many video frames one tool result may contribute. Clamped to 1-8. |
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_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:1153` | 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:1180` | 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. |