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 visiblesandbox_skippedevent 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 — includingfile_secrets_dropped: truewhen it declaresas: filesecrets, 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=autoand 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 typedFailureCode SANDBOX_DRIVER_UNAVAILABLE(store.FailureSandboxDriverUnavailable, wrappingsandbox.ErrDriverUnavailable); the run document carries it asfailure_codeand its error text, and the schedule record'slast_run_error_codecopies it, andpkg/retrypolicyclasses 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=nonerestores the historical opt-in behaviour machine-wide; the production deployment measured on 2026-08-05 carriesITERION_SANDBOX_DEFAULT=autowith an EMPTY override, so cloud runs DO get the k8s sandbox. Anything that needs a bind-mounted workspace there — auto-memory, for one — must declaresandbox: 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 founddevcontainer.json) whose workflow names no network base of its own (nonetwork:block, an empty one, or rules without a preset): there theiterion-defaultallowlist preset applies, because open egress on such a run is the repository's choice, not the operator's, and asandbox_network_defaultedevent says so (#2303). Opting intonetwork: allowlist(ordenylist) 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-initerion-defaultpreset covers LLM endpoints (z.ai and Moonshot included) + registries + forges + apt + Nix cache. The devcontainer'scontainerEnv/remoteEnvnever 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 asNODE_OPTIONS,PATH, the shells' startup variables and the agents' config homes; the full list lives indocs/sandbox.md; removed with asandbox_env_deniedevent), the workflow'ssandbox.env:keys win over it per key, and the operator's launch env overwrites repo-contributed keys. Sandboxedclawcalls are routed through the hiddeniterion __claw-runnersubprocess inside the container, so theiterionbinary 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
.botzthroughNewEngineRunnerextracts into the default cache —os.UserCacheDir()/iterion/bundles— i.e. the operator's real~/.cache/iterion. A test pinsdispatcher.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 withnotify.WithResolver(backed byhttpdial.ResolvePublicHostWith) instead of resolving a real name. - The HOME a login shell reads. A test never runs
sh -lc/$SHELL -lunder 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). WrapTestMaininhometest.IsolateUserHome(pkg/dispatcher,pkg/server), setHOMEper test witht.Setenv, and pin the rule withhometest.RequireNoOperatorProfile.
