diff --git a/core/platform_compat.py b/core/platform_compat.py index e667c1e96..be6d30b87 100644 --- a/core/platform_compat.py +++ b/core/platform_compat.py @@ -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 ─────────────────────────────────────────── diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index bb587858f..1b7178d13 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -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: diff --git a/src/containment.py b/src/containment.py index 3584cdee2..b7213b173 100644 --- a/src/containment.py +++ b/src/containment.py @@ -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): diff --git a/src/process_reaper.py b/src/process_reaper.py index 63a2dbff7..8eddf5db2 100644 --- a/src/process_reaper.py +++ b/src/process_reaper.py @@ -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 diff --git a/tests/containment_helpers.py b/tests/containment_helpers.py new file mode 100644 index 000000000..a816a194c --- /dev/null +++ b/tests/containment_helpers.py @@ -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 diff --git a/tests/test_agent_bash_tmux_env.py b/tests/test_agent_bash_tmux_env.py index cd90445ff..44fb9562e 100644 --- a/tests/test_agent_bash_tmux_env.py +++ b/tests/test_agent_bash_tmux_env.py @@ -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 = ( diff --git a/tests/test_agent_bash_windows.py b/tests/test_agent_bash_windows.py index cf9742379..99ab918c1 100644 --- a/tests/test_agent_bash_windows.py +++ b/tests/test_agent_bash_windows.py @@ -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 diff --git a/tests/test_native_execution_containment.py b/tests/test_native_execution_containment.py new file mode 100644 index 000000000..095addeb1 --- /dev/null +++ b/tests/test_native_execution_containment.py @@ -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 diff --git a/tests/test_orphan_reaping.py b/tests/test_orphan_reaping.py index d89b45810..38eca2e51 100644 --- a/tests/test_orphan_reaping.py +++ b/tests/test_orphan_reaping.py @@ -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. diff --git a/website/configuration-reference.md b/website/configuration-reference.md index 53dbf4351..bb0474604 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: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. |