sand-boxer/src/sandboxer/extensions/bwrap.py
tegwick d69827aaa2 feat: pin bwrap rein runtimes and isolate private state
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
2026-09-05 20:36:11 +02:00

273 lines
11 KiB
Python

"""ext.bwrap — local, same-host, bubblewrap namespace isolation (SAND-WP-0013)."""
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
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.runtime import RUNTIME_MOUNT, verified_runtime
from sandboxer.models import Profile
class BwrapExtension(SandboxExtension):
"""Provision a local sandbox via bubblewrap (bwrap) kernel namespaces.
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 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.
"""
def __init__(self, config: dict[str, Any] | None = None) -> None:
super().__init__(config)
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"]
)
def _bwrap_bin(self) -> str:
return os.environ.get("SANDBOXER_BWRAP_BIN", self.bwrap_bin)
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, *, info_fd: int | None = None) -> list[str]:
argv = [
self._bwrap_bin(),
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
"--unshare-cgroup",
"--unshare-net",
"--tmpfs",
"/",
"--proc",
"/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"]
runtime = verified_runtime(self.config)
if runtime is not None:
workspace = Path(workspace_dir).resolve()
if runtime.is_relative_to(workspace) or workspace.is_relative_to(runtime):
raise ValueError("runtime and workspace must not overlap")
argv += ["--dir", "/opt", "--dir", "/opt/sandboxer"]
argv += ["--ro-bind", str(runtime), RUNTIME_MOUNT]
argv += ["--bind", workspace_dir, workspace_dir]
argv += ["--chdir", workspace_dir]
argv += [
"/usr/bin/python3",
"/run/sandboxer/bwrap_runner.py",
workspace_dir,
f"{workspace_dir}/{self.control_socket_name}",
]
if runtime is not None:
argv.append("--runtime")
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]:
if profile.network.default != "deny" or profile.network.egress:
raise ValueError("bwrap currently supports only default-deny with empty egress")
if profile.setup.secret_refs:
raise ValueError("bwrap has no setup credential delivery contract")
runtime = verified_runtime(self.config)
if runtime is not None and inputs.get("repo"):
source = Path(inputs["repo"]).resolve()
if source.is_relative_to(runtime) or runtime.is_relative_to(source):
raise ValueError("runtime and source checkout must not overlap")
sandbox_id = self.new_sandbox_id(inputs)
workspace_dir = f"{self.base_dir}/{sandbox_id}"
Path(workspace_dir).mkdir(parents=True, exist_ok=True)
repo = inputs.get("repo")
if repo:
repo_path = Path(repo).expanduser().resolve()
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)
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(child_pid),
"supervisor_pid": str(proc.pid),
"workspace_dir": workspace_dir,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
pid = int(handle["pid"])
if not self._pid_alive(pid):
raise RuntimeError(f"bwrap process {pid} is not running")
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,
stdin_text: str | None = None,
) -> 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,
"stdin_text": stdin_text,
}
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
if pid_str and self._pid_alive(int(pid_str)):
pid = int(pid_str)
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
with suppress(ProcessLookupError, PermissionError):
os.kill(pid, signal.SIGKILL)
killed = True
workspace_dir = handle.get("workspace_dir", "")
removed = False
if workspace_dir and Path(workspace_dir).exists():
shutil.rmtree(workspace_dir, ignore_errors=True)
removed = not Path(workspace_dir).exists()
return {
"process_killed": str(killed),
"workspace_removed": str(removed),
"workspace_dir": workspace_dir,
}
@staticmethod
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except (ProcessLookupError, PermissionError):
return False
return True