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,13 +2,18 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
import hmac
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.models import (
|
||||
ExpireActionResult,
|
||||
ExtendTtlRequest,
|
||||
SandboxCreateRequest,
|
||||
SandboxExecRequest,
|
||||
SandboxExecResult,
|
||||
SandboxStatus,
|
||||
SnapshotRecord,
|
||||
SnapshotRestoreRequest,
|
||||
|
|
@ -18,6 +23,16 @@ app = FastAPI(title="sand-boxer", version="0.0.0")
|
|||
_manager = SandboxManager()
|
||||
|
||||
|
||||
def _authorize_exec(authorization: str | None) -> None:
|
||||
"""Require an explicit owner-service capability on the high-risk exec route."""
|
||||
expected = os.environ.get("SANDBOXER_EXEC_TOKEN")
|
||||
if not expected:
|
||||
raise HTTPException(status_code=503, detail="owner execution API is not configured")
|
||||
scheme, _, supplied = (authorization or "").partition(" ")
|
||||
if scheme.lower() != "bearer" or not hmac.compare_digest(supplied, expected):
|
||||
raise HTTPException(status_code=401, detail="invalid owner execution credential")
|
||||
|
||||
|
||||
@app.post("/v1/sandboxes", response_model=SandboxStatus)
|
||||
def create_sandbox(request: SandboxCreateRequest, host: str | None = None) -> SandboxStatus:
|
||||
try:
|
||||
|
|
@ -42,6 +57,25 @@ def get_sandbox_reachability(sandbox_id: str) -> dict:
|
|||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post("/v1/sandboxes/{sandbox_id}/exec", response_model=SandboxExecResult)
|
||||
def execute_in_sandbox(
|
||||
sandbox_id: str,
|
||||
request: SandboxExecRequest,
|
||||
authorization: str | None = Header(default=None),
|
||||
) -> SandboxExecResult:
|
||||
_authorize_exec(authorization)
|
||||
try:
|
||||
return _manager.execute(sandbox_id, request)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes", response_model=list[SandboxStatus])
|
||||
def list_sandboxes() -> list[SandboxStatus]:
|
||||
return _manager.list()
|
||||
|
|
@ -117,4 +151,4 @@ def extend_sandbox_ttl(sandbox_id: str, request: ExtendTtlRequest) -> SandboxSta
|
|||
|
||||
@app.post("/v1/sandboxes/expire", response_model=list[ExpireActionResult])
|
||||
def expire_sandboxes(apply: bool = False) -> list[ExpireActionResult]:
|
||||
return _manager.expire(apply=apply)
|
||||
return _manager.expire(apply=apply)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import typer
|
|||
from sandboxer import __version__
|
||||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.defaults import resolve_create_defaults
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest, SandboxExecRequest
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
|
|
@ -93,6 +93,10 @@ def sandbox_create(
|
|||
project: Annotated[str, typer.Option(help="Calling project id")] = "sand-boxer",
|
||||
host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
|
||||
ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None,
|
||||
session_id: Annotated[
|
||||
str | None, typer.Option(help="Governed consumer session id")
|
||||
] = None,
|
||||
run_id: Annotated[str | None, typer.Option(help="Governed consumer run id")] = None,
|
||||
) -> None:
|
||||
"""Provision a sandbox. No args → canary self-deploy of sand-boxer.
|
||||
|
||||
|
|
@ -104,7 +108,12 @@ def sandbox_create(
|
|||
request = SandboxCreateRequest(
|
||||
profile=resolved_profile,
|
||||
inputs=resolved_inputs,
|
||||
consumer=Consumer(actor=ActorType(actor), project=project),
|
||||
consumer=Consumer(
|
||||
actor=ActorType(actor),
|
||||
project=project,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
),
|
||||
ttl=ttl,
|
||||
)
|
||||
manager = SandboxManager()
|
||||
|
|
@ -130,6 +139,51 @@ def reachability_show(sandbox_id: str) -> None:
|
|||
_print_json(report)
|
||||
|
||||
|
||||
@app.command("exec", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
||||
def sandbox_exec(
|
||||
ctx: typer.Context,
|
||||
sandbox_id: Annotated[str, typer.Argument(help="Ready sandbox id")],
|
||||
actor: Annotated[str, typer.Option(help="Recorded consumer actor")],
|
||||
project: Annotated[str, typer.Option(help="Recorded consumer project")],
|
||||
session_id: Annotated[
|
||||
str | None, typer.Option(help="Recorded consumer session id")
|
||||
] = None,
|
||||
run_id: Annotated[str | None, typer.Option(help="Recorded consumer run id")] = None,
|
||||
credential_route_ref: Annotated[
|
||||
list[str] | None,
|
||||
typer.Option(help="Value-free credential catalog route (repeatable)"),
|
||||
] = None,
|
||||
timeout: Annotated[int, typer.Option(help="Command timeout in seconds")] = 900,
|
||||
max_output_bytes: Annotated[
|
||||
int, typer.Option(help="Per-stream captured output limit")
|
||||
] = 262_144,
|
||||
) -> None:
|
||||
"""Run COMMAND inside a bwrap sandbox through its owning process."""
|
||||
command = list(ctx.args)
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
if not command:
|
||||
raise typer.BadParameter("COMMAND is required after --")
|
||||
request = SandboxExecRequest(
|
||||
command=command,
|
||||
consumer=Consumer(
|
||||
actor=ActorType(actor),
|
||||
project=project,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
),
|
||||
credential_route_refs=credential_route_ref or [],
|
||||
timeout_seconds=timeout,
|
||||
max_output_bytes=max_output_bytes,
|
||||
)
|
||||
try:
|
||||
result = SandboxManager().execute(sandbox_id, request)
|
||||
except (KeyError, PermissionError, RuntimeError, ValueError) as exc:
|
||||
typer.echo(f"Error: {exc}", err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
_print_json(result.model_dump(mode="json"))
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def sandbox_get(sandbox_id: str) -> None:
|
||||
"""Get sandbox status by id."""
|
||||
|
|
@ -338,4 +392,4 @@ def credits_add(
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
app()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from sandboxer.extensions.registry import load_extension, resolve_backend
|
||||
from sandboxer.lifecycle.expire import (
|
||||
ExpireCandidate,
|
||||
|
|
@ -17,6 +21,8 @@ from sandboxer.models import (
|
|||
MeterRecord,
|
||||
Reachability,
|
||||
SandboxCreateRequest,
|
||||
SandboxExecRequest,
|
||||
SandboxExecResult,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
SnapshotRecord,
|
||||
|
|
@ -39,6 +45,8 @@ from sandboxer.telemetry.introspection import (
|
|||
|
||||
|
||||
class SandboxManager:
|
||||
_CREDENTIAL_ROUTE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: SandboxStore | None = None,
|
||||
|
|
@ -48,6 +56,7 @@ class SandboxManager:
|
|||
self.store = store or SandboxStore()
|
||||
self.credits = credits or CreditsStore()
|
||||
self.snapshots = snapshots or SnapshotStore()
|
||||
self._exec_locks: dict[str, Lock] = {}
|
||||
|
||||
@staticmethod
|
||||
def _handle_from_status(status: SandboxStatus) -> dict[str, str]:
|
||||
|
|
@ -207,6 +216,138 @@ class SandboxManager:
|
|||
|
||||
return build_reachability_report(status)
|
||||
|
||||
@staticmethod
|
||||
def _validate_exec_consumer(status: SandboxStatus, request: SandboxExecRequest) -> None:
|
||||
if request.consumer != status.consumer:
|
||||
raise PermissionError(
|
||||
"execution consumer does not exactly match sandbox "
|
||||
"actor/project/session/run identity"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_exec_request(cls, request: SandboxExecRequest) -> None:
|
||||
if not request.command[0] or any("\x00" in arg for arg in request.command):
|
||||
raise ValueError("command arguments must be non-empty and contain no NUL bytes")
|
||||
command_size = sum(len(arg.encode("utf-8")) for arg in request.command)
|
||||
if command_size > 65_536:
|
||||
raise ValueError("command argument vector exceeds 65536 bytes")
|
||||
invalid_refs = [
|
||||
ref
|
||||
for ref in request.credential_route_refs
|
||||
if not cls._CREDENTIAL_ROUTE_RE.fullmatch(ref)
|
||||
]
|
||||
if invalid_refs:
|
||||
raise ValueError("credential route references must be value-free catalog identifiers")
|
||||
if len(set(request.credential_route_refs)) != len(request.credential_route_refs):
|
||||
raise ValueError("credential route references must be unique")
|
||||
|
||||
def execute(self, sandbox_id: str, request: SandboxExecRequest) -> SandboxExecResult:
|
||||
"""Run a command through the owning extension without a host fallback."""
|
||||
lock = self._exec_locks.setdefault(sandbox_id, Lock())
|
||||
if not lock.acquire(blocking=False):
|
||||
raise RuntimeError("Sandbox already has an active owner-mediated command")
|
||||
try:
|
||||
return self._execute_locked(sandbox_id, request)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
def _execute_locked(
|
||||
self, sandbox_id: str, request: SandboxExecRequest
|
||||
) -> SandboxExecResult:
|
||||
status = self.store.get(sandbox_id)
|
||||
if not status:
|
||||
raise KeyError(f"Sandbox not found: {sandbox_id}")
|
||||
self._validate_exec_request(request)
|
||||
self._validate_exec_consumer(status, request)
|
||||
if status.state != SandboxState.READY:
|
||||
raise RuntimeError(
|
||||
f"Sandbox must be ready for execution (state={status.state.value})"
|
||||
)
|
||||
now = utcnow()
|
||||
if status.expires_at and now >= status.expires_at:
|
||||
raise RuntimeError("Sandbox TTL has expired")
|
||||
|
||||
profile = load_profile(status.profile_id)
|
||||
extension = load_extension(status.extension_id)
|
||||
backend = resolve_backend(extension)
|
||||
if not backend.supports_execution():
|
||||
raise RuntimeError(
|
||||
f"Extension {status.extension_id} has no owner-mediated execution boundary"
|
||||
)
|
||||
|
||||
command_name = Path(request.command[0]).name
|
||||
status.state = SandboxState.ACTIVE
|
||||
status.updated_at = now
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=f"Owner-mediated command started ({command_name})",
|
||||
event_type=event_type_for_state(status.state),
|
||||
)
|
||||
|
||||
context = {
|
||||
"sandbox_id": status.sandbox_id,
|
||||
"profile_id": status.profile_id,
|
||||
"actor": status.consumer.actor.value,
|
||||
"project": status.consumer.project,
|
||||
}
|
||||
if status.consumer.session_id:
|
||||
context["session_id"] = status.consumer.session_id
|
||||
if status.consumer.run_id:
|
||||
context["run_id"] = status.consumer.run_id
|
||||
|
||||
try:
|
||||
execution = backend.execute(
|
||||
self._handle_from_status(status),
|
||||
request.command,
|
||||
credential_route_refs=request.credential_route_refs,
|
||||
execution_context=context,
|
||||
timeout_seconds=request.timeout_seconds,
|
||||
max_output_bytes=request.max_output_bytes,
|
||||
)
|
||||
except Exception as exc:
|
||||
status.state = SandboxState.READY
|
||||
status.updated_at = utcnow()
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=f"Owner-mediated command boundary failed ({command_name}): {exc}",
|
||||
event_type="note",
|
||||
)
|
||||
raise
|
||||
|
||||
completed_at = utcnow()
|
||||
status.state = SandboxState.READY
|
||||
status.updated_at = completed_at
|
||||
self.store.save(status)
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=(
|
||||
f"Owner-mediated command completed ({command_name}, "
|
||||
f"exit={execution['exit_code']}, timed_out={execution['timed_out']})"
|
||||
),
|
||||
event_type=event_type_for_state(status.state),
|
||||
)
|
||||
return SandboxExecResult(
|
||||
sandbox_id=status.sandbox_id,
|
||||
profile_id=status.profile_id,
|
||||
extension_id=status.extension_id,
|
||||
consumer=status.consumer,
|
||||
command_name=command_name,
|
||||
exit_code=int(execution["exit_code"]),
|
||||
timed_out=bool(execution["timed_out"]),
|
||||
stdout=str(execution["stdout"]),
|
||||
stderr=str(execution["stderr"]),
|
||||
output_truncated=bool(execution["output_truncated"]),
|
||||
duration_seconds=max(0.0, (completed_at - now).total_seconds()),
|
||||
workspace_dir=str(execution["workspace_dir"]),
|
||||
network_default=profile.network.default,
|
||||
network_egress=profile.network.egress,
|
||||
credential_route_refs=request.credential_route_refs,
|
||||
started_at=now,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
|
||||
def list(self) -> list[SandboxStatus]:
|
||||
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
|
||||
|
||||
|
|
@ -510,4 +651,4 @@ class SandboxManager:
|
|||
summary=f"Snapshot restore failed: {exc}",
|
||||
event_type=event_type_for_state(status.state),
|
||||
)
|
||||
raise
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
99
src/sandboxer/extensions/bwrap_runner.py
Normal file
99
src/sandboxer/extensions/bwrap_runner.py
Normal 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())
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -147,6 +147,36 @@ class SandboxCreateRequest(BaseModel):
|
|||
ttl: str | None = None
|
||||
|
||||
|
||||
class SandboxExecRequest(BaseModel):
|
||||
"""A command grant bound to the consumer recorded at sandbox creation."""
|
||||
|
||||
command: list[str] = Field(min_length=1, max_length=256)
|
||||
consumer: Consumer
|
||||
credential_route_refs: list[str] = Field(default_factory=list, max_length=32)
|
||||
timeout_seconds: int = Field(default=900, ge=1, le=3600)
|
||||
max_output_bytes: int = Field(default=262_144, ge=1, le=1_048_576)
|
||||
|
||||
|
||||
class SandboxExecResult(BaseModel):
|
||||
sandbox_id: str
|
||||
profile_id: str
|
||||
extension_id: str
|
||||
consumer: Consumer
|
||||
command_name: str
|
||||
exit_code: int
|
||||
timed_out: bool = False
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
output_truncated: bool = False
|
||||
duration_seconds: float
|
||||
workspace_dir: str
|
||||
network_default: Literal["deny", "allow"]
|
||||
network_egress: list[str] = Field(default_factory=list)
|
||||
credential_route_refs: list[str] = Field(default_factory=list)
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
|
||||
|
||||
class Reachability(BaseModel):
|
||||
ssh: str | None = None
|
||||
remote_dir: str | None = None
|
||||
|
|
@ -211,4 +241,4 @@ class SnapshotRecord(BaseModel):
|
|||
consumer: Consumer | None = None
|
||||
name: str | None = None
|
||||
size_bytes: int | None = None
|
||||
created_at: datetime
|
||||
created_at: datetime
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability
|
||||
|
||||
__all__ = ["enrich_reachability", "build_reachability_report"]
|
||||
__all__ = ["enrich_reachability", "build_reachability_report"]
|
||||
|
|
|
|||
|
|
@ -55,20 +55,6 @@ def ssh_one_liner(reach: Reachability) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def local_exec_hint(reach: Reachability) -> str | None:
|
||||
"""No-SSH-hop exec hint for same-host extensions (e.g. ext.bwrap).
|
||||
|
||||
Consumers exec directly into the sandbox's namespaces via the
|
||||
placeholder process's pid, rather than opening an SSH channel.
|
||||
"""
|
||||
if reach.pid and reach.workspace_dir:
|
||||
return (
|
||||
f"nsenter --target {reach.pid} --mount --pid --net --uts --ipc "
|
||||
f"-- sh -c 'cd {reach.workspace_dir} && exec $SHELL'"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
|
||||
"""Consumer-facing reachability report with ops-bridge pointer."""
|
||||
reach = status.reachability
|
||||
|
|
@ -84,5 +70,11 @@ def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
|
|||
}
|
||||
if reach:
|
||||
payload["ssh_one_liner"] = ssh_one_liner(reach)
|
||||
payload["local_exec_hint"] = local_exec_hint(reach)
|
||||
return payload
|
||||
if status.extension_id == "ext.bwrap":
|
||||
payload["execution"] = {
|
||||
"mode": "owner-mediated",
|
||||
"cli": f"sandboxer exec {status.sandbox_id} [identity options] -- COMMAND...",
|
||||
"api": f"POST /v1/sandboxes/{status.sandbox_id}/exec",
|
||||
"note": "Direct namespace entry is unsupported; invoke the sand-boxer owner",
|
||||
}
|
||||
return payload
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue