Add owner-mediated bwrap execution boundary

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a06def-6490-7033-8448-2eab2d12ed44
This commit is contained in:
tegwick 2026-09-04 22:12:19 +02:00
parent 877676d1f1
commit d79e3fe358
23 changed files with 1321 additions and 86 deletions

View file

@ -1,12 +1,20 @@
"""ext.bwrap — local namespace isolation extension."""
import json
import signal
import socket
import stat
import subprocess
import threading
from unittest.mock import MagicMock, patch
import pytest
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.bwrap import BwrapExtension
from sandboxer.models import IsolationSpec, Profile
from sandboxer.extensions.bwrap_runner import _run
from sandboxer.models import IsolationSpec, Profile, Reachability
from sandboxer.reachability.enrich import enrich_reachability
def _profile() -> Profile:
@ -40,7 +48,13 @@ def test_bwrap_argv_unshares_net_and_binds_workspace(tmp_path) -> None:
assert "--unshare-net" in argv
assert "--unshare-user" in argv
assert str(tmp_path) in argv
assert argv[-2:] == ["sleep", "infinity"]
assert "/run/sandboxer/bwrap_runner.py" in argv
assert argv[-4:] == [
"/usr/bin/python3",
"/run/sandboxer/bwrap_runner.py",
str(tmp_path),
f"{tmp_path}/.sandboxer-owner.sock",
]
def test_existing_ro_binds_filters_missing_paths(tmp_path) -> None:
@ -55,13 +69,17 @@ def test_provision_spawns_bwrap_and_returns_handle(tmp_path) -> None:
fake_proc = MagicMock()
fake_proc.pid = 12345
with patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc) as popen:
with (
patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc) as popen,
patch.object(BwrapExtension, "_read_child_pid", return_value=23456),
):
handle = ext.provision(_profile(), {"sandbox_id": "abc12345"}, "localhost")
popen.assert_called_once()
assert handle["sandbox_id"] == "abc12345"
assert handle["host"] == "localhost"
assert handle["pid"] == "12345"
assert handle["pid"] == "23456"
assert handle["supervisor_pid"] == "12345"
assert handle["workspace_dir"].endswith("abc12345")
@ -75,13 +93,17 @@ def test_provision_copies_repo_into_workspace(tmp_path) -> None:
fake_proc = MagicMock()
fake_proc.pid = 1
with patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc):
with (
patch("sandboxer.extensions.bwrap.subprocess.Popen", return_value=fake_proc),
patch.object(BwrapExtension, "_read_child_pid", return_value=2),
):
handle = ext.provision(
_profile(), {"sandbox_id": "cafe1234", "repo": str(repo)}, "localhost"
)
assert (base_dir / "cafe1234" / "file.txt").read_text() == "hello"
assert handle["workspace_dir"] == str(base_dir / "cafe1234")
assert stat.S_IMODE((base_dir / "cafe1234").stat().st_mode) == 0o700
def test_provision_missing_repo_raises(tmp_path) -> None:
@ -100,7 +122,10 @@ def test_wait_ready_checks_process_and_workspace(tmp_path) -> None:
ext = BwrapExtension()
handle = {"pid": str(1), "workspace_dir": str(workspace), "host": "localhost"}
with patch.object(BwrapExtension, "_pid_alive", return_value=True):
with (
patch.object(BwrapExtension, "_pid_alive", return_value=True),
patch("sandboxer.extensions.bwrap.Path.is_socket", return_value=True),
):
result = ext.wait_ready(handle)
assert result["host"] == "localhost"
@ -113,18 +138,22 @@ def test_wait_ready_raises_if_process_dead(tmp_path) -> None:
ext = BwrapExtension()
handle = {"pid": "999999", "workspace_dir": str(workspace), "host": "localhost"}
with patch.object(BwrapExtension, "_pid_alive", return_value=False):
with pytest.raises(RuntimeError, match="not running"):
ext.wait_ready(handle)
with (
patch.object(BwrapExtension, "_pid_alive", return_value=False),
pytest.raises(RuntimeError, match="not running"),
):
ext.wait_ready(handle)
def test_wait_ready_raises_if_workspace_missing(tmp_path) -> None:
ext = BwrapExtension()
handle = {"pid": "1", "workspace_dir": str(tmp_path / "gone"), "host": "localhost"}
with patch.object(BwrapExtension, "_pid_alive", return_value=True):
with pytest.raises(RuntimeError, match="workspace missing"):
ext.wait_ready(handle)
with (
patch.object(BwrapExtension, "_pid_alive", return_value=True),
pytest.raises(RuntimeError, match="workspace missing"),
):
ext.wait_ready(handle)
def test_teardown_kills_process_group_and_removes_workspace(tmp_path) -> None:
@ -166,10 +195,7 @@ def test_supports_snapshots_is_false() -> None:
ext.snapshot({})
def test_reachability_local_exec_hint_for_bwrap_handle() -> None:
from sandboxer.reachability.enrich import enrich_reachability, local_exec_hint
from sandboxer.models import Reachability
def test_reachability_enriches_bwrap_handle_without_direct_exec_hint() -> None:
handle = {"pid": "555", "workspace_dir": "/tmp/sandboxer-bwrap/abc", "host": "localhost"}
reach = {"host": "localhost", "endpoint": "pid:555"}
profile = _profile()
@ -179,7 +205,147 @@ def test_reachability_local_exec_hint_for_bwrap_handle() -> None:
assert reachability.pid == "555"
assert reachability.workspace_dir == "/tmp/sandboxer-bwrap/abc"
assert local_exec_hint(reachability) == (
"nsenter --target 555 --mount --pid --net --uts --ipc "
"-- sh -c 'cd /tmp/sandboxer-bwrap/abc && exec $SHELL'"
)
def test_execute_sends_bounded_request_to_in_namespace_owner(tmp_path) -> None:
base_dir = tmp_path / "sandboxes"
workspace = base_dir / "abc12345"
workspace.mkdir(parents=True)
ext = BwrapExtension({"base_dir": str(base_dir)})
handle = {
"sandbox_id": "abc12345",
"pid": "42",
"workspace_dir": str(workspace),
}
control = workspace / ".sandboxer-owner.sock"
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(control))
server.listen(1)
received = {}
def serve_once() -> None:
connection, _ = server.accept()
with connection:
raw = b""
while chunk := connection.recv(65_536):
raw += chunk
received.update(json.loads(raw))
connection.sendall(
json.dumps(
{
"exit_code": 0,
"timed_out": False,
"stdout": "ok\n",
"stderr": "",
"output_truncated": False,
"workspace_dir": str(workspace),
}
).encode()
)
thread = threading.Thread(target=serve_once)
thread.start()
with patch.object(BwrapExtension, "_pid_alive", return_value=True):
result = ext.execute(
handle,
["python3", "-V"],
credential_route_refs=["rein-openweights-openrouter-approle"],
execution_context={"actor": "agt", "run_id": "run-1"},
timeout_seconds=30,
max_output_bytes=1024,
)
thread.join(timeout=2)
server.close()
assert received["command"] == ["python3", "-V"]
assert received["execution_context"] == {"actor": "agt", "run_id": "run-1"}
assert received["credential_route_refs"] == ["rein-openweights-openrouter-approle"]
assert received["timeout_seconds"] == 30
assert received["max_output_bytes"] == 1024
assert result["stdout"] == "ok\n"
assert result["exit_code"] == 0
def test_in_namespace_runner_uses_sanitized_environment(tmp_path) -> None:
process = MagicMock()
process.returncode = 0
process.communicate.return_value = (b"ok\n", b"")
payload = {
"command": ["python3", "-V"],
"credential_route_refs": ["rein-openweights-openrouter-approle"],
"execution_context": {"actor": "agt", "run_id": "run-1"},
"timeout_seconds": 30,
"max_output_bytes": 1024,
}
with patch(
"sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process
) as popen:
result = _run(payload, tmp_path)
assert popen.call_args.args[0] == ["python3", "-V"]
assert popen.call_args.kwargs["cwd"] == tmp_path
child_env = popen.call_args.kwargs["env"]
assert child_env["SANDBOXER_ACTOR"] == "agt"
assert child_env["SANDBOXER_RUN_ID"] == "run-1"
assert "rein-openweights-openrouter-approle" in child_env[
"SANDBOXER_CREDENTIAL_ROUTE_REFS"
]
assert set(child_env) == {
"HOME",
"LANG",
"PATH",
"SANDBOXER_CREDENTIAL_ROUTE_REFS",
"SANDBOXER_ACTOR",
"SANDBOXER_RUN_ID",
}
assert result["stdout"] == "ok\n"
def test_execute_refuses_workspace_outside_owner_base(tmp_path) -> None:
outside = tmp_path / "source-checkout"
outside.mkdir()
ext = BwrapExtension({"base_dir": str(tmp_path / "sandboxes")})
handle = {"sandbox_id": "abc12345", "pid": "42", "workspace_dir": str(outside)}
with (
patch.object(BwrapExtension, "_pid_alive", return_value=True),
pytest.raises(RuntimeError, match="outside owner-managed"),
):
ext.execute(
handle,
["true"],
credential_route_refs=[],
execution_context={},
timeout_seconds=30,
max_output_bytes=1024,
)
def test_runner_bounds_output_and_normalizes_timeout(tmp_path) -> None:
process = MagicMock()
process.pid = 99
process.communicate.side_effect = [
subprocess.TimeoutExpired("cmd", 1),
(b"123456", b"abcdef"),
]
payload = {
"command": ["sleep", "2"],
"credential_route_refs": [],
"execution_context": {},
"timeout_seconds": 1,
"max_output_bytes": 3,
}
with (
patch("sandboxer.extensions.bwrap_runner.subprocess.Popen", return_value=process),
patch("sandboxer.extensions.bwrap_runner.os.killpg") as killpg,
):
result = _run(payload, tmp_path)
killpg.assert_called_once_with(99, signal.SIGKILL)
assert result["exit_code"] == 124
assert result["timed_out"] is True
assert result["stdout"] == "123"
assert result["stderr"] == "abc"
assert result["output_truncated"] is True