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:
parent
877676d1f1
commit
d79e3fe358
23 changed files with 1321 additions and 86 deletions
|
|
@ -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", "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue