Independent verification in a clean room
Turning on the Docker clean room, what it verifies, why environment matters, and configuring image builds.
zurdo v1.36.0
A verified acceptance criterion is only as good as the environment where it runs. An agent writes code in one machine; criteria run in that same machine. The clean room re-evaluates every passing criterion in an independent, network-isolated sandbox the agent never touched — a Docker container by default, or an E2B cloud sandbox (experimental; see below) — catching environment-specific artifacts that make a local pass spurious: a file ignored by git, a tool or cache in $HOME, or a network call.
This page covers the docker backend, the default, through What the room is trusted on. For the experimental e2b backend, see The E2B backend.
Turning it on
The clean room is off by default. To enable it, create the Docker image and add its digest to your config:
docker build -q -f .zurdo/clean-room/Dockerfile .zurdo/clean-room
# sha256:deadbeefdeadbeefdeadbeefdeadbeef…
Then set the digest in .zurdo/config.toml:
[verification.clean_room]
enabled = true
image = "sha256:deadbeefdeadbeefdeadbeefdeadbeef…"
cache = "off"
zurdo init generates the Dockerfile in .zurdo/clean-room/, choosing the image from your ecosystem: FROM rust:<rust-toolchain.toml channel> for Rust (with CARGO_HOME and CARGO_TARGET_DIR on cache volumes), FROM golang:<go.mod version> for Go, or a starter that says dependencies must be installed. Every image pins ZURDO_VERSION, downloads that release from GitHub, verifies checksums, and runs as the unprivileged zurdo user.
What the clean room verifies
At pre-flight, the runner captures the repository's history (a git bundle of HEAD and every tag). After an iteration's criteria all pass locally, the runner:
- Creates a pass bundle — one commit holding the iteration's verified tree, written as a separate bundle, parented on the history head.
- Sends the history bundle, the pass bundle, and the criteria to Docker.
Inside the container:
- Clone the history bundle into
/work. - Fetch the pass bundle's commit.
- Check out the iteration's tree.
- (Optionally) run
fetch_commandif configured, with network on, to download dependencies. - Re-run the criteria with network off.
If all criteria pass in the clean room exactly as they passed locally, the iteration record carries verdict: agree. If any criterion disagrees — a hint that passed locally but failed in the room — the iteration fails, recorded as verdict: mismatch with a mismatches entry for each hint that differed. A room that produces no verdict (Docker unreachable, version mismatch, timeout, unparseable output) records verdict: unavailable with a one-line reason. The report renders each of these as one cell; see In the report.
The failure classes
| Verdict | Meaning | Next |
|---|---|---|
agree | Every criterion passed in the room as it did locally. | Iteration passed. |
mismatch | A criterion failed in the room that passed locally. | Iteration failed; costs an attempt. The local pass was spurious — the room is the authority. |
unavailable | The room could not produce a verdict. | Iteration failed (on_error = "fail"), or warned and used the local verdict (on_error = "warn"). |
skipped | The task carries **Clean-room**: skip. | Iteration record carries the task's reason, run proceeds. |
Cache state
Three states, recorded in each iteration:
cold— this is the first pass using this image; the build and dependency cache volumes do not yet exist.warm— the build and dependency cache volumes from a prior pass in this run already exist and are reused; volumes are named by the run's state slug and the image's first 12 digest digits, so subsequent passes in the same run against the same image see a pre-populated cache. Volumes persist after the run finishes, so a later run of the same PRD on the same image starts warm.off—cache = "off"in config; no volumes are mounted, every pass is cold.
Configuration
[verification.clean_room] table
| Key | Default | Meaning |
|---|---|---|
enabled | false | When false, the clean room is not used. When true, an iteration whose criteria all pass locally is judged again in the room. image is required when enabled = true. |
image | (required when enabled) | Docker image digest, pinned to sha256:… (a tag is refused). The image must carry a zurdo binary at /usr/local/bin/zurdo. |
backend | "docker" | "docker" (the default) or the experimental "e2b"; see The E2B backend below. |
cache | "volume" | "volume" (create named volumes for dependencies and build output) or "off" (no volumes). Volumes are prefixed with your repository key and the image digest, so a new image never reuses an old build. |
on_error | "fail" | "fail" (unavailable room fails the iteration, costing an attempt) or "warn" (unavailable room is logged, local verdict stands). |
fetch_command | (optional) | Shell command to run in the container with network on, before criteria run. Examples: "cargo fetch --locked", "go mod download", "pip install -r requirements.txt". Skipped when absent. |
timeout_seconds | 900 | Clean room timeout in seconds. The timeout is enforced twice: once inside the container and once by the host. |
allow_root | false | When false, the backend refuses images whose Config.User is root. Set true only if the image cannot run unprivileged. |
zurdo_binary | (optional) | Path to a Linux zurdo binary to mount read-only over the image's binary, for tests and local dogfooding. The image must still carry a zurdo binary (for the version check). |
Mount points
| Path | Purpose |
|---|---|
/zurdo-in | Read-only. Holds the history bundle, pass bundle, and criteria file. |
/work | Writable. Where the repository is checked out and criteria run. |
/zurdo-deps | Writable (when cache = "volume"). Ecosystem dependency cache (CARGO_HOME, GOMODCACHE, etc.). |
/zurdo-build | Writable (when cache = "volume"). Ecosystem build output (CARGO_TARGET_DIR, GOCACHE, etc.). |
The E2B backend (experimental)
The e2b backend runs the same verification inside a throwaway E2B sandbox instead of a local Docker container — for users who do not run Docker, and for the demo. It is experimental and opt-in: set backend = "e2b" under [verification.clean_room].
[verification.clean_room]
enabled = true
backend = "e2b"
template = "your-template-id"
template_build = "abcdef123456"
template— the id of the E2B template the room runs. You build it yourself with E2B's own tooling; Zurdo has no template-build code of its own. The demo walks through building one end to end.template_build— the buildtemplateis pinned to. Before every pass, Zurdo checks this againstGET /templates/{template}, the template's last successful build; a rebuilt template is refused, naming both builds, so a rebuild can never silently change what a verdict means.- The
cache,allow_root, and Docker's mount points (/zurdo-deps,/zurdo-build) do not apply toe2b.
Environment
The API key is read from the environment only, never from config.toml:
| Variable | Meaning |
|---|---|
E2B_API_KEY | Required. The sandbox provider's API key. |
E2B_API_URL | Optional. Overrides the control plane, default https://api.e2b.app — for a self-hosted control plane, or a test's loopback server. An empty value is treated as unset, and a trailing slash is dropped. |
What one pass does
Each pass runs in one throwaway sandbox, created from template and killed when the pass ends, whatever its outcome:
- The sandbox is created with
allow_internet_accessoff, unlessfetch_commandis set, in which case it is created online. - The history bundle, the pass bundle, and the criteria file upload into
/home/user/zurdo-ininside the sandbox, as raw bytes. - The repository is checked out into
/home/user/work. - With a
fetch_command, it runs online first, then the network is cut before the criteria run. - The criteria run network-off, as the sandbox's non-root user,
user.
There is no cache — every pass is cold, and the record's cache is always off. The record's image is <template>@<template_build>.
What doctor and pre-flight check
zurdo doctor's clean room section reports the backend as e2b (experimental), fails naming E2B_API_KEY when it is unset, and otherwise reports the same template-build check pre-flight makes — ok naming the template and its pinned build, or a failure naming both the pin and the build it found. It never mentions Docker and never creates a sandbox.
zurdo run makes the same two checks at pre-flight, before any task runs and before any sandbox exists: a missing key or a rebuilt template refuses the run (exit 3), naming E2B_API_KEY or both builds.
What the room is trusted on
The Docker backend validates the image before it evaluates criteria:
- User check — the image must not run as root, unless
allow_root = true. - Version check — the image's
zurdo --versionmust match the host's version. A version mismatch can cause different hint semantics, making verdicts incomparable.
Any other failure — Docker unreachable, image corrupted, container spawn failure, timeout, unparseable output — becomes unavailable.
Skipping the room for a task
Some criteria cannot be verified in a network-less room: they need the user's credentials, an external API, or local configuration. Mark such tasks with:
**Clean-room**: skip — uses customer API credentials not available in clean room
The syntax is skip — <reason>, with the em-dash (U+2014) as the separator and the reason required. A task with this key is never sent to the room; the report notes it was skipped and why.
Timeout
The clean room's timeout is 900 seconds (15 minutes) by default, configured under [verification.clean_room].timeout_seconds in .zurdo/config.toml. The timeout is enforced twice: the room runs timeout to kill itself, and the host kills the container by name if it still runs. A room that ignores its own timeout cannot hang the run.
In the report
zurdo report shows one row per task:
| clean room | agree |
| clean room | mismatch — shell: cargo test, file-exists: Makefile |
| clean room | unavailable — Docker daemon unreachable |
| clean room | skipped — uses customer API credentials not available in clean room (never verified in the clean room) |
The JSON report carries the full clean_room record: the verdict, image digest, cache state, binary source, duration in milliseconds, the git tree and history head the room judged, and the differing criteria (if any).
Demo
This demo bakes a finished copy of the hello-world example into an E2B template, so you can watch zurdo verify --criteria pass three criteria inside a real sandbox. You need an E2B account, and nothing else: no agent and no provider key.
Make a directory with three things in it.
repo/src/greeter.txt, the finished example:
hello world
criteria.json, the example's three automated criteria in the shape verify --criteria reads:
{
"task": "task-build",
"criteria": [
"- [ ] greeter file exists [file-exists: src/greeter.txt]",
"- [ ] greeter says hello world [grep: hello world in src/greeter.txt]",
"- [ ] cat exits cleanly on the greeter [shell: cat src/greeter.txt]"
]
}
Dockerfile, which installs git and the latest Linux zurdo release (checksum-verified), bakes repo/ in as a git repository, and creates the two directories the e2b backend uploads into and checks out into:
FROM ubuntu:24.04
RUN id -u user >/dev/null 2>&1 || useradd -m -s /bin/bash user
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git \
&& rm -rf /var/lib/apt/lists/*
RUN set -eux; \
arch="$(uname -m)"; \
case "$arch" in \
x86_64|aarch64) ;; \
*) echo "unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \
base_url="https://github.com/numeron-lab/zurdo-dist/releases/latest/download"; \
curl -fsSLO "${base_url}/checksums.txt"; \
tarball="$(grep -o "zurdo-v[0-9.]*-${arch}-unknown-linux-gnu.tar.gz" checksums.txt | head -n1)"; \
test -n "$tarball"; \
curl -fsSLO "${base_url}/${tarball}"; \
grep " ${tarball}\$" checksums.txt | sha256sum -c -; \
tar -xzf "${tarball}" -C /usr/local/bin zurdo; \
rm -f "${tarball}" checksums.txt
COPY --chown=user:user repo/ /home/user/demo/repo/
COPY --chown=user:user criteria.json /home/user/demo/criteria.json
RUN mkdir -p /home/user/zurdo-in /home/user/work \
&& chown user:user /home/user/zurdo-in /home/user/work \
&& chown -R user:user /home/user/demo
USER user
WORKDIR /home/user/demo/repo
RUN git init -q \
&& git config user.name "zurdo demo" \
&& git config user.email "demo@example.invalid" \
&& git add -A \
&& git commit -q -m "the finished example" --no-gpg-sign
Then:
-
Build the template. Build the directory's
Dockerfileas an E2B template with E2B's own tooling, following E2B's template documentation. Give it a name such aszurdo-clean-room-demo. -
Read the build id. The build prints the template id and a build id. Zurdo pins a template to one build and refuses any other, so a rebuild needs its config updated too.
-
Point Zurdo at it. In a
.zurdo/config.toml(a scratch directory is fine), setbackend = "e2b",template, andtemplate_buildunder[verification.clean_room], and exportE2B_API_KEY.zurdo doctorchecks the key and the pinned build without creating a sandbox. -
Run the example in a sandbox. Open a sandbox from the template with E2B's tooling and run, inside it:
cd /home/user/demo/repo zurdo verify --criteria ../criteria.jsonRun it from
repo/: the criteria's paths are relative to the directoryverifyruns in. You should see:verify --criteria: task task-build [pass] greeter file exists [pass] greeter says hello world [pass] cat exits cleanly on the greeter 3 criteria passedChange
src/greeter.txtand run it again to watch a criterion go red. The sandbox is disposable, so there is nothing to clean up.
A one-click browser demo is not offered: opening a sandbox for an anonymous visitor needs a server holding an E2B key, and Zurdo does not host anything on your behalf (nothing here is "zurdo cloud" — the sandbox, like the Docker image, is yours).