Skip to content
Like what we’re building? Star on GitHub

Sandbox — what a node may touch ​

Read it when a SANDBOX_* failure appears or you change what a node may execute; the product reference is docs/sandbox.md.

Sandbox ​

Per-run container isolation is on by default: at product entry points (iterion run/resume, studio, dispatcher) a workflow with no sandbox: block runs as sandbox: auto (reads .devcontainer/devcontainer.json, falling back to a published iterion-sandbox-slim:<version> image). Workflows can still pin block-form inline configuration (sandbox: with image: or build:) or explicitly opt out via sandbox: none — discouraged and flagged by the C128 warning. The two branches carry different guarantees when the host cannot sandbox (no docker/podman on PATH, #1425):

  • sandbox: auto ("isolate if you can") — degrades to unsandboxed with a visible sandbox_skipped event on the run's stream when the host has no container runtime (and, for a library caller that resolves neither a repo root nor a default image, on those too — iterion run, studio and the runner always resolve both). The workflow runs, and the run record says how — including file_secrets_dropped: true when it declares as: file secrets, which only a container mount (or the cloud runner's no-sandbox path) can deliver. The MODE decides this, never the tier that named it: workflow block, --sandbox=auto and the built-in default get one answer.
  • sandbox: { mode: inline, image/build: … } (an explicit container the author wired) — the refusal is a guarantee. The run parks with typed FailureCode SANDBOX_DRIVER_UNAVAILABLE (store.FailureSandboxDriverUnavailable, wrapping sandbox.ErrDriverUnavailable); the run document carries it as failure_code and its error text, and the schedule record's last_run_error_code copies it, and pkg/retrypolicy classes it Deterministic so the runner acks instead of redelivering into the same absent runtime. To run such a workflow on a host without a driver, say so: --sandbox none. ITERION_SANDBOX_DEFAULT=none restores the historical opt-in behaviour machine-wide; the production deployment measured on 2026-08-05 carries ITERION_SANDBOX_DEFAULT=auto with an EMPTY override, so cloud runs DO get the k8s sandbox. Anything that needs a bind-mounted workspace there — auto-memory, for one — must declare sandbox: none. When active, claw, claude_code, pi, Kimi, Grok, and tool nodes execute against a long-lived container that bind-mounts the worktree — by default at the host workspace's absolute path so Claude Code project keys match in/out container. Codex is the exception: its pinned SDK refuses Iterion's outer sandbox because it cannot route through the command builder. Network egress is unrestricted by default (network: open) — except on a repo-defined sandbox (auto + a found devcontainer.json) whose workflow names no network base of its own (no network: block, an empty one, or rules without a preset): there the iterion-default allowlist preset applies, because open egress on such a run is the repository's choice, not the operator's, and a sandbox_network_defaulted event says so (#2303). Opting into network: allowlist (or denylist) starts an HTTP CONNECT proxy on the host that enforces the policy — its only seat is the workflow (no env, chart or DB setting); under kubernetes the per-run NetworkPolicy exists only while the proxy runs, and the chart's NetworkPolicy never selects a sandbox pod; the built-in iterion-default preset covers LLM endpoints (z.ai and Moonshot included) + registries + forges + apt + Nix cache. The devcontainer's containerEnv/remoteEnv never carries the env deny class (*_BASE_URL, *_API_KEY, *_TOKEN, *_SECRET, *_PROXY, the namespaces of the agents, of the cloud SDKs they authenticate through, of git and of the code loaders — CLAUDE_* with the CLI's provider switches, ANTHROPIC_*, ITERION_*, AWS_*, GOOGLE_*, AZURE_*, GIT_*, LD_*, … — plus exact names such as NODE_OPTIONS, PATH, the shells' startup variables and the agents' config homes; the full list lives in docs/sandbox.md; removed with a sandbox_env_denied event), the workflow's sandbox.env: keys win over it per key, and the operator's launch env overwrites repo-contributed keys. Sandboxed claw calls are routed through the hidden iterion __claw-runner subprocess inside the container, so the iterion binary must be present on the container PATH (or bind-mounted by the host when available).
  • A driver with no host filesystem (kubernetes) drops the host binds and says so (sandbox_host_binds_dropped); attachments are copied in, refused typed, or park the run resumable — contract and codes: attachments.md and sandbox.md (#2086).

By default the sandbox also auto-mounts ~/.iterion/ (run store) and ~/.claude/ (Claude Code OAuth + per-project sessions) at the same absolute path inside the container so persistent memory survives across runs. On Linux, when the spec doesn't pin a User, the docker driver runs the container as the host UID:GID so writes back to those mounted trees stay host-owned. Disable via sandbox.host_state: none in the DSL, --sandbox-host-state=none, or ITERION_SANDBOX_HOST_STATE=none — recommended for multi-tenant cloud runners that must not leak host OAuth credentials. The kubernetes driver hard-errors on host_state: auto (cloud pods have no host filesystem to bind). See docs/sandbox.md for the full reference and iterion sandbox doctor for host diagnostics.

Two isolation floors ride on top: #1959 (ITERION_SANDBOX_FLOOR), #2069 (ITERION_SANDBOX_HOST_STATE_GUARD) — what they shadow.

Each setup phase between "container up" and "first node" carries its own bound, because an unbounded one does not fail — it waits, holding the run's queue lease with no sandbox_started event until max_duration fires hours later: pod-Ready (ITERION_SANDBOX_K8S_POD_READY_TIMEOUT, 10m), workspace copy + git fixup (ITERION_SANDBOX_WORKSPACE_COPY_TIMEOUT, 15m), post_create (ITERION_SANDBOX_POST_CREATE_TIMEOUT, 30m — an install outlasts a copy). An expiry parks the run failed_resumable + SANDBOX_SETUP_TIMEOUT, checkpoint intact, on a launch AND on a resume (a resume rebuilds the sandbox from scratch), and the cloud runner re-offers it to a fresh pod after 2 min. See docs/sandbox.md + docs/resume.md.

V2-6 wires sandbox.build: via docker buildx build on the local docker driver — BuildKit lives inside the Docker daemon, so no extra service. The kubernetes driver rejects sandbox.build: by design; cloud workflows reference pre-built images via sandbox.image: with a CI-built digest (production path). See docs/sandbox.md.

The launch-time forecast. Before the engine starts anything, two launch-side consumers predict this decision through runtime.RunWillBeSandboxed — the in-pod file-secret gate and the fallback screen that refuses a codex crossing on a run that will be sandboxed (#1564). The forecast is only as good as the repository it reads: it resolves runtime.EngineRepoRoot of the directory the engine will receive, which can differ from the executor's own workspace (notably a resumed run's recorded repository precedence), so that directory travels in runview.ExecutorSpec.SandboxProbeWorkDir — read by the probe alone, never by Claw's file tools. The runner, non-isolated subbots and the CLI also set WorkDir when their engine and file tools share that directory, keeping relative reads and writes in the run tree; two guards keep the probe honest: TestEveryExecutorConstructionNamesTheRunsRepository makes every construction site answer, and TestEveryEngineWorkDirReachesItsOwnScreen reads the source of the six surfaces that decide both in one function and requires the SAME expression — and, for the seven that answer one frame away (the runner's spec assembly and the frame that forwards into it, iterion run and its executor factory, both resumes, the studio launch), the expression recorded on probeRootDecidedElsewhere, so a thirteenth answering function cannot appear unbound. An answer counts wherever a site names it: a key of the spec's literal, an assignment on that spec below it, a factory's forwarded argument. Where no surface names a directory, the probe resolves the repository of the process cwd — what an engine given none roots itself at — never the cwd itself. A wrong root is silent: any non-empty root resolves an active auto spec (the default image is never empty) and driver selection reads the mode alone, so only an empty root could flip the verdict today. The day activeness depends on the repository's devcontainer.json, a surface reading the process cwd would screen the operator's crossing against a repository the run never touches.

Tests never reach the operator's machine ​

Two seams keep unit tests hermetic where the sandbox boundary meets the test suite — each seam asserts it was the one consulted, so a host default turns the test red:

  • The bundle cache root. Opening a .botz through NewEngineRunner extracts into the default cache — os.UserCacheDir()/iterion/bundles — i.e. the operator's real ~/.cache/iterion. A test pins dispatcher.WithBundleCacheRoot(t.TempDir()) (#2080, the class #2016 closed for ~/.iterion).
  • The SSRF-vetting resolver. notify's vetting resolves the target hostname; a test injects its lookup with notify.WithResolver (backed by httpdial.ResolvePublicHostWith) instead of resolving a real name.
  • The HOME a login shell reads. A test never runs sh -lc / $SHELL -l under the operator's HOME: the profile sources (pyenv init) and a killed spawn leaves a rehash lock that stalls the next shell up to 60 s (#2231). Wrap TestMain in hometest.IsolateUserHome (pkg/dispatcher, pkg/server), set HOME per test with t.Setenv, and pin the rule with hometest.RequireNoOperatorProfile.