Sandbox & Runtime Environments
Omnigent runs agent sessions in throwaway cloud sandboxes. The twist is breadth: one host process can be launched into Modal, Daytona, Islo, E2B, CoreWeave, Kubernetes, NVIDIA OpenShell, Boxlite — or Databricks Lakebox internally — through a single small interface. This doc covers that provider abstraction, two worked examples, the managed-host flow, and the credential proxy (the most interesting security idea in the codebase).
There are two distinct isolation layers. Keep them apart:
- Remote sandbox launchers — provision a whole machine (VM /
microVM / container / Pod) from a provider, ship Omnigent into it,
run
omnigent hostthere. Lives inomnigent/onboarding/sandboxes/. - Local in-process sandboxing — when the agent runs a shell tool,
that subprocess is jailed with
bwrap(Linux) or Seatbelt (macOS) plus an L7 egress proxy. Lives inomnigent/inner/(+ thin re-exports inomnigent/sandbox/). The credential proxy plugs in here.
1. The launcher abstraction
Everything provider-specific hides behind one ABC,
SandboxLauncher (omnigent/onboarding/sandboxes/base.py:152). The
generic bootstrap flow composes its primitives; provider modules supply
the transport. The docstring states the contract bluntly: "Everything
provider-specific … lives behind a SandboxLauncher implementation;
everything provider-agnostic … lives in bootstrap"
(base.py:9-12).
The interface is small. Required methods:
| Method | Role | Ref |
|---|---|---|
prepare() |
Local preflight: verify provider tooling + creds on the calling machine | base.py:200 |
provision(name) |
Create (or reserve) a sandbox; return its id | base.py:212 |
run(id, cmd) |
Run a shell command, capture output → RemoteCommandResult |
base.py:364 |
Methods with a shared default:
| Method | Default | Ref |
|---|---|---|
start_host(...) |
EXEC model: probe $HOME, mkdir workspace, optional git clone, start omnigent host detached |
base.py:230 |
run_background(...) |
setsid nohup … & so the process outlives the exec session |
base.py:379 |
Optional capabilities — default to a clean SandboxCapabilityError
naming what's missing — put (ship files), stream_exec (PTY stream),
exec_foreground (hold a process open), forward_local_port,
terminate, resume, keep_alive, attach, wheel_install_command
(base.py:330-557).
Behaviour is declared with four ClassVars, checked before any remote work so a provider fails fast instead of mid-flow:
provider— short name for CLI choices / errors (base.py:165).supports_local_port_forward— can it bridge a local port in (ssh -L)? Needed for the in-sandbox OAuth dance; Modal can't, so it fails up front with a--no-authhint (base.py:178).supports_cli_bootstrap— does it supportomnigent sandbox create, or is it managed-only? (base.py:188).can_resume— can a stopped sandbox be revived in place by reattaching a persistent volume? (base.py:198). False everywhere ephemeral.
Two boot models share start_host:
- Exec model (default). The sandbox is a bare box.
start_hostexecs into it, sets up the workspace, and backgrounds the host process with its identity + token in the environment (base.py:280-328). Used by Modal, Daytona, Islo, CoreWeave, Boxlite, OpenShell. - Entrypoint-as-host. The sandbox boots running
omnigent host.provisiononly reserves the id (so the server can arm the launch token before the box exists, closing the dial-back race), andstart_hostis overridden to materialize the box (base.py:218-228). Kubernetes does this — the Pod's command is the host.
The registry
Providers register name → "module:Class" in _LAUNCHERS
(omnigent/onboarding/sandboxes/__init__.py:57-73). Modules import
lazily (each pulls an optional SDK extra) and may be absent from a
build — available_providers() uses find_spec with no import side
effects (omnigent/__init__.py:76-94), and the OSS distribution simply omits
the internal lakebox module. get_launcher(provider) resolves a name
to an instance (omnigent/__init__.py:97).
The default image
Across providers, a sandbox boots from one prebaked image:
ghcr.io/omnigent-ai/omnigent-host:latest (base.py:32). It bakes the
full Omnigent install + git/tmux/curl + the coding-harness CLIs, so
sandbox creation skips dependency install — provider + server_url
is a complete config. For local-checkout development, sandbox create
overlays freshly-built wheels with pip install --force-reinstall --no-deps (base.py:42-76) — --force-reinstall because the image
already bakes 0.1.0, so pip would otherwise skip silently.
One interface, nine backends
The whole point: provider lock-in collapses to one ABC and a registry
line. A new sandbox provider is a single file implementing
prepare / provision / run / terminate — the managed flow, the CLI,
and the credential plumbing all come for free.
2. Two worked examples
Modal (container, 24h cap)
ModalSandboxLauncher (omnigent/onboarding/sandboxes/modal.py),
provider = "modal" (omnigent/onboarding/sandboxes/modal.py:268). Modal's managed container
platform via the modal SDK.
- Provision.
modal.Sandbox.create()with entrypointsleep infinity, a 24h timeout, and 2 vCPU / 4 GiB (omnigent/onboarding/sandboxes/modal.py:369). The image resolves explicit →OMNIGENT_MODAL_HOST_IMAGE→ default host image. Modal Secrets (harness LLM keys) inject as env. - Run.
handle.exec("bash", "-lc", command), blocking on stdout / stderr (omnigent/onboarding/sandboxes/modal.py:457). - Ship files.
handle.filesystem.copy_from_local(local, remote)(omnigent/onboarding/sandboxes/modal.py:480). - Teardown.
modal.Sandbox.from_id(...).terminate(), idempotent on already-reaped (omnigent/onboarding/sandboxes/modal.py:423). - Quirks. Hard 24h lifetime, no extension (
MAX_SANDBOX_LIFETIME_S,omnigent/onboarding/sandboxes/modal.py:58);keep_aliveis a no-op.supports_local_port_forward = False(omnigent/onboarding/sandboxes/modal.py:274), so the CLI auto-skips the in-sandbox login. No process-kill handle, soexec_foregroundwrites a pidfile and kills via a second exec on Ctrl-C.
E2B (template-based, real process kill)
E2BSandboxLauncher (omnigent/onboarding/sandboxes/e2b.py),
provider = "e2b" (omnigent/onboarding/sandboxes/e2b.py:353). The e2b SDK.
- Provision.
Sandbox.create(template=…, timeout=…, envs=…)(omnigent/onboarding/sandboxes/e2b.py:520). The key difference from every other provider: E2B cannot boot an arbitrary registry image — it boots a pre-built template (DEFAULT_E2B_TEMPLATE = "omnigent-host",omnigent/onboarding/sandboxes/e2b.py:89). The host image is baked into a template out-of-band viae2b template build. Config names a template, not an image (the YAML parser enforces this distinction atomnigent/server/managed_hosts.py:1217-1249). - Run.
handle.commands.run("bash -lc …", timeout=0)—0disables the SDK's default 60s per-command cap (omnigent/onboarding/sandboxes/e2b.py:639). - Ship files.
sandbox.files.write(remote, local.read_bytes())(omnigent/onboarding/sandboxes/e2b.py:674). - Lifecycle.
keep_alivere-extends viahandle.set_timeout(...), soft-failing if the account cap is below the request (omnigent/onboarding/sandboxes/e2b.py:601);terminateisSandbox.kill(id). Unlike Modal,CommandHandle.kill()genuinely stops the remote process. - Quirks. Hard lifetime cap (24h Pro / 1h Hobby), no "never
expire";
provisionretries clamped if the requested timeout exceeds the account cap.
3. Managed hosts: the server provisions the sandbox
There are two ways an omnigent host comes to exist.
- External / CLI host. A human runs
omnigent hoston their own machine, or runsomnigent sandbox create … && omnigent sandbox connect …to bootstrap one into a sandbox themselves (omnigent/cli_sandbox.py:156-407). This is thesupports_cli_bootstrappath: build local wheels, ship them, install, stream-attach. - Managed host. A session is created with
host_type="managed"and the server provisions the sandbox, starts the host inside it, and waits for it to register (omnigent/server/managed_hosts.py). The human is replaced.
The managed flow is where most providers actually run. Providers with
managed-launch support: modal, daytona, boxlite, cwsandbox, islo, e2b, openshell, kubernetes (omnigent/server/managed_hosts.py:145).
How host and sandbox relate
The host abstraction (omnigent/host/) is the durable identity and
tunnel; the sandbox is disposable compute. They are deliberately
decoupled:
"The host's identity is DURABLE while its sandbox is not" — the
hostsrow carries the launch-token digest, provider, and sandbox id, and a relaunch overwrites them in place: a new sandbox generation under the samehost_id, so session bindings survive a sandbox dying at the provider's lifetime cap (omnigent/server/managed_hosts.py:11-15).
A managed sandbox host never persists anything to its own config file.
The server generates the identity + token and injects three env vars —
OMNIGENT_HOST_TOKEN, OMNIGENT_HOST_ID, OMNIGENT_HOST_NAME — which
override the on-disk identity entirely (omnigent/host/identity.py:27-29, 77-85). The token rides a dedicated WebSocket header,
X-Omnigent-Host-Token, not Authorization, so intermediate proxies
can't confuse it with a user Bearer token (identity.py:36).
Launch sequence
launch_managed_host (omnigent/server/managed_hosts.py:1690) orchestrates it:
launcher.prepare()thenlauncher.provision(host_name)→sandbox_id(omnigent/server/managed_hosts.py:1746-1748)._arm_and_start_hostmints a 32-byte launch token and pre-registers the host row with its token digest — before the host process starts (omnigent/server/managed_hosts.py:1888-1898). This closes the dial-back race: the credential resolves the instant the host first connects.launcher.start_host(...)— exec model execs in and backgrounds the host; entrypoint model (k8s) creates the Pod (omnigent/server/managed_hosts.py:1905-1916)._wait_for_host_onlinepolls the hosts table up to 120s (omnigent/server/managed_hosts.py:1941,MANAGED_HOST_ONLINE_TIMEOUT_S).- Any post-provision failure tears the sandbox down and revokes
the token before re-raising — otherwise the box leaks until the
provider's lifetime cap (
omnigent/server/managed_hosts.py:1918-1937).
Token TTLs are set to outlive the sandbox, so a live box always
re-authenticates across tunnel reconnects, while a token leaked from a
dead box expires. Modal: 25h (24h cap + slack, omnigent/server/managed_hosts.py:163).
Daytona / Islo / OpenShell / Kubernetes: 7 days policy bound
(omnigent/server/managed_hosts.py:172-199). CoreWeave / E2B derive the TTL from the
operator-overridable lifetime cap (omnigent/server/managed_hosts.py:201-205).
flowchart TD
A["POST /v1/sessions<br/>host_type=managed"] --> B["launcher.prepare()"]
B --> C["launcher.provision(name)<br/>→ sandbox_id"]
C --> D["mint launch token<br/>pre-register host row<br/>(digest + TTL)"]
D --> E{boot model}
E -- exec --> F["start_host: $HOME probe,<br/>mkdir workspace,<br/>git clone, host detached"]
E -- entrypoint --> G["k8s: create token Secret<br/>+ Pod (cmd = omnigent host)"]
F --> H["host dials back<br/>X-Omnigent-Host-Token"]
G --> H
H --> I{online within 120s?}
I -- yes --> J["session binds runner,<br/>tunnel live"]
I -- no / error --> K["terminate sandbox +<br/>revoke token, raise 502"]
J --> L["session ends / cap hit →<br/>terminate; relaunch reuses host_id"]
Config is supplied two ways, one seam: a YAML sandbox: block parsed
by parse_sandbox_config (omnigent/server/managed_hosts.py:604), or — for embedding
deployments — a ManagedSandboxConfig built directly with a custom
launcher factory (omnigent/server/managed_hosts.py:87-97). Provider credentials are
never in the YAML (12-factor): Modal reads MODAL_TOKEN_*, Daytona
reads DAYTONA_API_KEY, Islo reads ISLO_API_KEY, all from the server
process environment (omnigent/server/managed_hosts.py:72-83).
For your project — Swisscheese:
This is the harness shape Swisscheese needs: a durable session identity
that survives the underlying compute being recycled. Run N reviewer
agents as N managed hosts, each in its own provider sandbox; when one
hits a 24h cap, relaunch reuses the host_id and the review pipeline's
bindings stay intact. The launcher ABC means you can mix providers
(cheap container for the writer, GPU OpenShell box for a model-heavy
reviewer) behind one interface.
4. The provider matrix
| Provider | Mechanism (SDK) | Isolation level | Notes |
|---|---|---|---|
| Modal | modal SDK |
Managed container | Hard 24h cap, no extend (omnigent/onboarding/sandboxes/modal.py:58); no port-forward; pidfile kill workaround |
| E2B | e2b SDK |
Managed sandbox | Boots templates, not registry images (omnigent/onboarding/sandboxes/e2b.py:89); 24h Pro / 1h Hobby; real process kill |
| Daytona | daytona SDK |
Cloud sandbox | No lifetime cap; 15-min idle auto-stop disabled at provision; keep_alive keeps it off (omnigent/onboarding/sandboxes/daytona.py:391); free-tier egress allowlist |
| Islo | raw httpx (no SDK extra) |
Cloud sandbox | API-key + token cache; deleted on teardown; clears Islo's seeded apiKeyHelper when a Claude cred is injected (omnigent/onboarding/sandboxes/islo.py:375) |
| CoreWeave | cwsandbox SDK |
CW cloud sandbox | Hard cap, default 24h, env-overridable OMNIGENT_CWSANDBOX_MAX_LIFETIME_S; egress explicitly set internet (omnigent/onboarding/sandboxes/cwsandbox.py) |
| Kubernetes | kubernetes client |
Pod (container on cluster) | Entrypoint-as-host (omnigent/onboarding/sandboxes/kubernetes.py start_host); token via per-Pod Secret; Pod Security "restricted", runAsNonRoot, drop ALL caps, seccomp; amd64-only |
| OpenShell | NVIDIA openshell SDK (gRPC) |
On-prem gateway (Docker/Podman/microVM/k8s driver) | GPU-capable; file transfer via stdin→cat; no API key — uses active gateway |
| Boxlite | boxlite SDK |
Micro-VM (local KVM/HVF) or remote pool | Managed-only, supports_cli_bootstrap=False (omnigent/onboarding/sandboxes/boxlite.py:194); local mode runs micro-VMs on the server host; private-registry creds via env |
| Lakebox | Databricks (internal) | Databricks workspace sandbox | Internal-only module; parses but rejects managed launch in OSS |
No provider sets can_resume = True or
supports_local_port_forward = True in the OSS tree — sandboxes are
ephemeral and the in-sandbox OAuth port-forward is a Lakebox-era
capability. Every provider's provision defers materialization where
it can and arms the token first.
5. The credential proxy: secrets that never enter the sandbox
This is the standout security pattern, and it belongs to the local
sandbox layer (designs/SANDBOX_CREDENTIAL_PROXY.md;
omnigent/inner/credential_proxy.py,
omnigent/inner/egress/proxy.py).
The problem. A sandboxed tool often needs to authenticate to an
external host — gh api, git clone https://…, a Bearer SaaS API. The
naive fix injects the real token into the sandbox env or a config file.
That defeats the sandbox: any code the agent runs — including code a
prompt-injection payload tricked it into running — can read the token
from os.environ / ~/.gitconfig and exfiltrate it.
The mechanism. Omnigent already runs a mandatory L7 MITM egress proxy for all HTTP(S) leaving the sandbox (the egress allow-list). It extends that proxy to attach credentials on the way out. The default is swap-on-access: nothing credential-shaped enters the sandbox at all.
- Parent resolves the secret. The unsandboxed parent reads each
configured secret from its source (
{env}/{file}/{command}). The real value stays in the parent and the proxy's in-memory rewrite table — never serialized, never on argv, never on the sandbox disk. - Inject on access. A tool fires its request with no
Authorizationheader. The proxy recognises the bound host and injectsAuthorization: <scheme> <real>outbound.git,curl,python,node— any HTTP client — authenticate with zero in-sandbox wiring. - Placeholder shim for gating clients. Some clients (notably
gh) refuse to issue a request without seeing a local token, so there's no outbound request to decorate. For those, the parent mints a random single-use placeholderoa_cred_…and injects only that into the env var. The client believes it is authenticated; the proxy swaps the placeholder for the real secret on the wire. - Leak guard. A placeholder presented for a host it is not bound to
(exfiltration attempt) is rejected
403; an unknownoa_cred_*value is likewise rejected. Even if a tool reads the placeholder from its own env and replays it against an attacker host, the proxy attaches no real credential. - No clobbering. A real
Authorizationheader the client set itself is forwarded untouched and suppresses injection.
flowchart LR
subgraph parent["parent (unsandboxed)"]
S["resolve real secret<br/>→ proxy rewrite table"]
end
subgraph box["sandbox (jailed)"]
T["git / curl / python<br/>GET /repo (NO auth header)"]
end
subgraph proxy["egress MITM proxy"]
P["host bound?<br/>inject Authorization: scheme real<br/>(wrong-host placeholder → 403)"]
end
S -.holds secret.-> P
T --> P
P --> U["upstream → 200 OK"]
Why it's safe by construction. The YAML block requires
egress_rules plus a hard-isolating backend — linux_bwrap or
darwin_seatbelt — and the parser rejects it otherwise. Only those two
backends can guarantee the MITM proxy is the only egress path: a tool
can't open a raw socket around it. On Linux that's --unshare-net
(omnigent/inner/bwrap_sandbox.py:449) with traffic forced through the
proxy on loopback; on macOS it's Seatbelt's (deny default) egress
baseline (omnigent/inner/seatbelt_sandbox.py:46). The resolved secret
is parent-side only and deliberately excluded from SandboxPolicy
serialization, so it can't leak into a dump.
For your project — EU AI Act compliance platform: For an audit-traceable GRC agent, this is the difference between "the agent had the deploy token in scope" and "the agent could never read the token, only cause an authenticated request." The egress proxy is a single chokepoint you can log: every authenticated outbound call, its host, and its bound credential — without the secret ever crossing the isolation boundary. That's a clean Article 12 record-keeping story and a concrete Article 9 risk control.
HTTP(S) only
The credential proxy covers Bearer / Basic over HTTP(S). SSH git remotes
are out of scope (design doc §Non-goals) — an ssh:// clone gets no
swap-on-access. Plan repo access around HTTPS where you want secretless
operation.
6. Local backends, in one breath
When the agent runs a shell tool inside a host, that subprocess is
jailed by a registered SandboxBackend (omnigent/inner/sandbox.py:266):
linux_bwrap— bubblewrap with--unshare-net/-pid/-uts/-ipc, read-only binds of system paths, per-file write grants, and a hardened seccomp denylist layered on after exec (omnigent/inner/bwrap_sandbox.py:197, 449-462). Linux default whenbwrapis on PATH.darwin_seatbelt—sandbox-execprofile with(deny default), default-deny egress, a dotfile allowlist, and per-literal/devwrite grants (omnigent/inner/seatbelt_sandbox.py:342, 26-64). macOS default.linux_landlockandnonealso exist as registered backends.
Thin re-exports at omnigent/sandbox/bwrap.py and
omnigent/sandbox/seatbelt.py keep the import surface stable while the
legacy inner/ modules retire. Note: there is no gVisor / runsc
path anywhere in the tree — isolation is namespaces + seccomp (Linux),
Seatbelt (macOS), provider-native VMs/containers (remote), and the L7
egress proxy throughout.
7. Design observations
Good ideas.
- One ABC, nine backends, lazy SDKs. Adding a provider is one file +
one registry line; absent SDK extras simply drop the provider from
available_providers(). No conditional imports leaking across the codebase. - Token armed before the host boots. Pre-registering the launch-token
digest before
start_hostremoves the dial-back race by construction — the credential resolves the instant the host connects (omnigent/server/managed_hosts.py:1888-1916). - Durable identity, disposable compute. Sessions survive a sandbox
dying at a lifetime cap because the
host_idoutlives the box and a relaunch reuses it. - Swap-on-access credentials. Putting nothing credential-shaped in the sandbox — not even a placeholder, in the default path — is the strongest version of "least privilege for secrets" I've seen in an agent harness. The leak guard makes the opt-in placeholder path safe even against replay.
- Fail-fast capability flags. Checking
supports_local_port_forward/supports_cli_bootstrapbefore remote work means users get a clear--no-auth/ "managed-only" message instead of an opaque mid-flow capability error.
Potential pitfalls.
- Lifetime caps are real. Modal 24h, E2B 24h/1h, CoreWeave configurable
— long agent runs need the relaunch path to be solid, and
can_resumeis False everywhere, so a relaunch is a fresh box (workspace re-cloned from theomnigent.sandbox.repolabel). - Credential proxy is HTTP(S)-only. SSH remotes bypass it.
- No gVisor option. For untrusted-code workloads where namespaces + seccomp feel insufficient, the only stronger isolation is going through a microVM provider (Boxlite local, or a VM-backed remote) — there's no in-process gVisor toggle.
- E2B's template indirection. Unlike every other provider, you can't point E2B at a registry image; the host image must be rebuilt into a template out of band whenever it changes.