Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
273 lines
11 KiB
Python
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
|