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:
parent
c2886b2f77
commit
d69827aaa2
12 changed files with 639 additions and 9 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
52
src/sandboxer/extensions/runtime.py
Normal file
52
src/sandboxer/extensions/runtime.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue