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,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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue