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

@ -47,6 +47,23 @@ class SandboxExtension(ABC):
"""Optional post-destroy actual cost in USD."""
return None
def supports_execution(self) -> bool:
"""Whether the owner can run a command inside an established sandbox."""
return False
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]:
"""Run a bounded command through an owner-mediated sandbox boundary."""
raise NotImplementedError(f"{type(self).__name__} does not support execution")
def supports_snapshots(self) -> bool:
"""Whether this extension implements checkpoint snapshot/restore."""
return False
@ -63,4 +80,4 @@ class SandboxExtension(ABC):
host: str,
) -> dict[str, str]:
"""Provision a new sandbox from a prior checkpoint."""
raise NotImplementedError(f"{type(self).__name__} does not support restore")
raise NotImplementedError(f"{type(self).__name__} does not support restore")

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", "")

View file

@ -0,0 +1,99 @@
"""Minimal command broker launched inside an ext.bwrap namespace."""
from __future__ import annotations
import json
import os
import signal
import socket
import subprocess
import sys
from pathlib import Path
_MAX_REQUEST_BYTES = 1_048_576
def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]:
raw = value or b""
truncated = len(raw) > limit
if truncated:
raw = raw[:limit]
return raw.decode("utf-8", errors="replace"), truncated
def _run(payload: dict, workspace: Path) -> dict[str, object]:
command = payload["command"]
timeout_seconds = int(payload["timeout_seconds"])
max_output_bytes = int(payload["max_output_bytes"])
credential_refs = payload.get("credential_route_refs", [])
context = payload.get("execution_context", {})
child_env = {
"HOME": str(workspace),
"LANG": "C.UTF-8",
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"SANDBOXER_CREDENTIAL_ROUTE_REFS": json.dumps(credential_refs),
**{f"SANDBOXER_{key.upper()}": value for key, value in context.items()},
}
timed_out = False
process = subprocess.Popen(
command,
cwd=workspace,
env=child_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
try:
stdout_raw, stderr_raw = process.communicate(timeout=timeout_seconds)
exit_code = process.returncode
except subprocess.TimeoutExpired:
timed_out = True
os.killpg(process.pid, signal.SIGKILL)
stdout_raw, stderr_raw = process.communicate()
exit_code = 124
stdout, stdout_truncated = _bounded_output(stdout_raw, max_output_bytes)
stderr, stderr_truncated = _bounded_output(stderr_raw, max_output_bytes)
return {
"exit_code": exit_code,
"timed_out": timed_out,
"stdout": stdout,
"stderr": stderr,
"output_truncated": stdout_truncated or stderr_truncated,
"workspace_dir": str(workspace),
}
def main() -> int:
workspace = Path(sys.argv[1]).resolve(strict=True)
socket_path = Path(sys.argv[2])
socket_path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
server.bind(str(socket_path))
socket_path.chmod(0o600)
server.listen(8)
while True:
connection, _ = server.accept()
with connection:
chunks: list[bytes] = []
size = 0
while True:
chunk = connection.recv(65_536)
if not chunk:
break
size += len(chunk)
if size > _MAX_REQUEST_BYTES:
chunks = []
break
chunks.append(chunk)
try:
if not chunks:
raise ValueError("empty or oversized execution request")
response = _run(json.loads(b"".join(chunks)), workspace)
except Exception as exc:
response = {"boundary_error": str(exc)}
connection.sendall(json.dumps(response).encode("utf-8"))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -25,6 +25,19 @@ class ExtensionBackend(Protocol):
def teardown(self, handle: dict[str, str]) -> dict[str, str]: ...
def supports_execution(self) -> bool: ...
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]: ...
def extensions_dir() -> Path:
return _EXTENSIONS_DIR
@ -71,4 +84,4 @@ def resolve_backend(extension: Extension) -> ExtensionBackend:
raise ValueError(f"Invalid handler for {extension.id}: {extension.handler}")
module = importlib.import_module(module_path)
cls = getattr(module, attr)
return cls(extension.config)
return cls(extension.config)