Implement ext.bwrap: local bubblewrap namespace extension (SAND-WP-0013)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Adds the first local, same-host, kernel-namespace-only sandbox
extension: no SSH hop, no container runtime. Extends IsolationSpec.level
with "process", implements BwrapExtension (provision/wait_ready/
teardown) spawning bwrap with unshared user/mount/pid/ipc/uts/net
namespaces, registers ext.bwrap + profile.bwrap-local, and extends
manager._handle_from_status to carry pid/workspace_dir. Verified with a
live bwrap smoke run in addition to the mocked test suite.

T04 (reachability vs. the SSH-based glas-harness consumer contract)
deliberately left open pending glas-harness's harness contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-26 12:37:56 +02:00
parent d2186e8ac8
commit 76c38e758c
8 changed files with 484 additions and 6 deletions

View file

@ -65,6 +65,8 @@ class SandboxManager:
"endpoint": status.inputs.get("endpoint", ""),
"provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""),
"provider": status.inputs.get("provider", ""),
"pid": status.inputs.get("pid", ""),
"workspace_dir": status.inputs.get("workspace_dir", ""),
}
def _resolved_host(self, profile, extension, host_override: str | None) -> str:
@ -154,6 +156,8 @@ class SandboxManager:
status.inputs["endpoint"] = handle.get("endpoint", "")
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
status.inputs["provider"] = handle.get("provider", "")
status.inputs["pid"] = handle.get("pid", "")
status.inputs["workspace_dir"] = handle.get("workspace_dir", "")
reach = backend.wait_ready(handle)
reach = enrich_reachability(reach, profile, handle)
status.reachability = Reachability(**reach)

View file

@ -0,0 +1,140 @@
"""ext.bwrap — local, same-host, bubblewrap namespace isolation (SAND-WP-0013)."""
from __future__ import annotations
import os
import shutil
import signal
import subprocess
from pathlib import Path
from typing import Any
from sandboxer.extensions.base import SandboxExtension
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 long-running placeholder process (`sleep infinity`) whose pid is
the handle's exec target. `--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.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) -> list[str]:
argv = [
self._bwrap_bin(),
"--die-with-parent",
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
"--unshare-cgroup",
"--unshare-net",
"--tmpfs",
"/",
"--proc",
"/proc",
"--dev",
"/dev",
]
for path in self._existing_ro_binds():
argv += ["--ro-bind", path, path]
argv += ["--bind", workspace_dir, workspace_dir]
argv += ["--chdir", workspace_dir]
argv += ["sleep", "infinity"]
return argv
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
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)
argv = self._bwrap_argv(workspace_dir)
proc = subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return {
"sandbox_id": sandbox_id,
"host": host,
"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}")
return {
"host": handle.get("host", "localhost"),
"endpoint": f"pid:{pid}",
}
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):
try:
os.kill(pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
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

View file

@ -41,7 +41,7 @@ class Consumer(BaseModel):
class IsolationSpec(BaseModel):
level: Literal["container", "microvm", "policy"] = "container"
level: Literal["container", "microvm", "policy", "process"] = "container"
class NetworkSpec(BaseModel):