Run each evaluation task inside its own fresh container. Strong host isolation and a pinned, reproducible agent runtime.
Supersedes the agent-side FS perimeter flag from #199 (reverted in 9fe4320). The container boundary subsumes what that flag tried to do at the agent level.
Set sandbox.driver: docker on a task (or pass --driver docker on the CLI —
a thin alias for -D sandbox.driver=docker) when you want:
- Isolation from the host filesystem/network — agent-generated code can't reach files outside the sandbox.
- A pinned toolchain — the image bakes in Python 3.13, Node 22 LTS,
@anthropic-ai/claude-code,uv, and the matchingcoder_evalversion, so results don't drift with host upgrades.
Aggregation (P/R/F1, suite thresholds, reports) always stays on the host. Each container is a sealed "run one task → emit one task.json" worker.
make docker-image # core + both built-in agents (default; no credentials)
# opt in to the UiPath extra (resolves from public PyPI; no credentials):
make docker-image-fullBoth build coder-eval-agent:<pkg-version> and tag it :latest.
make docker-imageinstalls the core package plus both built-in agents — claude-code (baked above) and Codex (--extra codex, public PyPI). It needs no credentials and covers the common case: claude-code or Codex tasks scored withrun_command/file_contains(incl. converted skillsbench tasks).llm_judge/agent_judgework here too (they route through the run's Anthropic/Bedrock backend).make docker-image-fulladditionally installs theuipathextra. TheuipathSDK resolves from public PyPI (peruv.lock), so the build needs no credentials. Use this only for tasks that shell out to the in-hostuipathCLI. (Codex is already in the default image — no extra needed.)
Codex sandbox under Docker. Codex's Landlock-backed
read-only/workspace-writesandboxes can't initialize inside the eval container — their writes/execs fail silently and the agent produces no artifacts (ascore=0FAILURE with no loud error). The docker runner setsCODER_EVAL_IN_CONTAINER=1, and the Codex agent honors it by falling back tofull-access: the container itself is the trust boundary. Host runs (tempdir) are unaffected — Landlock works there and the marker is unset. So Codex tasks run under--driver dockerwith their naturalacceptEditspermission mode; no need to setbypassPermissionsby hand.
# Single task
coder-eval run path/to/task.yaml --driver docker
# All tasks
coder-eval run --driver docker
# Or in the task YAML
sandbox:
driver: docker
docker:
network: bridge # or "none" for sealed runs
image: my-custom:tag # override the default imageInstead of pointing at a pre-built image, a task can ship its own Dockerfile
and have coder-eval build it before the run:
sandbox:
driver: docker
docker:
dockerfile_path: ./environment/Dockerfile # relative to the task YAML
⚠️ Contract: a task Dockerfile MUST either start withFROM coder-eval-agent:<version>or use the runtime kit (see below). The container runs the coder-eval orchestrator (coder-eval _run-task-internal) via the framework image'sENTRYPOINT. A task Dockerfile extends that image and adds only task-specific layers — extraaptpackages,COPY-ed inputs, etc.Build the framework base first —
make docker-image(tags bothcoder-eval-agent:<version>andcoder-eval-agent:latest).
The FROM coder-eval-agent contract above means a task is rebased onto the
Debian framework image. That breaks tasks whose Dockerfile was written for a
different base image (e.g. a Fedora recipe using dnf, which doesn't exist on Debian). To keep the task's own base image and build successfully, coder-eval's runtime need to be copied into the task's image. Use make coder-eval-runtime first to make the runtime available for copying.
FROM fedora:41 # the task's own base, kept verbatim
RUN dnf -y install ... # the task's native recipe, runs on its own OS
# --- copy coder-eval runtime into a task image ---
COPY --from=coder-eval-runtime:latest /opt/coder-eval /opt/coder-eval
COPY --from=coder-eval-runtime:latest /usr/local/bin/coder_eval_entrypoint.sh /usr/local/bin/coder_eval_entrypoint.sh
LABEL org.coder-eval.version="<ver>"make coder-eval-runtime builds the kit (docker/Dockerfile.runtime): a
standalone CPython + Node + the coder-eval CLI + Claude Code, all under
/opt/coder-eval, plus the entrypoint at the same /usr/local/bin/... path
the host pins. The kit is glibc-only — it runs on debian/ubuntu/fedora/rhel/…
but not musl/Alpine.
Both base images are independent and persistent — build each once and run any mix of rebase
and inject tasks without rebuilding. To build both in one shot (no credentials needed), use
make docker-images (= make docker-image + make coder-eval-runtime); reach for the
individual targets when you only need one.
The kit installs the no-credential set only (core + codex), like
make docker-image— it never installs the[uipath]extra, so there is nomake docker-images-full. An inject-mode task that needs the LLMGW/uipathjudge isn't supported by the kit as built.
# environment/Dockerfile
FROM coder-eval-agent:latest # inherit runtime + entrypoint
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils
RUN pip install --no-cache-dir PyMuPDF==1.24.10
COPY input/ /root/input/Behavior:
- Path resolution —
dockerfile_pathis resolved relative to the task YAML's directory at load time (with$VAR/${VAR}expansion). A missing file fails fast at load, not mid-run. - Entrypoint check — after building, coder-eval inspects the image's
ENTRYPOINTand aborts with aFROM coder-eval-agenthint if the runtime wasn't inherited. - Overrides
image— whendockerfile_pathis set, it takes precedence over anyimagevalue. - Build context — the build context is the Dockerfile's parent directory,
so relative
COPY ./input/...instructions resolve naturally. In the layout above,environment/is the context. - Caching — the image is tagged deterministically as
coder-eval-task-<task_id>:built, so repeat runs of the same task reuse Docker's layer cache. Edit the Dockerfile and the next run rebuilds the changed layers only. - Version-label check skipped — the
org.coder-eval.versionpreflight only applies to the framework image; task-built images don't carry it and won't warn.
A build failure aborts the task with a DockerBuildError (a DockerRunError
subclass) carrying docker build's output. Because the build runs before the
run dir, docker.log, or task.json exist, the runner explicitly records the
failure so it is never a silent empty result dir: it creates the run dir, writes
the full build log to docker.log, and writes a synthetic task.json with
final_status: BUILD_FAILED (an error-category status) before re-raising. So
a failed build shows up per-task on the dashboard with its build log, exactly
where you'd look for container output.
The docker build invocation is configurable via sandbox.docker.build:
sandbox:
driver: docker
docker:
dockerfile_path: ./environment/Dockerfile
build:
args: # -> --build-arg KEY=VALUE
PKG_VERSION: "1.2.3"
TOKEN: "${HOST_TOKEN}" # values are $VAR / ${VAR} expanded from the host env
secrets: # -> --secret <spec> (requires BuildKit)
- id=mytoken,env=MY_TOKEN # forward a host env var as a build secret
- id=npmrc,src=~/.npmrc # or a file
extra_args: ["--target", "runtime"] # escape hatch for any other docker build flag
buildkit: true # optional: force DOCKER_BUILDKIT (see below)args→--build-arg KEY=VALUE. Values are environment-expanded against the host. Prefersecretsfor credentials — build-args are recorded in the image history.secrets→--secret <spec>. Useid=NAME,env=VARto forward a host env var orid=NAME,src=PATHfor a file; reference it in the Dockerfile viaRUN --mount=type=secret,id=NAME .... Secrets are exposed only to the mounting RUN step and never baked into layers. Secrets require BuildKit.extra_args→ raw flags inserted before the build context (e.g.--target,--network,--platform). Escape hatch for options without a dedicated field.buildkit→ controls theDOCKER_BUILDKITenv var. Omitted (default), coder-eval inherits the invoker's environment — setDOCKER_BUILDKIT=1before running coder-eval to enable it globally. Setbuildkit: true/falseto force it per task. Ifsecretsare configured but BuildKit isn't enabled, coder-eval logs a warning (the build would otherwise fail).
The build context is always appended last, so extra_args can't displace it.
macOS users — read this first. Claude Code's OAuth tokens live in the macOS Keychain. The container has no path to the Keychain, so the bundled CLI inside will return
Not logged in · Please run /loginand every task will fail at iteration 1. Before running--driver docker, set one of these on the host:
ANTHROPIC_API_KEY=...(direct Anthropic), orCLAUDE_CODE_USE_BEDROCK=1+AWS_BEARER_TOKEN_BEDROCK=...+AWS_REGION=...(Bedrock).Linux hosts where Claude Code stores creds under
~/.claudealready work because that directory is bind-mounted into the container.
Credentials are forwarded via --env VAR (name-only, never embedded in argv) for these vars when set on the host: ANTHROPIC_API_KEY, API_BACKEND, UIPATH_*, AWS_BEARER_TOKEN_BEDROCK, AWS_REGION, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_MODEL.
To add one or two custom vars to the defaults (recommended), use env_passthrough_extra:
sandbox:
driver: docker
docker:
env_passthrough_extra: ["MY_CUSTOM_TOKEN", "DEBUG_FLAG"] # Keeps all defaults + theseTo completely replace the list, use env_passthrough:
sandbox:
driver: docker
docker:
env_passthrough: ["MY_CUSTOM_TOKEN", "ANTHROPIC_API_KEY"]The default env_passthrough includes HOME so the in-container ~/.claude lookup resolves at the same path as on the host (the mount lands at $HOME/.claude symmetrically). Practical contract:
Path.home()inside the container returns the host'sHOMEvalue (e.g./Users/youon macOS). The directory exists in the container because Docker auto-creates it as the mount parent for~/.claude.~/.claudeis not the host's real dir — the runner makes a throwaway lean copy in a tmp dir per task and mounts that copy read-write at$HOME/.claude. The copy keeps the small set the container needs (auth via.credentials.json,settings.json,plugins/) and drops heavy or transient per-session state —security/(often hundreds of MB),projects/,cache/,file-history/,backups/,downloads/,sessions/,telemetry/,shell-snapshots/,todos/,session-env/, plus the volatile churn dirs the live CLI rewrites. The skip set is a denylist; the authoritative list isCLAUDE_COPY_IGNOREinsrc/coder_eval/isolation/docker_runner.py(a test asserts this doc and that constant agree, so the list never silently drifts). The container may write anywhere under~/.claude; those writes hit the copy and are discarded when the task ends — the host's real~/.claudeis never modified. Note the copy includes the OAuth token (.credentials.json) and is mounted read-write, so the in-container agent can read and tamper with the token copy — contained, since the copy is discarded at task end and the host's real dir is untouched. Opt out entirely withCODER_EVAL_NO_CLAUDE_MOUNT=1.- Writes under
$HOMEoutside the~/.claudemount land in the container's ephemeral rootfs overlay. Don't expect them to persist or to be visible to the host. - If a tool detects platform from
HOME(e.g. "starts with/Users/→ macOS"), it will draw the wrong conclusion. Vanishingly rare in practice.
Remove HOME from env_passthrough if you don't want this behavior — the container's image-default HOME=/root will win, but then the host's OAuth dir is no longer reachable.
The host's run dir is bind-mounted read-write into the container at the same absolute path (so task.json and artifacts land directly on the host filesystem). This makes --run-dir load-bearing for isolation:
- Do not point
--run-dirat a symlink. Docker resolves the source of a bind mount; following a symlink would silently grant the container RW access to a different host location. - Do not point
--run-dirat a sensitive parent (e.g.$HOMEdirectly,/etc, a repo root). Use a dedicatedruns/subtree. - The default (
runs/<timestamp>/) is safe.
| Layer | Location |
|---|---|
| Agent process (Claude Code SDK) | inside container |
| Sandbox + per-row criterion checking | inside container |
task.json serialization |
container → host bind mount |
Per-criterion aggregate() (P/R/F1, suite thresholds) |
host |
| Reports, run summary, experiment rollups | host |
task.json is the only artifact crossing the boundary. Aggregation reads it via the existing host pipeline unchanged.
- Relative template paths:
template_sources[].pathis resolved to a host absolute path before staging, so it won't exist inside the container unless you also forward the parent dir viasandbox.docker.extra_mounts. - No container reuse across tasks: each task = one fresh container. Adds ~1–3 s startup overhead per task; negligible vs. LLM latency.
- macOS Keychain auth: not reachable from the container; set
ANTHROPIC_API_KEY(direct) or Bedrock credentials instead.
The host's DockerRunner (coder_eval/isolation/docker_runner.py) renders the docker run argv, bind-mounts task inputs at /work/input, allocates an output dir at /work/output, and tails container stdout into docker.log in the task's run dir.
Inside the container, the entrypoint invokes coder-eval _run-task-internal (hidden subcommand), which loads the staged YAML + context, runs the standard in-process Orchestrator (driver auto-coerced back to tempdir), and writes task.json to the output mount. Host reads it and feeds the existing aggregation pipeline.
A result_kind discriminator on CriterionResult ensures ClassificationCriterionResult subclasses survive the JSON round-trip — without it, host-side aggregation would silently lose observed_label/expected_label.