feat: pin bwrap rein runtimes and isolate private state

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0726e-5232-73f2-aaca-2c05ceb62efb
This commit is contained in:
tegwick 2026-09-05 20:36:11 +02:00
parent c2886b2f77
commit d69827aaa2
12 changed files with 639 additions and 9 deletions

View file

@ -15,6 +15,7 @@ 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
@ -72,6 +73,13 @@ class BwrapExtension(SandboxExtension):
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 += [
@ -80,6 +88,8 @@ class BwrapExtension(SandboxExtension):
workspace_dir,
f"{workspace_dir}/{self.control_socket_name}",
]
if runtime is not None:
argv.append("--runtime")
return argv
@staticmethod
@ -102,6 +112,15 @@ class BwrapExtension(SandboxExtension):
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)

View file

@ -23,7 +23,9 @@ def _bounded_output(value: bytes | None, limit: int) -> tuple[str, bool]:
return raw.decode("utf-8", errors="replace"), truncated
def _run(payload: dict, workspace: Path) -> dict[str, object]:
def _run(
payload: dict, workspace: Path, *, runtime_enabled: bool = False
) -> dict[str, object]:
command = payload["command"]
timeout_seconds = int(payload["timeout_seconds"])
max_output_bytes = int(payload["max_output_bytes"])
@ -31,12 +33,20 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
context = payload.get("execution_context", {})
stdin_text = payload.get("stdin_text")
child_env = {
"HOME": str(workspace),
"HOME": "/run/sandboxer/state/home",
"XDG_CONFIG_HOME": "/run/sandboxer/state/config",
"XDG_CACHE_HOME": "/run/sandboxer/state/cache",
"XDG_STATE_HOME": "/run/sandboxer/state/data",
"TMPDIR": "/run/sandboxer/state/tmp",
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONNOUSERSITE": "1",
"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()},
}
if runtime_enabled:
child_env["PATH"] = "/opt/sandboxer/runtime/bin:" + child_env["PATH"]
timed_out = False
process = subprocess.Popen(
command,
@ -73,6 +83,13 @@ def _run(payload: dict, workspace: Path) -> dict[str, object]:
def main() -> int:
workspace = Path(sys.argv[1]).resolve(strict=True)
socket_path = Path(sys.argv[2])
runtime_enabled = sys.argv[3:] == ["--runtime"]
if sys.argv[3:] and not runtime_enabled:
raise ValueError("invalid owner runtime mode")
state = Path("/run/sandboxer/state")
state.mkdir(mode=0o700)
for name in ("home", "config", "cache", "data", "tmp"):
(state / name).mkdir(mode=0o700)
socket_path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
server.bind(str(socket_path))
@ -95,7 +112,9 @@ def main() -> int:
try:
if not chunks:
raise ValueError("empty or oversized execution request")
response = _run(json.loads(b"".join(chunks)), workspace)
response = _run(
json.loads(b"".join(chunks)), workspace, runtime_enabled=runtime_enabled
)
except Exception as exc:
response = {"boundary_error": str(exc)}
connection.sendall(json.dumps(response).encode("utf-8"))

View file

@ -0,0 +1,52 @@
"""Digest-pinned, owner-configured Python runtimes for bwrap."""
from __future__ import annotations
import hashlib
import json
import os
import stat
from pathlib import Path
RUNTIME_MOUNT = "/opt/sandboxer/runtime"
def runtime_digest(root: Path) -> str:
"""Hash the complete artifact, including modes and internal symlink targets."""
if not root.is_absolute() or root.resolve(strict=True) != root or not root.is_dir():
raise ValueError("runtime path must be a canonical absolute directory")
records = []
for path in sorted(root.rglob("*")):
mode = path.lstat().st_mode
if stat.S_ISLNK(mode):
target = os.readlink(path)
if Path(target).is_absolute() or not path.resolve(strict=True).is_relative_to(root):
raise ValueError("runtime symlink escapes the artifact")
value = ["symlink", target]
elif stat.S_ISREG(mode):
with path.open("rb") as stream:
value = ["file", hashlib.file_digest(stream, "sha256").hexdigest()]
elif stat.S_ISDIR(mode):
value = ["directory"]
else:
raise ValueError("runtime contains an unsupported special file")
records.append([path.relative_to(root).as_posix(), stat.S_IMODE(mode), *value])
return hashlib.sha256(json.dumps(records, separators=(",", ":")).encode()).hexdigest()
def verified_runtime(config: dict) -> Path | None:
"""Only owner extension configuration can select a runtime artifact."""
runtime = config.get("runtime")
if runtime is None:
return None
if not isinstance(runtime, dict) or set(runtime) != {"path", "sha256"}:
raise ValueError("runtime requires exactly path and sha256")
if not isinstance(runtime["path"], str) or not isinstance(runtime["sha256"], str):
raise ValueError("runtime path and sha256 must be strings")
root = Path(runtime["path"])
if (not (root / "pyvenv.cfg").is_file() or not (root / "bin/python3").is_file()
or (root / ".git").exists()):
raise ValueError("runtime must be a standalone Python environment, not a checkout")
if runtime_digest(root) != runtime["sha256"]:
raise ValueError("runtime artifact digest does not match owner configuration")
return root

View file

@ -188,8 +188,8 @@ class Reachability(BaseModel):
tunnel_via: str | None = None
identity: str | None = None
# Local (no-SSH-hop) descriptor — populated for same-host extensions
# like ext.bwrap. A consumer execs into the sandbox directly
# (e.g. `nsenter --target <pid> ...`) rather than over SSH.
# like ext.bwrap. Commands enter through owner-mediated execute;
# the pid is lifecycle metadata, not consumer setns authority.
pid: str | None = None
workspace_dir: str | None = None