Scope note. This is a design-decision document about sandbox isolation. It is unrelated to
docs/comparison.md, which compares coder_eval to other eval frameworks (SWE-bench, Harbor, …).
- Approach A — PR #88,
fix/docker-isolation-copy-prune: COPY/PRUNE + GRADE-OUTSIDE. Open, draft, 8 commits, +5,754 / −214 across 35 files. Implemented and verified. - Approach B — Declarative trust split + DAC permissions:
input_dir/reference_dironTaskDefinition,$INPUT_DIR/$REFERENCE_DIRsubstitution, agent runs as an unprivileged uid,reference_diris root-owned0700,task.yamlitself lives inreference_dir. A design, not yet built.
When coder_eval grades an agent, the grading material sits on the same filesystem the agent is
working in — including under --driver docker, which is the mode users assume is safe.
On main, an agent in the container can reach all of the following:
| Surface | Where it lives today |
|---|---|
Full success_criteria |
/work/input/task.yaml, staged verbatim (docker_runner.py:605-620) |
| Raw task YAML text | context.json.source_yaml |
| Reference solution | reference.file / reference.directory, auto-mounted (:1253-1254) |
Task-dir graders (check_*.py, RESOLUTION.md, reference agents) |
host task dir bind-mounted at its own host path (:1176) |
| Signposts pointing at all of the above | TASK_DIR / SKILLS_REPO_PATH in the agent's env |
The agent and the harness run as the same user in the container — both root, since neither
docker/Dockerfile nor docker/Dockerfile.runtime sets USER and _build_argv passes no
--user. So there is no permission barrier of any kind: a single grep -r reaches the answer key.
This is measured, not theoretical. PR #88 reports a nightly audit in which ~2.4% of replicates
read the suite instead of solving the task, and an adversarial "check /work" prompt drives that
to ~100%.
A separate but adjacent defect: ~/.uipath (including .auth) was mounted read-write from the
host original, so an agent could overwrite operator credentials.
The root cause is structural: the harness lives inside the sandbox it is supposed to be isolating
from. The in-container process runs the agent and grades it (run_task_internal_command.py:190-209),
so the rubric has to be shipped into the container at t=0.
Closes the leak by absence, not by permission. The container never contains grading material.
Three coordinated moves:
- COPY/PRUNE the agent's inputs. The staged
task.yamlis criteria-stripped viaagent_safe_dump()(success_criteria: [],reference: null) andcontext.json.source_yamlis nulled. Plugins are projected to a sanitized bundle copy (project_plugin_for_agent, allowlistPLUGIN_AGENT_ALLOWED_SUBDIRS=skills/commands/agents/hooks/.claude-plugin) mounted:roat/work/skills. The raw checkout, the reference, and the host task dir are not mounted at all. A grader-dir overlap guard hard-errors anytemplate_sources/system_prompt_file/extra_mountswhose source contains the task dir. - GRADE OUTSIDE. The container runs the agent only. After it exits, the host grades the
copied-out artifacts via
regrade_on_host, using the full unstrippedTaskDefinitionit still holds, withTASK_DIRpointing at the real host task dir. Re-grade is restricted to an allowlist of final statuses (SUCCESS/FAILURE/MAX_TURNS_EXHAUSTED). - Copy-then-mount host state.
~/.uipathand~/.claudeare forwarded as throwaway rw copies;~/.claude/jobs(operator session timelines) is denylisted.
Plus harness-outside: pre_run/post_run invoke helper scripts under the skills-repo tests/
tree, which is no longer mounted. post_run becomes host-only; pre_run gains a per-command
runs_in: host|agent (default host, seeding the workspace through a :ro /work/seed staging
mount). The PR body flags this as a known blocker affecting 243 of 1,147 downstream tasks (~21%);
the branch implements the fix, with only the ~7 uv sync tasks needing runs_in: agent.
Verification shipped with it: Detector A (host-unchanged — daemon-less mount-shape proxy in CI
plus an -m live byte-and-metadata-identical real run), Detector B (zero-grading-material in the
agent mount view, with a positive control so "staged nothing" can't pass vacuously), a baked-image
answer scan, and live confirmation that an adversarial "read your grader" task fails on gpt / claude
/ gemini / kimi.
Stated costs: early stop is disabled under docker (criteria are gone, so the in-container watcher can't arm — a warning is logged, verdict unaffected), and the design doc states plainly that allowlist-by-absence has no DAC backstop — if the prune boundary is wrong, nothing else stops the read.
Closes the leak by permission, with an author-declared boundary.
TaskDefinitiongainsinput_dir(agent-visible fixtures) andreference_dir(answer key), substituted as$INPUT_DIR/$REFERENCE_DIRinside the task file.task.yamlitself lives inreference_dir, so the rubric is protected by the same single rule as everything else — no special case.- In the container, the agent runs as an unprivileged uid;
reference_diris root-owned0700;input_dirand the workspace are chowned to the agent uid. - Effectively this splits the existing
$TASK_DIRtoken into its two halves by trust level — which is precisely why the leak exists today, since one token points at a directory mixing both.
Refinements this needs to be sound:
- Scope-aware substitution.
$REFERENCE_DIRmust be a hard validation error in agent-facing fields (initial_prompt,system_prompt, mutations) — resolving it there advertises the path and leaks it into logs and reports.$INPUT_DIRsubstitutes anywhere. - Copy, don't mount,
reference_dir. The host bind mount is:ro, sochmodfailsEROFS; making it writable would mutate the user's real repo permissions with no crash-safe restore. Stage a container-local root-owned copy — which also lets the symmetric same-host-path mount disappear. - Static, not scoped, permissions. A
with block_access(): …context manager that flips modes on entry and restores on exit is not crash-safe (SIGKILL between enter and exit leaves them flipped). With two identities, the perms are simply set once at stage time and never restored. - Dataset files default to
reference_dir— for activation suites the JSONL rows carryexpected_skill, which is the answer key. - The
~/.claudecredential copy must stay agent-writable, so0700-root cannot be applied blanket — it is per-path.
| Pros | Cons | |
|---|---|---|
| A — COPY/PRUNE + GRADE-OUTSIDE (PR #88) | Closes the leak even against a root agent — no identity work needed. Fails safe: a file that isn't there can't be read. Also closes surfaces B doesn't touch: host-credential overwrite (~/.uipath rw), ~/.claude/jobs exposure, env signposts, baked-image content. Built, tested, and empirically verified (detectors in CI + live adversarial probe across four models). Requires no per-task authoring changes for the leak fix itself. Removes the harness from the sandbox — the structural root cause. |
Early stop disabled under docker — a shipped feature silently degrades on one driver. Forced pre_run/post_run host-side, touching ~21% of downstream tasks and adding a new runs_in concept. No DAC backstop (the PR says so): the prune allowlist is "a coder_eval-side guess about what is answer-free", so a plugin putting answers in skills/ defeats it. Detector B catches known sentinels, not an unknown golden file. Large blast radius: +5,754 LOC / 35 files, restructuring orchestrator.py. Nothing protects tempdir. |
| B — Trust split + DAC permissions | Supplies exactly the DAC backstop A lacks — a second, independent barrier. The boundary is author-declared, not framework-guessed, replacing A's residual risk #1 with an explicit contract. Preserves early stop (criteria stay in container memory, which the agent can't read) and keeps pre_run/post_run in-container (they run as root and can still read reference_dir) — no runs_in, no 243-task migration. Much smaller runtime change: a uid on spawn plus chowns at setup. Conceptually simple: one rule, "reference_dir is harness-only". |
Depends entirely on identity separation that doesn't exist yet — both processes are root today, so chmod 0700 is a no-op until the agent runs as a separate uid. Fails open and silently: one missed chown, one new mount, and there's no absence to fall back on. Real friction running the agent CLI unprivileged (HOME, npm cache, the ~/.claude copy must stay writable). Pushes classification onto 1,147 downstream task authors unless the default is "protected unless declared input". Does not address host-credential overwrite, baked-image content, or env signposts. Also nothing for tempdir (no second identity on the host). Unbuilt and unverified. |
| Axis | A — Absence | B — Permissions |
|---|---|---|
| Works against a root agent | ✅ | ❌ (requires uid split first) |
| Failure mode | Fails safe (nothing to read) | Fails open (silently, if a chown is missed) |
| Boundary defined by | Framework allowlist (a guess) | Task author (a declaration) |
| Early stop under docker | ❌ disabled | ✅ preserved |
pre_run / post_run |
Moved host-side; runs_in added; ~21% of tasks affected |
Unchanged, in-container |
Host credential / ~/.claude/jobs / baked image |
✅ covered | ❌ out of scope |
tempdir driver |
❌ | ❌ |
| Per-task migration | None for the leak fix | input_dir / reference_dir across the suite |
| Framework blast radius | +5,754 LOC, 35 files | Smaller runtime change; new schema + token rules |
| Status | Implemented, CI-gated, live-verified | Design only |
These are not competing designs — B is the missing layer under A.
PR #88's own residual-risk list names its weakest point: "Allowlist-by-absence has no DAC backstop
… PLUGIN_AGENT_ALLOWED_SUBDIRS is a coder_eval-side guess about what is answer-free." That is
precisely the gap Approach B fills, in two independent ways:
- As a mechanism — a uid split gives the second barrier, so a prune-boundary miss is no longer game over.
- As a contract — and this matters more.
input_dir/reference_dirreplaces the framework's guess with an author declaration, which is the durable fix PR #88 already identifies as a cross-repo follow-up ("push the agent-bundle boundary into the skills repo — a manifest declaring the agent-safe surface"). Approach B is that manifest, expressed in the task schema.
If only one ships, it should be A: it is built, measured against real leak rates, gated by detectors in CI, and it closes several surfaces B never touches. B's mechanism half is also blocked on identity work (non-root agent, credential-copy ownership) that A doesn't need.
The sequencing that gets the most value:
- Land A. It is the structural fix — it removes the harness from the sandbox.
- Adopt B's declarative half next, as the durable replacement for the prune allowlist. It is the higher-value part of B and is independent of the permission mechanism.
- Add B's uid split as defense-in-depth where material must remain in-container.
- Revisit early stop. A disables it under docker; the leak-free restoration is a host-side
watcher over the live event stream — the host already receives every
ToolStartEvent/ToolEndEventwith fullCommandTelemetry(streaming/wire.py), and can already signal the container through the heartbeat channel. Worth first confirming the feature earns its keep: everystop_earlyusage in this repo is a fixture for testing early stop itself. - State plainly that
tempdiris not cheat-resistant under either approach. Neither has a boundary there; it should be documented as a development driver.