Repository navigation
bench: task containers in rootless Docker with required limits, exec-only, no daemon for agents [PROTECTED: owner merges] - #757
Conversation
…only, and no daemon for agents Second of three stacked PRs for #748 Phase 0. Each task's repo environment and tests run in a throwaway container, in the bench user's rootless Docker. The agents run on the host, in the engine's sandbox. That user's daemon can mount anything the user can read, its claude login included (owner's update, 2026-10-18). So an agent must never reach the daemon: - agent.ts: the sandbox denies XDG_RUNTIME_DIR, the Docker sockets, the login and the credential stores to the agent's commands and its Read tool. - auth.ts: agentBase drops DOCKER_*, XDG_RUNTIME_DIR and the bus address from every agent's and every probe's environment. - container.ts: the task runner sends only `docker exec <its container> argv`. Any other verb, any mount or privilege flag, and credential or daemon variables are refused. Each container: - has --network none, --cap-drop ALL and no-new-privileges, and mounts nothing (the worktree arrives as a tar stream on exec -i); - must have --memory with an equal --memory-swap, --cpus and --pids-limit (defaults 8g, 4, 4096; configurable; zero or missing is refused). Before the first task, a probe container is created, then read back through docker inspect and from inside its own cgroup (memory.max, cpu.max, pids.max). Each task's container is checked the same way. A limit that didn't apply refuses, naming the controller (the owner measured that rootless Docker gets none until systemd delegates them). hidden.ts: the task view (an allowlist) and hiddenLeaks. Every docker argv is checked against the row's hidden fields, and nothing is sent on a match. Test (fails without): test/bench.test.ts. Measured on the combined branch (9d73baa4) with each guard switched off: the limits read-back, the agent env and sandbox deny, the exec-only check and the hidden-field check each made their test fail. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (round 1/2)Reviewed: the diff of Acceptance and focus checks
Unix-socket connect (the builder's open inference)
Fail-without
Protection and housekeeping
Scope
CI
Required changes
Recommendations (not blocking)
|
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com> # Conflicts: # test/bench.test.ts
…t Read rules, strict docker flags, srt probe Review round 1 on the containers PR (#748), with the owner's decisions of 2026-10-18: - limits: the container's memory.swap.max is read back from its own cgroup and must be 0 (no swap beyond the memory); anything else refuses, naming the memory controller. A memory limit that isn't a multiple of 4096 is refused (the kernel keeps memory.max in pages, so it would never read back equal). - hidden fields: every byte on a docker command's stdin (a worktree's tar stream) is checked too, against the task's base tree: only a hidden piece the base tree lacks counts (added test-patch and gold lines, hints, the instance id, FAIL_TO_PASS ids and names). No base tree given: every piece counts. - sandbox: the lane's deny list is built with the engine's withDeniedReads, so the Read tool gets the exact rule for a file (Read(//home/u/.claude.json)), not only the folder form. The rootless daemon's data root and a unix DOCKER_HOST's socket are denied too. - checkDockerArgs: options are parsed with their values and the required ones are looked for among the parsed options only, so `--name --network` no longer passes; --name must be a bench container; --env must be NAME=value; --workdir must be normalized; options are own keys only. - benchClaude: the engine's runner with retries off (budgetMs 0): a transient failure is reported at once. - sandbox probe (owner's requirement, option A): before a run, from inside sandbox-runtime 0.0.79's sandbox (`srt`, version checked) with the lane's own denyRead, try to connect to every daemon socket and to read ~/.claude.json, ~/.netrc, ~/.npmrc and ~/.pypirc; refused unless every attempt is refused and its deny rule is in place. Test (fails without): bench.test.ts "a limit that did not apply refuses" (cgroup-swap, the 4096 case), "a tree's tar stream carries nothing hidden that its base tree lacks", "the Read tool is denied each credential file itself", the forged --name cases in "the task runner sends docker exec into its own container only", "the bench never retries a run", and "the sandbox probe refuses the run unless every attempt from inside the sandbox is refused". Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…all clock The repo's no-wall-clock-durations check (#582) refused the new "the bench never retries a run" test, which read its elapsed time as Date.now() minus a start; an NTP step moves that. It now uses the monotonic clock. Test (fails without): no-wall-clock-durations.test.ts, "no test file measures a new duration on the wall clock" (CI run 39185975983, shard 4/4: "test/bench.test.ts: 1, allowed 0"). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…wall clock The repo's no-wall-clock-durations check (#582) counts Date.now() minus a start in a test file as a duration an NTP step can move; the new wall-cap test had two. They now use the monotonic clock. Test (fails without): no-wall-clock-durations.test.ts, "no test file measures a new duration on the wall clock" (the same check failed #757's CI run 39185975983 on this file). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 1 fixes (head 2fda9e6; #756's round 1 merged at 2c8a6b8)Labels: measured = a command I ran or CI output, inferred = reasoned, not run. Required changes
Also done
Body
Fail-without method (measured)
Local and CI (measured)
|
Evaluator verdict: CHANGES NEEDED (round 2/2)Reviewed: the diff of All four round-1 required changes are done, and so are the extras and the owner's check A. Two things are left, and both come from the new tar-stream scan:
The scan was my round-1 option (a). I asked for test-patch lines and FAIL_TO_PASS names to be counted, and I missed that the tree being scanned is the agent's own. Round-1 required changes
Extras
Owner's sandbox check A (
|
| Item | Result | Evidence |
|---|---|---|
| srt version pin | Met | SRT_VERSION = '0.0.79'. requireSrt refuses unless the trimmed stdout of srt --version is exactly that. inferred from srt's cli.js: program.version(pkg.version), and its package.json has "version": "0.0.79". measured (test): a missing srt and a 0.0.78 srt are both refused before any attempt. The install step is in #759's README (npm install -g --prefix ~/.local @anthropic-ai/sandbox-runtime@0.0.79). |
| Attempts cover every Docker socket path and the four files | Met for the owner's list | measured probeAttempts: connects to $XDG_RUNTIME_DIR/docker.sock, /run/docker.sock, /var/run/docker.sock and the DOCKER_HOST socket (every socket benchDenied lists), and reads of ~/.claude.json, .netrc, .npmrc and .pypirc. See recommendation B for two more sockets under the runtime dir. |
| Run refuses unless every attempt is refused | Met | sandboxProbe (probe.ts:136-148) refuses in any of these cases: a non-zero exit, unreadable output, a count mismatch, or any attempt not refused. measured (test): the no-sandbox stand-in is refused, naming connected and opened file, and the sentinel isn't in the message. |
| Missing file counts only with its deny rule | Met | verdict = rule && …. measured: verdict(false,'read','ENOENT') is false and verdict(true,'read','ENOENT') is true. The lane without exact Read rules (sandboxSettings alone) is refused with no deny rule. on_host is recorded beside each attempt. |
| "close to, not identical" label | Met | measured: sandbox-runtime 0.0.79: close to, not identical with, the build bundled in Claude Code 2.1.303. The test pins this exact string. |
| Result recorded | Met for this PR's part | The report (with runner) is returned, and it's attached to the error on refusal. The summary write is #759's: driver.ts:161, const probe = o.preflight.sandboxProbe() (measured by grep on issue-748-bench-driver; not reviewed here). |
| Stand-in tests for pass and refuse | Met | fakeSrt('honors') decides from the denyRead in the generated srt settings file and passes. fakeSrt('none') refuses, and the no-exact-rule lane refuses. The none stand-in sends every attempt outside its temp dir to a missing path, so the test never touches the machine's real sockets (inferred from the code). |
| srt semantics | Consistent | inferred from srt's source: a read-denied file gets --ro-bind /dev/null (linux-sandbox-utils.js:2375), which the probe reads as opened char-device = refused. socket(AF_UNIX) is blocked by seccomp unless allowAllUnixSockets is set (:2631). The lane never sets it (src/sandbox.ts:28-44). If the apply-seccomp binary is missing, srt only warns (:2633). The probe still requires the deny rule for sockets, so that case doesn't pass silently on the rule check. |
The tar scan: can hidden content get in?
All measured by probing TaskRunner.syncTree on the head build, with a base tree given.
-
A file the base already has, changed by the test patch: Yes, when every added line is under 24 characters. A test patch that changes
assert f(-1) == -5toassert f(-1) == 0inside the existing FAIL_TO_PASS testtest_negativeswas SENT. The test's name is in the base, so it doesn't count, and the changed line is too short to be a piece. This is the common SWE-bench shape: an existing test's expected value changes. A longer added line in the same file was refused. -
A binary or encoded file: Yes. The new FAIL_TO_PASS test was SENT in three forms:
- as a gzip member inside the tar;
- as a whole gzipped tar stream;
- as a UTF-16LE file.
The same text as plain UTF-8 was refused. The scan is a UTF-8 substring search, so it can't see inside compressed members (
.git/objectsincluded, if a tree ever carries.git) or other encodings. -
A tar header field: No gap found.
- The instance id as a file name in a header was refused, and so was the hints text in a pax record.
- inferred: a piece can split across ustar's prefix and name fields only at a
/, so instance ids, test names and hints, which have no/, can't be split. - inferred: numeric header fields (mode, mtime) could carry data only by deliberate encoding, which isn't this wall's threat (a driver bug).
-
The other direction (it refuses legitimate work):
- An agent's own correct fix, identical to the gold line, was refused (
hidden patch). The line wasreturn sum(c * q for c, q in items if q > 0)with no marker. - An agent's own regression test that happens to share the upstream test's name was refused (
hidden test_patch, FAIL_TO_PASS). - The PR's own test asserts the first case: "the gold patch" in
bench.test.tsis the fix line plus a marker comment. - inferred: the refusal names the matching field. Once the planned relay passes errors back to the agent, that makes an oracle: the agent can learn which of its lines or test names match the hidden patch.
- An agent's own correct fix, identical to the gold line, was refused (
-
No base given: the unchanged base tree itself was refused (
hidden FAIL_TO_PASS, PASS_TO_PASS), becausesendpasses''and so every test name counts. This fails closed. But bench: run driver, patch export, grading inputs, aggregates and setup README [PROTECTED: owner merges] #759 buildsnew TaskRunner(o.docker, c, t.row, undefined, signal)(measured by grep), so any real tree synced there would be refused. See recommendation A.
Fail-without
-
measured: this PR's tests were run against the round-1 head's own modules, not a guard-off edit. I ran
git checkout a52dafd7 -- scripts/bench/container.ts scripts/bench/hidden.tsin my worktree, then built and ran the tests:# pass 15,# fail 3:Missing expected exception: exec --env HOME bench-r1-t01 ls;Missing expected exception: cgroup-swap;Missing expected exception: an added line of the test patch.
This matches the builder's numbers. tsc reported one type error,
TaskRunnercalled with 4 arguments where the old code takes 3, and still emitted. -
measured, with round-1
agent.tsas well: the test file no longer links (benchDeniedis missing), so I probed the build instead.Read(//home/u/.claude.json):false.- Data root and
DOCKER_HOSTsocket denied:false. - These were
ALLOWED:run --name --network,--name --cap-dropand--name --security-opt, each with the real flag removed;exec --workdir=/work/../etc;exec --env HOME; and memory100000000. - inferred: the old
benchClaudebuiltCliRunnerwith no retry options, so it used the default budget.
-
measured: after restoring the head modules, the bench tests give
# pass 18,# fail 0.npm run typecheckexits 0.npm run check:denylistgivesdenylist: 1275 files clean. All runs used a temp HOME, state dir and held-out dir, withXDG_RUNTIME_DIR,DOCKER_HOSTand the bus address unset. -
probe.tsis new, so there is no old code to run it against. Its stand-in tests cover both outcomes (above).
Size
- measured: 969 lines (
git diff --stat). That's about 421 non-comment code lines in the four new modules, plus 15 inauth.ts, and 309 of tests. - Acceptable (inferred): it's one cohesive unit (containers, limits, the hidden wall, the agent lane), and the owner added option A mid-review.
probe.tsand its test (about 220 lines) were the one clean split. Splitting now would cost another review round for little gain.
Protection and housekeeping
- measured: every path is under
scripts/**ortest/**. Protection is marked: the title has[PROTECTED: owner merges], the body has**Risk class:** protected, the PR has theneeds-ownerlabel, and both sections are present. - "What this can never do":
- The mount and network bullet holds (measured, above).
- The limits bullet holds (measured).
- The daemon bullet: the probe's refusal is wired in bench: run driver, patch export, grading inputs, aggregates and setup README [PROTECTED: owner merges] #759, not here (inferred).
- The hidden-field bullet ("or in a tar stream except where the task's base tree already holds it") is not true as worded (measured, above). See required change 1.
- No regression for existing installs: measured, the diff touches nothing in
src/. inferred: no install route, permission or exposure changes. - measured: 4 commits, all signed off (DCO CI green). No Co-Authored-By or other attribution. No new dependencies. No
.claude/settings.json. No hardcoded product name added. - Real state (rule A), inferred from the code:
- The tests use a fake claude, stand-in srts, temp dirs and the moved HOME.
- The probe test only
lstats the host's/run/docker.sockand/var/run/docker.sock(metadata) and never connects to them. - Nothing reads or writes the owner's state.
Scope
- inferred: there is no scope creep. Option A and
NO_RETRYare the owner's decisions of 2026-10-18.
CI
- measured (
gh pr view 757 --json statusCheckRollup, head 2fda9e6): every check is SUCCESS, and the only others are theci slowjobs, which are SKIPPED. That covers all 8 test shards on Node 22 and 24, dco, denylist, new-deps, gitleaks and agentshield. - The first CI run on 0950e0e failed on the wall-clock-durations check. That was the builder's own test, and 2fda9e6 fixes it.
Required changes
-
Make the tar-scan claims match what it covers. The places to change:
hidden.ts:4-8("every byte on a command's stdin");container.ts:15-16;- the PR body's "What this can never do" hidden-field bullet.
Each should say the scan is a UTF-8 substring check of:
- test-patch and gold added lines of at least 24 characters;
- test ids and names of at least 12 characters;
- the hints and the instance id.
And say what it can't see:
- compressed or binary members;
- other encodings;
- shorter lines, such as a changed expected value in an existing test.
The first wall (taskView plus the held-out deny) is what covers those. measured: the four "SENT" cases above.
-
Stop the stdin scan from refusing an agent's own work. measured: an agent fix identical to the gold line is refused, and so is an agent test that shares the upstream test's name. Concretely:
- On stdin, count only pieces an agent can't plausibly write itself: the instance id, the hints, and the test patch as a run of at least 3 consecutive added lines (what a driver bug that applied the patch would carry), not single lines.
- Drop gold-patch lines and test names from the stdin check.
- Add a test: a tree whose fix equals the gold line, and one with a same-named test, both go through, while an applied test-patch hunk is still refused.
If the owner prefers to keep single-line matching, the alternative is to record a match on the task's result for the owner instead of refusing. Either way, the relay must not echo which hidden field matched (inferred oracle, above).
Recommendations (not blocking)
- A. Make
TaskRunner'sbaserequired.- With no base, every real tree is refused (measured), and bench: run driver, patch export, grading inputs, aggregates and setup README [PROTECTED: owner merges] #759 passes
undefinedtoday. - In the same way, make
LaneInput.dockerHostanddataHomerequired keys (string | undefined), so a caller can't silently forget them. No caller passes them yet (measured, grep of bench: run driver, patch export, grading inputs, aggregates and setup README [PROTECTED: owner merges] #759).
- With no base, every real tree is refused (measured), and bench: run driver, patch export, grading inputs, aggregates and setup README [PROTECTED: owner merges] #759 passes
- B. Also probe
$XDG_RUNTIME_DIR/busand the rootless containerd socket ($XDG_RUNTIME_DIR/docker/containerd/containerd.sock; path inferred from rootless dockerd's exec-root).- Both are covered only by the runtime-dir deny today. Either one gives the same reach as the Docker socket:
systemd-run --userthrough the bus runs commands outside the sandbox.
- Both are covered only by the runtime-dir deny today. Either one gives the same reach as the Docker socket:
- C.
dockerHostSocketignores a relativeunix://name.sock(measured: no deny is added). Docker contexts (~/.docker/contexts) can also name a socket. Consider refusing to start when the driver's daemon socket isn't under a denied path. - D.
sandboxProbeleaves its settings file inos.tmpdir(). Remove it after the run.
…issue-748-bench-containers Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com> # Conflicts: # test/bench.test.ts
…nd the scan's claims made exact Round 3 on #757, the owner's decisions of 2026-10-18. The stdin scan refused an agent's own correct work: a fix identical to the gold line, and a regression test that shares the upstream test's name. On stdin (a worktree's tar stream) only the instance id, the hints and the test patch as a run of 3 or more consecutive added lines now count (stdinCarriesHidden): what a driver bug that applied the test patch would carry. Gold-patch lines, test names and test ids are no longer checked there. Every stdin refusal says the same words, so a refusal relayed to an agent never tells it which of its lines match the hidden patch. The argv scan is unchanged. hidden.ts and container.ts now say exactly what is scanned (UTF-8 substrings: argv added lines of at least 24 characters, test ids of at least 12, the hints, the instance id; stdin as above) and what isn't (compressed or binary content, other encodings, shorter lines), and that the first wall is taskView. Also: TaskRunner's base and LaneInput's dockerHost and dataHome are required; a task's stdin with no base is refused. The probe also tries $XDG_RUNTIME_DIR/bus and the rootless containerd socket. A relative unix:// DOCKER_HOST and DOCKER_CONTEXT are refused, and the driver's docker always gets DOCKER_HOST (the rootless socket by default), so a docker context never picks its daemon. The probe's settings file is removed after the run. Test (fails without): "a tree's tar stream is refused only for the instance id, the hints or an applied test-patch run", "the driver's docker always names its daemon" and the sandbox probe test, in test/bench.test.ts. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 3 (owner-granted): what changedHead d57acd8. #756's round-3 head (571f3c0) is merged in first with a signed-off merge commit, b79b30b. The only conflict was 1. The tar-scan claims are now exact (owner decision 1).
2. Stdin is refused only for what an agent can't write (owner decision 2).
3. Non-blocking items, all done.
Local results (measured): |
… issue-748-bench-driver #757's round 3 (d57acd8), with #756's round 3 and main under it. Conflicts resolved in scripts/bench/container.ts (the async exec kept, TaskRunner's base now required ahead of the signal) and test/bench.test.ts (both sides' tests kept, the new tar-stream test made async). What the merge needed to build on #757's API: BenchOptions.baseTree, required, gives each task's base tree to its TaskRunner (the driver passed undefined before); the test machine passes dataHome and a base tree; the confirmation test counts the probe's two new sockets. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (round 3)Reviewed: the diff of Round 3 does what the owner asked, with one exception. The new stdin rule doesn't catch the test patch when it is written into the tree as a All runs used a temp HOME, state, held-out and XDG dirs, with refusing Round-3 items
The two choices, and the tar edge casesAll measured with my probe on the head build, using a real ustar stream (512-byte headers, NUL padding).
Fail-withoutmeasured: I made a scratch worktree at d57acd8 and ran
These match the builder's numbers. On the head: Protection, size, housekeeping
CImeasured: Required changes
Recommendations (not blocking)
Open item (noted, not required here)The argv scan still names the matching field (measured: |
… a run of imports through Round 4 on #757, the owner's decisions of 2026-10-18. Round 3's stdin rule matched only whole applied lines, so the test patch written into the tree as a .diff file (its lines start with `+`, LF or CRLF) went through, where round 2 refused it. stdinCarriesHidden now also matches each line with one leading `+` dropped, with the same base exemption (a run the base tree holds in either form doesn't count). A window made only of import lines (Python `import a` / `from a import b`, the names inside an open `from a import (`, a `\` continuation) is no longer a test-patch run: an agent's own test file that starts with the same imports as a new upstream test file was refused. A run of imports and code still counts. Also: a blank context line in the test patch no longer ends a run (the tree side already drops blank lines), and the tar stream gets a NUL before each header block (tarText), so a file of exactly 512*n bytes whose last line ends the run, with no trailing newline, no longer joins that line to the next file's name. Test (fails without): the five new "stdin: ..." tests in test/bench.test.ts: the test patch as a .diff file (LF), the same with CRLF, an agent's own test file with a new test file's imports, a blank context line, and a run that ends a 512-byte tar member. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 4 (owner-granted): what changedNew commit 90b1bab on top of d57acd8 (no force-push). Labels: measured = a command I ran, inferred = from reading code, guess = judgement. Owner decision 1: the test patch as a
|
…iners Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: PASSRound 4 (owner-granted), final check of head 94c6e71 (fix 90b1bab plus a signed-off merge of main 0b294b5), against base All my runs used a throwaway HOME, state, held-out and XDG dirs, with refusing 1. The two owner fixes holdProbe: a Django-shaped test patch with two hunks in an existing file (a parenthesised import block, then a new test whose 2nd line is
Every stdin refusal is the single Evasions (measured on the head, all sent):
Trailing spaces after each line, and a Which matter:
2. The non-blocking extras don't weaken anything that matters
3. Fail-without (measured)Scratch worktree at d57acd8. Only
On the head: 4. The rest
Required changesNone. Non-blocking (for the owner's merge note)
|
…r; edit tools only in the run's folder Round 4 of #759 (the owner's decisions of 2026-10-18 and 2026-10-19, and the round-3 verdict's required changes): - Check D adds an unparsed read of each sentinel: node -e READ_JS. Claude Code's permission layer doesn't parse it, so the OS sandbox's own denyRead is what must stop it. READ_JS fstats the open descriptor and exits 3 on anything but a regular file, so the sandbox's /dev/null mask counts as a refusal, not a leak (as the probe's PROBE_JS). - Each attempt records which layer stopped it, from the stream-json only, failing closed. The permission layer: the result line's permission_denials (by tool_use_id), or the tool result's denial wording; both are inferred from public docs, not verified against 2.1.303. The OS sandbox: only the exact listed read failing on a masked or unreadable sentinel. Otherwise "unknown" or "model declined", never the OS sandbox. The driver warns when the OS sandbox didn't refuse every unparsed read itself. - "all refused" only when the model ran nothing but the listed commands. A read through $HOME, or any other tool, now makes it "model declined" at best. - Edit, Write, MultiEdit and NotebookEdit are scoped to the run's own working folder, not the whole task folder (an Edit allow rule also widens where sandboxed Bash may write), and a run whose cwd is outside the task's folder is refused, unstarted. - A path put into a rule (the task's folder, the run's cwd) with a glob or rule character, or a trailing slash, is refused rather than escaped: the escaping of Claude Code's rule globs isn't documented. - Sentinels are removed on SIGHUP too. - README and driver.ts: what each read tests, the layers, and the real example of an unscoped Edit (~/.docker/config.json, not ~/.profile, which dontAsk already protects). - #757's new stdin tests await syncTree, which is async on this branch (the merge left them calling it synchronously, so they failed here). Test (fails without): on the pre-fix sources (a049531's confirm.ts, usage.ts and driver.ts), the new tests fail: the node -e read, the layers, "all refused" with an extra command, the cwd scope, the rule-path refusal, SIGHUP, and the driver's per-attempt layers. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Intent: This is the second of three stacked PRs for #748 Phase 0. Each task's repo environment and tests run in a throwaway container in the bench user's rootless Docker; the agents run on the host in the engine's sandbox. Each container:
memory.swap.max= 0 included) before anything runs in it;taskView, the first wall). As a narrow second wall, the runner scans a command's argv and stdin for a driver bug that would pass one on (exactly what it checks is below).The bench user's daemon could mount that user's claude login into a container, so no agent can reach it:
Nothing here runs a model, Docker or a download.
Risk class: protected (every path is under
scripts/**).Part of #748. #756 (preflight) is merged; base
main, with main 0b294b5 merged in. Next: #759 (driver, grading, aggregates, README).Size: 1,282 lines against the base (measured,
git diff --stat, round 4), over the ~700 the owner asked to hear about. 846 are in the five modules (code and comments) and 436 in the tests. I have not split it further.What's in it
scripts/bench/container.ts:checkDockerArgs, which every docker command passes. Each option is parsed with its value. The required options are looked for among the parsed options only, so a value (--name --network) never stands in for one. Option names are own keys. It allows only:info --format;inspect --format <bench container>;startandrm --forceof a bench container;create/runwith exactly--name <bench container>,--network none,--cap-drop ALL,no-new-privileges, the four limits,--workdir /workand--entrypoint sleep;execwith only a normalized--workdirunder/work,--env NAME=value(the=is required, and no credential or daemon name is allowed) and--interactive.Any
-v,--volume,--mount,--privileged,--device,--useror other network is refused.dockerEnv: the docker CLI's environment carries no credential variable and always hasDOCKER_HOST: the bench user's own, or else the rootless socketunix://$XDG_RUNTIME_DIR/docker.sock. So the CLI never takes its daemon from a docker context, which can name any socket. It refusesDOCKER_CONTEXTand a relativeunix://DOCKER_HOST.TaskRunner, the task's commands: it allows onlyexecinto its own container.syncTreesends a worktree as a tar stream onexec -i, so nothing is mounted. Itsbase(a tar of the task's base commit, made by the driver before any agent runs) is required. A task's stdin with no base tree is refused outright.Limits:
limitsFrom:WORKLANE_BENCH_MEMORY/CPUS/PIDS, defaults 8g, 4, 4096. Refused: a zero or missing value, CPUs over the host's, memory over 75% of the host's, and memory that isn't a multiple of 4096 (the kernel keepsmemory.maxin pages, so it would never read back equal).Docker.probe: create, then inspect (Memory, MemorySwap, NanoCpus, PidsLimit), then start, then readmemory.max,memory.swap.max,cpu.maxandpids.maxinside, then rm.Docker.start: the same read-back for each task's container.checkLimitsrequiresmemory.swap.maxto be0. It raisesLimitsNotApplied, naming the controller and the README's Delegate= drop-in.Rootless check:
docker infosecurity options must includename=rootless.scripts/bench/hidden.ts:taskView, the first wall: an allowlist of the base commit, the problem statement and the test runner. No hidden field (the gold patch, the test patch, FAIL_TO_PASS, PASS_TO_PASS, the hints, the difficulty, the instance id) is ever handed to an agent or a container.The second wall is a UTF-8 substring scan, and it checks only the following.
Every docker argv (
hiddenLeaks):A piece the problem statement holds doesn't count. A refusal names the field.
A command's stdin, the tar stream included (
stdinCarriesHidden, owner's decision of 2026-10-18). Only what an agent can't plausibly write itself counts:+dropped, so the test patch written into the tree as a.difffile (LF or CRLF) is refused (round 4);import a/from a import b, the names inside an openfrom a import (, a\continuation. Whether a line is an import line is read from the new side of the patch's hunk. A run of imports and code still counts;tarText, round 4), so a file of exactly 512·n bytes with no trailing newline doesn't run its last line into the next file's name.Gold-patch lines, test names and test ids are not checked on stdin: an agent's correct fix may equal the gold line, and its own regression test may share the upstream test's name. A piece the base tree already holds doesn't count (for a test-patch run, in either form). The id and the hints also don't count when the problem statement holds them.
Every stdin refusal says the same words, whatever matched, so a refusal relayed to an agent never tells it which of its lines match.
What the scan can't see:
.gitobject pack);.difffile included: the owner's round-2 decision) and test names.Those rely on the first wall.
scripts/bench/agent.ts:benchLane: built with the engine'swithDeniedReads, so the Read tool gets both rule forms for each path,Read(//p/**)and the exactRead(//p). A file such as~/.claude.json,~/.netrc,~/.npmrcor~/.pypircmatches only the exact form. Commands reach no host. The lane denies:$XDG_RUNTIME_DIR(itsdocker.sock,busand the rootless containerd socket included) and itsdocker.sockby name;/run/docker.sockand/var/run/docker.sock;DOCKER_HOST's socket (a relative one is refused);~/.local/share/docker, and$XDG_DATA_HOME/dockerwhen set);~/.claude,~/.claude.json,~/.docker(contexts included) and the other credential stores;LaneInput'sdockerHostanddataHomeare required keys (string | undefined), so a caller can't silently leave them out.daemonHost: the daemon the driver's docker talks to (seedockerEnv).benchClaude: the engine'sCliRunnerwith retries off (NO_RETRY,budgetMs: 0). A transient failure, a 429 included, is one session, reported at once. bench: preflight for the SWE-bench run (subscription-only login, CLI pin, caps, usage meter) [PROTECTED: owner merges] #756's meter then stops the bench on it.scripts/bench/probe.ts(owner's requirement and choice, option A, 2026-10-18): the sandbox probe, before every run.srt --versionmust be exactly0.0.79, or the run is refused.srt --settings <file> -- node -e <probe>, with the file holding the lane's owndenyReadand allowed hosts as sandbox-runtime configuration, it tries:$XDG_RUNTIME_DIR/bus(through it,systemd-run --userruns a command outside the sandbox) and the rootless containerd socket$XDG_RUNTIME_DIR/docker/containerd/containerd.sock;~/.claude.json,~/.netrc,~/.npmrcand~/.pypirc. It reports only what each one is (fstat) and never reads a byte./dev/null).on_host(an lstat outside the sandbox) is recorded alongside.--helpcommands list, and the CLI reference).[Sandbox Linux] …log strings of each published sandbox-runtime release with the 2.1.303 binary, usingscripts/bench/srt-fingerprint.mjs(added in bench: run driver, patch export, grading inputs, aggregates and setup README [PROTECTED: owner merges] #759, read-only). The bundled build lacks 0.0.79's "Re-covering a mount point an earlier sandbox left behind". It has a "No placeholder for …: neither bwrap nor the command can create …" line that no release has. 0.0.80 differs in about 4 more lines. So it sits between 0.0.79 and 0.0.80, and 0.0.79 is closest.scripts/bench/auth.ts:agentBasedropsDOCKER_*,XDG_RUNTIME_DIR,DBUS_SESSION_BUS_ADDRESSandCONTAINER_HOST. It applies to the runner's base, to each request's own env and to the bench's probes.test/bench.test.ts: 26 tests in all on this branch (10 from bench: preflight for the SWE-bench run (subscription-only login, CLI pin, caps, usage meter) [PROTECTED: owner merges] #756).Known issue (for the planned test relay)
hiddenLeaks(the argv scan) refuses an exec whose argv names a FAIL_TO_PASS or PASS_TO_PASS test by its pytest id (measured by #757's reviewer, and again in round 3). The agent's test relay (after #759) will hit this when an agent asks to run such a test, and the refusal names the field. That relay must not echo argv refusals to the agent as they are (inferred: the same oracle as on stdin), and should exempt ids already in the base tree.Still ahead in #748 Phase 0: the PR-mode task runner on
src/harness-evals/replay.tswith stub CI, the agent's test relay into the container (the agent can't reach the daemon, so its test runs need a driver-side relay throughTaskRunner), task selection, the pinned-download scripts, the grading run, the B0, B1 andclaude -pbaselines, and the CLI entry point. The driver and grading are in #759.How it was verified
claude,dockerandsrtstand-ins first on PATH (their hit log was never created):bench.test.jsgives# pass 26,# fail 0.npm run typecheckexits 0.npm run check:denylist:denylist: 1283 files clean.npm run check:dco -- origin/main HEAD:DCO: all commits signed off.git diff --stat origin/main...HEAD: only this PR's 6 files, 1,282 insertions and 6 deletions, the same as against the old base.test/bench.test.tschecked out:# pass 21,# fail 5. Each new test fails there:.difffile (LF):true !== false(sent);true !== false(sent);the agent's own test file, with the same imports: sent,false !== true(refused);split by a blank context line " ": refused,true !== false;a run ending a 512-byte file, then a header: refused,true !== false..difftest patch, with and without the instance id elsewhere and with CRLF, is now refused. The two 2-line blocks split by a blank context line are refused. The run ending at exactly 512 bytes is refused. The agent's test file with the same 3 imports is sent. The gold patch as a.difffile is still sent (owner's decision). Every "must pass" and "must refuse" case keeps its round-3 result. Split-across-files and reordered runs are still sent.node --import ./dist/test/isolate.js --test dist/test/bench.test.jswith a temp HOME, state and held-out dirs and refusingclaude,dockerandsrtstand-ins first on PATH:# pass 21,# fail 0.npm run typecheckexits 0.npm run check:denylist:denylist: 1282 files clean.npm run check:dco -- origin/main HEAD:DCO: all commits signed off.scripts/bench/{hidden,container,agent,probe}.tschecked out from its parent. No guard was edited.# pass 18,# fail 3:refused: this docker command's input would carry the task's hidden patch (not in its base tree)on an agent fix identical to the gold line;Missing expected exception: the lane: unix://docker.sock;the settings file is removed,true !== false.hidden patch, then sent;hidden test_patch, FAIL_TO_PASS, then sent;… input carries hidden task content that its base tree lacks);busand the containerd socket);DOCKER_HOST: the CLI got none, then gotunix:///run/user/1001/docker.sock;DOCKER_CONTEXT=elsewhere, and a relativeunix://docker.sock: sent, then refused before anything was sent. The lane with that relative socket: accepted with no rule for it, then refused.baseand theLaneInputkeys being required is enforced by tsc. The runtime refusal of a task's stdin with no base is in the tar-stream test.~/.claude.jsonand a listening unix socket as the daemon's. There was no model, no Docker and no real home. socat and bubblewrap 0.9.0 are on this machine.EPERM;~/.claude.json:EACCES(it exists on the host);~/.netrc,~/.npmrc,~/.pypirc:ENOENT, missing on the host, deny rule present.~/.claude.jsonand runtime-dir rules was refused:read <tmp>/u/.claude.json: no deny rule, where the sandbox sawopened file.EPERMcomes from sandbox-runtime's seccomp filter, which blocks every unix socket, a socket not denied included. So for sockets the probe also requires the deny rule (checked in the settings). The two sockets added in round 3 weren't run under the real srt (inferred: both sit under the runtime dir the lane denies).socket -> socket).git checkout a52dafd7 -- scripts/bench/{agent,container,hidden}.ts, then restored; no guard edited):container.ts/hidden.ts:# pass 15,# fail 3:Missing expected exception: exec --env HOME bench-r1-t01 ls;Missing expected exception: cgroup-swap;Missing expected exception: an added line of the test patch(the tar stream; that check is replaced in round 3).agent.tstoo (the test file no longer links, so I probed the build directly), the old code:run --name --network,--name --cap-dropand--name --security-opt(the real flag removed),run --name victim,exec --workdir=/work/../etcand memory100000000;Read(//home/u/.claude.json)in the deny list: false;~/.local/share/dockerand theDOCKER_HOSTsocket denied: false;benchClaudeon a 429:transient waits started=1.transient waits started=0, reason=failed.srts: one that honours the settings (passes), and one with no sandbox and a live socket (refused, namingconnectedandopened file). It also covers a wrong version and a lane without the exact Read rules.Missing expected rejection: inspect-memory;agentBaseoff gaverun: XDG_RUNTIME_DIR; the verb allowlist off gaveMissing expected exception: cp x bench-r1-t01:/x; the hidden-field check off gave0 !== 6.ENOENT,/dev/nullover the socket givesECONNREFUSED), and by the srt run above.New dependencies
None. sandbox-runtime 0.0.79 is installed for the bench user only (README step in #759). The repo's own pin (0.0.78, used by the engine's MCP broker) is unchanged.
What this can never do
Mount anything into a task container, or give it a network, a capability or a device:
checkDockerArgsrefuses every such option on parsed options (a value can't impersonate one), andmakeArgsbuilds none.Run a task container without its memory, CPU and pids limits, with swap beyond its memory, or with a limit that silently didn't apply. Both the daemon's record and the container's own cgroup are read back, and a mismatch refuses.
Let an agent reach the bench user's daemon or user bus through the environment or the sandbox. Every run is refused unless sandbox-runtime 0.0.79's sandbox with the lane's settings refuses each socket (the user bus and the containerd socket included) and credential file. That is close to, not identical with, the build Claude Code 2.1.303 bundles. bench: run driver, patch export, grading inputs, aggregates and setup README [PROTECTED: owner merges] #759 adds a confirmation in Claude Code's own sandbox.
Talk to a daemon other than the one whose socket the lane denies through a docker context or a relative socket path: the driver's docker always gets an absolute
DOCKER_HOST, andDOCKER_CONTEXTis refused (measured, test and probe). inferred from the docker CLI's documented precedence: an explicitDOCKER_HOSToverrides the configured context.Hand a hidden field to an agent or a container:
taskViewis an allowlist (the first wall). The second wall is narrower, and it can miss:.difffile included) or a test name.What it refuses is listed in the hidden.ts entry above.
Compromised component
srtthe bench user installed: it runs only the probe, with the lane's settings, as the bench user. A tamperedsrtcould report false refusals. The version is checked but not the bytes, so a reviewed hash pin would be the next step (inferred).src/imports it.RUN_OPTIONSorEXEC_OPTIONS, loosen a limit, changeSRT_VERSIONorSTDIN_RUN, or widen what counts as an import line. The guard is this review: the owner mergesscripts/**.