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:
tegwick 2026-09-04 22:12:19 +02:00
parent 877676d1f1
commit d79e3fe358
23 changed files with 1321 additions and 86 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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")

View file

@ -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", "")

View 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())

View file

@ -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)

View file

@ -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

View file

@ -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"]

View file

@ -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