Sandbox isolation

How pond confines an agent run. The threat model and the broader hardening roadmap live in ../run-trust.md; this spec is the implementation reference for the sandbox profiles, providers, and the dispatch-time fail-closed enforcement.

Code: swarm/src/sandbox.py (worker-side enforcement), app/executors/swarm.py (dispatch-side validation + routing).

The two-sided contract

Isolation is decided in two places that must agree:

  • Dispatch (trusted, in pond): resolve_sandbox_dispatch validates a stage’s executor.sandbox block and derives (sandbox_block, required_capability, required_tags). It is fail-closed — a stage with no sandbox.profile (and no explicit none opt-out) is refused before any agent runs.
  • Worker (untrusted host): resolve_policy turns the stored sandbox_block into a concrete SandboxPolicy, and a SandboxProvider launches the job under it. The worker re-resolves rather than trusting a pre-computed policy.

The profile names are the shared contract — keep _SANDBOX_PROFILES (worker) and _KNOWN_SANDBOX_PROFILES (dispatch) in sync.

Profiles

A profile fixes the isolation posture (immutable); only resource/image knobs are overridable (_SANDBOX_OVERRIDABLE = memory, cpus, pids_limit, tmpfs_size, image) — an overrides block can never widen network/filesystem/caps.

ProfileFilesystemNetworkUse
nonehostopendev / local only (explicit opt-out)
untrusted-code-readro checkoutbroker-onlyread-only analysis
untrusted-code-write (harness)rw throwaway copyallowlistthe full coding harness (edit/build/test/install)

The confined profiles drop all caps, set no-new-privileges, run non-root, read-only root with a size-capped /tmp tmpfs, and apply hardened ulimits (nofile/nproc/core=0) plus cgroup memory/cpus/pids defaults.

Network postures

  • broker-only — the job sits on a per-job --internal Docker network whose only egress is a sidecar running the credential broker (see model-credentials); the model key never enters the job.
  • allowlist — broker-only plus a forward CONNECT proxy in the sidecar: HTTPS-only, default-deny, a tunnel opens only when the host is on the allowlist and resolves to a non-private IP (loopback / link-local / metadata / RFC1918 rejected — closes DNS-rebind). The job gets HTTPS_PROXY/NO_PROXY. Safe defaults (pypi/npm/crates/github) are extended per-job with the dispatcher’s allow_hosts (e.g. the target’s git remote). This is what makes pip/npm/ git work while exfil/pivot stays blocked.

rw throwaway copy

untrusted-code-write mounts a disposable copy of the checkout (staged by _stage_checkout, removed on every exit path), so edits / node_modules / generated files all work and are discarded — the source tree is never mutated.

Providers (OCI runtimes)

A SandboxProvider turns a SandboxSpec into a running, confined process. get_provider(name) is fail-closed on unknown names; filter_sandbox_capabilities drops an advertised sandbox.<backend> a host can’t actually enforce.

ProvidernameBoundary
NoneProvidernonepassthrough (dev fallback)
DockerProviderdockershared-kernel container (runc)
GvisorProvidergvisoruserspace kernel (runsc)
KataProviderfirecrackermicroVM (Kata + Firecracker VMM)

The hardened backends are DockerProvider + a --runtime flag, so the harness stays an ordinary editable OCI image — “modify the environment” = edit a Dockerfile, not a kernel. _RuntimeProvider resolves its runtime from $SWARM_{GVISOR,KATA}_RUNTIME or the daemon’s registered runtimes (Firecracker-before-QEMU) and is fail-closed: it refuses to instantiate rather than silently downgrade to runc when its runtime is absent.

Trust-tier routing

executor.sandbox.tier (trusted | byo | any) maps to a required pool:<tier> tag the orchestrator subset-matches against worker tags, so a job only lands on a worker in the required pool. Confined profiles default to trusted (untrusted code never silently reaches BYO/unpinned compute); byo is explicit-only and additionally requires a tenant:<project_id> tag (data minimization — a BYO host only sees its own tenant’s work).

Invariants

  • No profile or an unknown profile/tier/backend at dispatch the stage fails closed, never runs unconfined.
  • overrides can only touch resource/image knobs, never the posture.
  • A hardened provider never falls back to runc.
  • The model credential is reachable only via the broker on broker-only/allowlist.