01 — Domain Model¶
Entities¶
Sandbox¶
A live, isolated execution environment with an identity and a lifecycle.
- MUST expose a stable
idunique within its backend for the sandbox's lifetime. - MUST expose
capabilities: Capabilityandisolation: IsolationTierreflecting this sandbox instance, not a static backend default — a backend MAY produce sandboxes with differing capabilities depending on template or options. - MUST be an async context manager whose exit tears the sandbox down, including on exception and on cancellation (05).
- MUST expose
.filesand.native.
Lifecycle states: creating → running → (killed | failed). paused is deferred to
.native until at least two backends support it stably (ADR-0003).
Process¶
A single execution within a sandbox, returned by stream(). An async context manager that
iterates tagged output chunks and terminates in an ExecResult
(ADR-0019).
- MUST terminate the remote process on context exit if it is still running — on normal
completion, early
break, exception, or cancellation. - MUST distinguish stdout from stderr, and MUST preserve ordering within each stream. Ordering between the two streams is explicitly not guaranteed.
- MUST expose the terminal
ExecResult, includingexit_code, viawait().
FileSystem¶
Read/write access scoped to one sandbox. Never host filesystem access.
Template¶
A base environment specification: a container image reference, or a provider template id. sandboxio does not build images in v0.1.
Session¶
Post-MVP. A logical, resumable binding of tenant or agent-run to sandbox. Not in v0.1;
the v0.1 equivalent is metadata labelling (05).
Value objects¶
All value objects MUST be frozen dataclasses with value equality. Callers compare with ==,
never is (Q13).
@dataclass(frozen=True)
class Resources:
cpu: float | None = None # cores; None = backend default, never unbounded
memory_mb: int | None = None
disk_mb: int | None = None
gpu: str | None = None # e.g. "T4"; CapabilityNotSupported if unsupported
@dataclass(frozen=True)
class NetworkPolicy:
egress: Literal["deny", "allow", "learn"] = "deny"
allow: tuple[str, ...] = () # hostnames and/or CIDRs
ingress_ports: tuple[int, ...] = ()
@dataclass(frozen=True)
class RichOutput:
mime_type: str # e.g. "text/html", "image/png"
data: str # binary payloads are base64 text
@dataclass(frozen=True)
class FileInfo:
path: str # sandbox-internal
size: int
is_dir: bool
Resourcesdefaults MUST resolve to modest concrete caps, never "unlimited" (05). Where the caps belong to the template rather than the sandbox, a non-defaultResources(...)is refused, never ignored (05).egress="learn"is v0.2 and MUST raiseCapabilityNotSupportedin v0.1 rather than silently behaving asdeny.
ManagedSandbox¶
@dataclass(frozen=True)
class ManagedSandbox:
sandbox_id: str
backend: str
state: Literal["running", "stopped", "paused"]
created_at: datetime | None = None
labels: dict[str, str] = field(default_factory=dict) # the caller's metadata, as stored
What ReapableBackend.list_managed() returns and
sandboxio reap prints. Not a Sandbox: nothing can be executed through it.
Capability¶
A Flag enum. Additions are backwards compatible; removals are breaking.
class Capability(Flag):
RUN_COMMAND = auto(); RUN_CODE = auto(); STATEFUL_CODE = auto()
STREAMING = auto(); FILESYSTEM = auto(); UPLOAD_DOWNLOAD = auto()
PTY = auto(); PAUSE_RESUME = auto(); SNAPSHOT_FORK = auto()
GPU = auto(); LSP = auto(); GIT = auto(); NETWORK_POLICY = auto(); TUNNELS = auto()
COST_REPORTING = auto() # post-hoc cost attribution by label (v0.2)
Rules:
- A declared capability MUST have a passing contract test for that backend.
- An undeclared capability, if invoked, MUST raise
CapabilityNotSupported— never a silent no-op, never a degraded emulation. - Capabilities are discovered, not inferred from backend name. Callers branch on flags.
Backend notes for v0.1:
- Docker declares
STATEFUL_CODEoff.run_code(context_id=...)raisesCapabilityNotSupported;run_codewithout a context is a one-shot exec (ADR-0024).resultsstaysNoneand MUST NOT be synthesised from stdout. - Docker declares
NETWORK_POLICYfor deny/allow-all only. A non-empty allowlist raises (ADR-0023). - E2B takes its resource caps from the template.
Resources(cpu=…),memory_mbanddisk_mbare refused withConfigurationErrorrather than ignored (05).Resourcesis not aCapability, so this is a refusal, not an undeclared flag.
IsolationTier¶
class IsolationTier(Enum):
UNKNOWN = "unknown" # undeclared or unverified — satisfies NO requirement
CONTAINER = "container" # runc, shared kernel — trusted/dev/CI code only
GVISOR = "gvisor" # user-space kernel — defence in depth, not VM-equivalent
MICROVM = "microvm" # dedicated guest kernel — floor for untrusted multi-tenant
Ordering is an explicit internal rank map (UNKNOWN 0, CONTAINER 10, GVISOR 20,
MICROVM 30), not a property of the member values (ADR-0018).
- The rank orders one thing only: resistance to kernel escape by an adversarial tenant, under the threat model in 05. It MUST NOT be presented as a general "more secure" scale.
- Rich comparisons (
<,<=,>,>=) derive from the rank; arithmetic is not available.tier.satisfies(minimum)is the form the docs teach. - Ranks are spaced by 10 so a tier can be inserted without renumbering. Adding a stronger
tier means an existing
require_isolationaccepts it automatically. - Every member MUST have a rank, asserted by a test that enumerates the class.
- Every backend and every sandbox MUST report a tier.
- A tier above
CONTAINERMUST be justified by provider documentation, recorded with a date (ADR-0006).
UNKNOWN¶
UNKNOWNis the default for any adapter that does not declare a tier. An adapter author who says nothing has claimed nothing.- It satisfies no requirement, including
require_isolation=CONTAINER. require_isolation=UNKNOWNis meaningless and MUST raiseConfigurationError.- Creating on an
UNKNOWN-tier backend MUST emitUnverifiedIsolationWarningonce per backend per process.
| Backend | Tier | Notes |
|---|---|---|
| Docker (local) | CONTAINER |
shared kernel; trusted code only |
| E2B | MICROVM |
Firecracker |
| Modal | GVISOR |
not hardware-VM equivalent |
| Fake | CONTAINER |
executes nothing; MUST NOT be used outside tests |
| Daytona | UNKNOWN |
unverified — not claimed at any tier until documented with a date |
ExecResult¶
@dataclass(frozen=True)
class ExecResult:
exit_code: int
stdout: str
stderr: str
results: tuple[RichOutput, ...] | None = None # interpreter rich outputs; None if unsupported
meter: Meter | None = None # v0.2 — duration + backend, no cost
streamed: bool = False # True → stdout/stderr empty by construction
okproperty:exit_code == 0.raise_for_status()raisesExecutionErroron non-zero exit.- Returns MUST be dataclasses, never raw dicts —
result.stdoutmust autocomplete. resultsMUST beNonewhen the backend lacks rich outputs, and MUST NOT be faked from parsed stdout.streamedisTrueonly for a result fromProcess.wait(). When set,stdoutandstderrMUST be empty — the caller already consumed the bytes, and streaming exists to avoid buffering them twice.