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

@ -2,10 +2,15 @@
from __future__ import annotations
import json
import os
import select
import shutil
import signal
import socket
import subprocess
import time
from contextlib import suppress
from pathlib import Path
from typing import Any
@ -19,8 +24,8 @@ class BwrapExtension(SandboxExtension):
Unlike ext.compose-ssh / ext.vm-packer, this extension never leaves the
local host: no SSH hop, no container runtime, no remote placement. A new
user/mount/pid/ipc/uts/net namespace is created per sandbox, kept alive
by a long-running placeholder process (`sleep infinity`) whose pid is
the handle's exec target. `--unshare-net` with no veth/interface makes
by a minimal command broker whose namespace pid is retained for lifecycle
evidence and teardown. `--unshare-net` with no veth/interface makes
`network.default: deny` real, rather than declarative-only like the
other self-hosted extensions.
"""
@ -30,6 +35,9 @@ class BwrapExtension(SandboxExtension):
cfg = self.config
self.base_dir: str = cfg.get("base_dir", "/tmp/sandboxer-bwrap")
self.bwrap_bin: str = cfg.get("bwrap_bin", "bwrap")
self.control_socket_name: str = cfg.get(
"control_socket_name", ".sandboxer-owner.sock"
)
self.ro_binds: list[str] = cfg.get(
"ro_binds", ["/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf"]
)
@ -40,7 +48,7 @@ class BwrapExtension(SandboxExtension):
def _existing_ro_binds(self) -> list[str]:
return [path for path in self.ro_binds if Path(path).exists()]
def _bwrap_argv(self, workspace_dir: str) -> list[str]:
def _bwrap_argv(self, workspace_dir: str, *, info_fd: int | None = None) -> list[str]:
argv = [
self._bwrap_bin(),
"--die-with-parent",
@ -56,14 +64,42 @@ class BwrapExtension(SandboxExtension):
"/proc",
"--dev",
"/dev",
"--clearenv",
]
if info_fd is not None:
argv += ["--info-fd", str(info_fd)]
for path in self._existing_ro_binds():
argv += ["--ro-bind", path, path]
runner = Path(__file__).with_name("bwrap_runner.py")
argv += ["--dir", "/run", "--dir", "/run/sandboxer"]
argv += ["--ro-bind", str(runner), "/run/sandboxer/bwrap_runner.py"]
argv += ["--bind", workspace_dir, workspace_dir]
argv += ["--chdir", workspace_dir]
argv += ["sleep", "infinity"]
argv += [
"/usr/bin/python3",
"/run/sandboxer/bwrap_runner.py",
workspace_dir,
f"{workspace_dir}/{self.control_socket_name}",
]
return argv
@staticmethod
def _read_child_pid(proc: subprocess.Popen, info_fd: int) -> int:
ready, _, _ = select.select([info_fd], [], [], 10)
if not ready:
proc.kill()
raise RuntimeError("timed out waiting for bwrap namespace child pid")
raw = os.read(info_fd, 16_384)
try:
child_pid = int(json.loads(raw)["child-pid"])
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
proc.kill()
raise RuntimeError("bwrap did not report a valid namespace child pid") from exc
if child_pid <= 0:
proc.kill()
raise RuntimeError("bwrap reported an invalid namespace child pid")
return child_pid
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
@ -77,19 +113,30 @@ class BwrapExtension(SandboxExtension):
if not repo_path.exists():
raise FileNotFoundError(f"Repo path does not exist: {repo_path}")
shutil.copytree(repo_path, workspace_dir, dirs_exist_ok=True)
Path(workspace_dir).chmod(0o700)
argv = self._bwrap_argv(workspace_dir)
proc = subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
info_read_fd, info_write_fd = os.pipe()
try:
argv = self._bwrap_argv(workspace_dir, info_fd=info_write_fd)
proc = subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
pass_fds=(info_write_fd,),
)
finally:
os.close(info_write_fd)
try:
child_pid = self._read_child_pid(proc, info_read_fd)
finally:
os.close(info_read_fd)
return {
"sandbox_id": sandbox_id,
"host": host,
"pid": str(proc.pid),
"pid": str(child_pid),
"supervisor_pid": str(proc.pid),
"workspace_dir": workspace_dir,
}
@ -100,11 +147,79 @@ class BwrapExtension(SandboxExtension):
workspace_dir = handle["workspace_dir"]
if not Path(workspace_dir).is_dir():
raise RuntimeError(f"workspace missing: {workspace_dir}")
control_socket = Path(workspace_dir) / self.control_socket_name
deadline = time.monotonic() + 5
while not control_socket.is_socket():
if not self._pid_alive(pid):
raise RuntimeError(f"bwrap process {pid} exited before control became ready")
if time.monotonic() >= deadline:
raise RuntimeError("bwrap owner control socket did not become ready")
time.sleep(0.05)
return {
"host": handle.get("host", "localhost"),
"endpoint": f"pid:{pid}",
}
def supports_execution(self) -> bool:
return True
def _validated_workspace(self, handle: dict[str, str]) -> Path:
sandbox_id = handle.get("sandbox_id", "")
if not sandbox_id or "/" in sandbox_id or sandbox_id in {".", ".."}:
raise RuntimeError("invalid sandbox id in execution handle")
workspace_value = handle.get("workspace_dir", "")
if not workspace_value:
raise RuntimeError("sandbox execution handle has no workspace")
workspace = Path(workspace_value).resolve(strict=True)
expected = (Path(self.base_dir).resolve() / sandbox_id).resolve()
if workspace != expected or not workspace.is_dir():
raise RuntimeError("refusing execution outside owner-managed sandbox workspace")
return workspace
def execute(
self,
handle: dict[str, str],
command: list[str],
*,
credential_route_refs: list[str],
execution_context: dict[str, str],
timeout_seconds: int,
max_output_bytes: int,
) -> dict[str, object]:
"""Ask the broker already inside bwrap to run an argument-vector command."""
pid = int(handle.get("pid", "0"))
if pid <= 0 or not self._pid_alive(pid):
raise RuntimeError(f"bwrap process {pid} is not running")
workspace = self._validated_workspace(handle)
request = {
"command": command,
"credential_route_refs": credential_route_refs,
"execution_context": execution_context,
"timeout_seconds": timeout_seconds,
"max_output_bytes": max_output_bytes,
}
response_limit = max_output_bytes * 2 + 65_536
chunks: list[bytes] = []
size = 0
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(timeout_seconds + 5)
client.connect(str(workspace / self.control_socket_name))
client.sendall(json.dumps(request).encode("utf-8"))
client.shutdown(socket.SHUT_WR)
while True:
chunk = client.recv(65_536)
if not chunk:
break
size += len(chunk)
if size > response_limit:
raise RuntimeError("bwrap owner response exceeded its declared bound")
chunks.append(chunk)
response = json.loads(b"".join(chunks))
if "boundary_error" in response:
raise RuntimeError(f"bwrap owner command boundary failed: {response['boundary_error']}")
return response
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
pid_str = handle.get("pid", "")
killed = False
@ -113,10 +228,8 @@ class BwrapExtension(SandboxExtension):
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
with suppress(ProcessLookupError, PermissionError):
os.kill(pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
killed = True
workspace_dir = handle.get("workspace_dir", "")