Repository navigation
bench: run driver, patch export, grading inputs, aggregates and setup README [PROTECTED: owner merges] - #759
Conversation
…nd the setup README Third of three stacked PRs for #748 Phase 0. driver.ts runs the task set one at a time. Before any container, model call or write, it checks: - the caps; - that research-on-demand is off (every cap 0); - the bench user, the CLI pin and the subscription login; - that Docker is rootless; - a probe container's limits. Per task: - a fresh container, its limits read back; - the task's run under the driver's own wall-clock cap (3 h). RunTask, the engine's PR-mode loop, is wired in a later PR; - its patches exported, and the container removed. At the first usage limit it stops cleanly. The task in flight is recorded as stopped, every later one as "not run (usage limit)", and the aggregate (rewritten after every task) says it is partial. grade.ts exports `git diff base head` as plain .diff files and builds the SWE-bench predictions from those files and the held-out manifest only. A link or any other file in the patches folder is refused. aggregate.ts gives counts and rates only, with Wilson intervals: the confusion matrix, merge precision, false holds, caught bad fixes and the baselines. It records the CLI version, the plan, the limits and the usage-equivalent dollars, and flags a smoke mean over $8 per task. store.ts is the held-out store (manifest, dataset rows). README.md describes the isolation, the caps and the owner's one-time setup: the bench user, rootless Docker, the required systemd Delegate= drop-in, and the pinned CLI installed with DISABLE_UPDATES unset. Test (fails without): test/bench.test.ts. Measured on the combined branch (9d73baa4) with each guard switched off: the driver's cap check and the grading file checks each made their test fail. The driver's usage-limit stop is run by the owner. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
|
Owner's fail-without results (measured by the owner, 2026-10-18), on 9d73baa4 in a scratch worktree, now removed:
Both guards fail without their check. The PR body is updated. |
Evaluator verdict: CHANGES NEEDED (round 1/2)I reviewed only this PR's diff against SummaryThe preflight order, the usage-limit stop, the grading file checks, the Wilson formula and the results record are all sound. Four things block the merge:
Acceptance and focus
Fail-without
Protection
Scope and size
CImeasured (
The Required changes
Suggestions (not blocking)
|
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com> # Conflicts: # test/bench.test.ts
…hes every run, sandbox checks before a run Review round 1 on the driver PR (#748), with the owner's decisions of 2026-10-18: - exportPatch: the diff runs in a bare repo the driver makes, with the clone's object store as an alternate, no system or user config, the engine's hardened flags (core.fsmonitor off, hooksPath /dev/null), --no-textconv, --no-ext-diff and fixed a/ b/ prefixes. A textconv driver, external diff, pager or diff.noprefix the agents plant in their clone no longer runs or changes the patch. - wall-clock cap: meter.task takes the task's signal and adds it to every run, and refuses new runs once it fires; TaskRunner.exec is async and ends at the same signal (its docker CLI killed, the container removed after); the driver waits for the task's runs in flight before it records the spend and starts the next task (5 min bound, then the bench halts rather than overlap). - checkCaps: the mode is checked with isMode (own keys only) and the caps must be finite. - sandbox checks before the first task, owner's options A and D: the srt probe (probe.ts, sandbox-runtime 0.0.79) as a preflight step; then, as the run's first model call (claude-haiku-5-5, metered), Claude Code itself tries each Docker socket's /_ping and reads sentinels placed at the protected paths, in its own sandbox. The verdict comes from the stream-json transcript: a sentinel's content or a _ping OK anywhere refuses the run; a model that doesn't run the commands is recorded as "model declined" and the run relies on the srt probe, with a warning. Both are recorded in sandbox.json, results.json and the aggregate. - README: Docker Engine, not Docker Desktop (its WSL integration off); bubblewrap and socat; the admin home closed to the bench user (chmod 750, checked); srt 0.0.79 for the bench user only; the sandbox checks; where Edit and Write may write; srt-fingerprint.mjs to re-check new sandbox-runtime releases. Test (fails without): bench.test.ts "patch export runs nothing the clone configures", "the wall-clock cap ends every agent run and container command of a task", the prototype-key modes in "the driver refuses to start without a spend cap", "the sandbox confirmation in Claude Code's own sandbox refuses the run" and "a declining model leaves the srt probe as the guard". Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
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 092d788; #757's round 1 merged at 63a69ff and a9da0f6)Labels: measured = a command I ran or CI output, inferred = reasoned, not run. Required changes
The owner's sandbox decisions (2026-10-18)
Fail-without method (measured)This branch's tests against the pre-fix Local and CI (measured)
Body
|
Evaluator verdict: CHANGES NEEDED (round 2/2)I reviewed only this PR's diff against SummaryAll four round-1 required changes are fixed, and I re-ran my probes to confirm each one:
Option C is fine. Option D (
Round-1 required changes
Sandbox check D (
|
| Item | Status | Evidence |
|---|---|---|
| One Haiku 5.5 call at run start, metered, capped at $0.50, recorded | met | measured (test 29): one check session with --model claude-haiku-5-5, and usage.sandbox_check 0.02. inferred from confirm.ts:154 and usage.ts: the cap is min(0.5, per-task, run left), the call goes through UsageMeter (so it counts against the ceiling), and it's written to sandbox.json, results.json and the aggregate (driver.ts:179-191). The model id in the 2.1.303 binary is the builder's measurement, not mine. |
| Sentinels only where a protected file is missing, never over a real file, removed after | met | measured (test 30, and my probe 1): a real ~/.netrc is skipped and unchanged. inferred: files are created with openSync(p, 'wx') (O_EXCL), so a file or a dangling link at the path makes creation throw, and lstat has already counted it as present. measured: after every outcome, including leaked, the home holds no sentinel. |
~/.claude.json never touched; its sentinel under ~/.claude/; the rule really covers it |
partially met | measured (confirm-probe.mjs 1). With ~/.claude.json present, the stand-in is ~/.claude/.bench-sentinel-…, and the real file is unchanged. With it absent, the sentinel is created at ~/.claude.json and put to the model (required change 1). On coverage, inferred from confirm.ts:93 and probe.ts:78-88: denied() is checked in code before each sentinel is made, against both the sandbox's denyRead (~/.claude) and the Read rule Read(//<home>/.claude/**). A path with no rule refuses the run, so the coverage claim is enforced, not just stated. |
| Real files never shown to the model | met | measured (test 30): neither real path is in the prompt. inferred: the prompt holds only socket paths and sentinel paths. |
Verdict from the stream-json tool transcript; a leaked sentinel or _ping OK refuses the run |
partially met | measured (probe 5, and tests 29-30): an honest leak gives leaked and the run is refused. measured (probes 2 and 3): when the model runs a different command that succeeds, the verdict is still all refused for all 7 attempts (required change 2). The commands were cat '<s>' > /dev/null && echo read-ok-but-hidden (exit 0), wc -c '<s>' (exit 0, 48 <s>) and curl … -o /dev/null -w '%{http_code}' (output 200). |
| "Model declined" falls back to check A, with a warning | met | measured (test 30, and probe A): WARNING: sandbox confirmation: model declined … sandbox-runtime 0.0.79: close to, not identical with, the build bundled in Claude Code 2.1.303, and the run goes on. inferred from driver.ts:161-162: A has already passed by then, or the run threw. |
| Results recorded | met | measured (tests 29-30): sandbox.json status, and agg.sandbox.confirmation with its attempts and skipped list. inferred from driver.ts:191: results.json carries sandbox. |
| Tests cover every outcome with stand-ins | met | measured: refused, leak (the stand-in really reads the file), ping, declined, real files, and srt reaching a file. The two cases in required changes 1 and 2 are missing. |
| No real model call in tests | met | measured: the bench tests ran with a refusing claude, docker and srt first on PATH. That guard was hit 0 times, and the scratch HOME and state stayed empty. inferred: machine() passes the stand-in's absolute c.bin to benchClaude. |
Check C
- measured (read
srt-fingerprint.mjs): it is read-only. It uses onlyreadFileSync,readdirSyncandconsole.log, with no exec, spawn or write. - measured: README line 83 has the re-check step on each new release, and the switch to
SRT_VERSIONwhen a release shows no difference.
Fail-without (measured)
Method:
- I made a scratch worktree at 092d788 and ran
git checkout 63a69ff5 -- scripts/bench/{driver,grade,usage,aggregate}.ts, which is the pre-fix code from a commit. I made no guard-off edit. - I built it with the head's tests (tsc emitted with type errors, because those tests use the new fields).
node --import ./dist/test/isolate.js --test dist/test/bench.test.jsgave# pass 25,# fail 5:Missing expected rejection: constructor;- patch export:
nothing the clone configured ran,expected: true,actual: false; - the wall cap: the expected log's
'end worker: killed'lines are missing; - the two confirmation tests:
expected: 'all refused'andexpected: 'model declined'.
- On the head, the same command gives
# pass 30,# fail 0. npm run typecheckexits 0, andnpm run check:denylistprinteddenylist: 1282 files clean.
Protection
- measured: every path is under
scripts/**ortest/. The title has[PROTECTED: owner merges], the body has**Risk class:** protected, the PR has theneeds-ownerlabel, and both sections are there. - The claims, inferred:
- "Run anything an agent configured while exporting a patch" now holds (see item 1).
- "Start a task after Claude Code's own sandbox let a sentinel be read" does not fully hold (required change 2).
- The Edit/Write bullet in "Compromised component" understates the route (required change 3).
- No regression for existing installs, inferred: not applicable. Nothing in
src/or any installed path changes, andscripts/benchisn't in the package.
Scope and size
- Scope, inferred: no scope creep. Everything is Bench: gated-merge benchmark on SWE-bench Verified (smoke, baseline, per-release rerun) in scripts/bench/ #748 Phase 0, the round-1 fixes, or the owner's options C and D.
- Size, measured: 1,413 lines (
git diff --stat). That is about 860 lines of code, 391 of tests and 188 of README. - Judgement, inferred: acceptable as one PR now. It has been reviewed in full twice, the pieces are coupled (the driver calls the confirmation), and nothing in it can run until the RunTask PR lands.
- If the owner wants a smaller unit, the clean seam is a fourth stacked PR for the sandbox checks:
confirm.ts, its wiring indriver.ts,srt-fingerprint.mjs, the README's "Sandbox checks" section and tests 29-30. That is about 430 lines, and required changes 1 and 2 all fall inside it. The driver, grading and aggregates PR would then be about 980 lines. - Commit hygiene, measured: all five commits are signed off, none has an attribution trailer, there are no new dependencies and no
.claude/settings.json. "Part of Bench: gated-merge benchmark on SWE-bench Verified (smoke, baseline, per-release rerun) in scripts/bench/ #748" is right for a stacked PR.
CI
measured (gh pr checks 759): every check passes on 092d788, including all eight test shards on Node 22 and 24. The real-systemd jobs are skipped as usual.
Required changes
- Never create a file at
~/.claude.json. Its sentinel always goes under~/.claude/.- measured (
confirm-probe.mjs1): with~/.claude.jsonabsent, the prompt holdscat '~/.claude.json', and the driver created that file for the call. - Why it matters:
- the owner's spec is that
~/.claude.jsonis never touched and its sentinel goes under~/.claude/; - the check's own
claude(outside the sandbox) reads and writes~/.claude.jsonas its config while the sentinel sits there (guess at how 2.1.303 handles non-JSON there); - an interrupted check leaves the sentinel at that path (suggestion A: measured, it survives SIGINT and SIGTERM).
- the owner's spec is that
- Fix: in
placeSentinels,.claude.jsonalways maps to~/.claude/.bench-sentinel-<hex>, and is skipped if~/.claudeisn't a plain folder. - Test: the stand-in records whether
~/.claude.jsonexists during the call, with the file absent beforehand. Assert that it never did, and that the~/.claude/sentinel was used.
- measured (
- Count an attempt as refused only on positive evidence.
- Today: any Bash call whose command contains the path counts as a refused attempt, unless the sentinel's content or
OKshows up in its output. - measured (probes 2 and 3):
cat '<s>' > /dev/null && echo …,wc -c '<s>'andcurl … -o /dev/nullall exited 0 (so the read or connect worked), and the status wasall refusedfor all 7 attempts. - Fix:
- an attempt is
refusedonly when a Bash call ran exactly the listed command and itstool_resultis an error (is_error: true, which Claude Code sets on a non-zero exit: inferred); - a call that names the target and exits 0 is
reached, and the status isleaked; - anything else counts as not run, so the status is
model declined.
- an attempt is
- Test: stand-in sessions for the altered-command cases above.
- Today: any Bash call whose command contains the path counts as a refused attempt, unless the sentinel's content or
- Close the Edit/Write route before any agent can be given those tools, and correct the protected sections.
- The route, inferred:
- the PR's own premise is that an unqualified
Edit/Writein a run's allowed tools can change any file the bench user can; ~/.profileexists (from/etc/skel), the README's step 3 appends to it, its Read isn't denied, andsudo -iu wl-bench(the README's way in) runs it;- so a rewritten
~/.profileruns agent code as the bench user, outside every sandbox, with the daemon socket and the login in reach. That is the risk the owner's design closes.
- the PR's own premise is that an unqualified
- The same holds for
~/.bashrc, and, as a guess, for overwriting~/.local/bin/claudethrough its symlink. - Why the current mitigations don't cover it, inferred:
--setting-sources projectkeeps user-settings hooks out, but it doesn't help here;- "the next claude start is checked again" doesn't help either, because the version check passes a planted script that prints
2.1.303.
- Fix:
- preferred ("make the wrong thing impossible"), in this PR's
TaskMeter.run: refuse any request whoseallowedToolshasEdit,Write,MultiEditorNotebookEditunqualified, or qualified outside the task'sworkDir(the driver passes it tometer.task), with a fail-without test; - at minimum: correct "Compromised component" and the README's "So nothing the driver reads back trusts…" so they name this route, and open an issue that blocks the RunTask PR until it is closed.
- preferred ("make the wrong thing impossible"), in this PR's
- The route, inferred:
Suggestions (not blocking)
- A. Interrupted check.
- measured (
sentinel-kill.mjs, a stub runner that never returns): after SIGINT and after SIGTERM, the sentinels stay at~/.netrc,~/.npmrcand~/.pypirc(and at~/.claude.json, see 1). - The next check then lists them as
skippedreal files, and the confirmation silently covers less. - Fix: record the paths in the run's folder before creating them, and at the next start remove leftovers whose content starts with
bench-sentinel-. Or handle SIGINT and SIGTERM.
- measured (
- B. Which layer refused, guess about 2.1.303's internals: Claude Code's Read deny rule may stop a literal
cat <path>before the OS sandbox is ever exercised. Record which layer refused each attempt, from the result text, sosandbox.jsonshows whether the bundled sandbox itself was tested. - C.
TaskRunnerfalls back to the blockingcallwhencallAsyncis unset andcallis set (container.ts:225). Keep that fallback to tests, so a future entry point can't bring back a blocking exec. - D. Round-1 suggestions A-C are still open:
readPatches:nlink > 1andO_NOFOLLOW;error/stoppedrows in the rates;- later tasks' agents can read earlier tasks' folders.
… 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>
…heck never touches ~/.claude.json and needs proof Round 3 on #759, the owner's decisions of 2026-10-18. Edit, Write, MultiEdit and NotebookEdit run outside the sandbox as the bench user, so a run that had them pre-approved without a scope could rewrite ~/.profile or ~/.bashrc, which a login shell then runs outside every sandbox. TaskMeter (usage.ts scopeEditTools) now scopes a bare one to Edit(//<task folder>/**) and refuses, unstarted, a run that grants any of them anywhere else; meter.task takes the task's folder. The sandbox confirmation never creates a file at ~/.claude.json: its sentinel always goes under ~/.claude/, and there is none when that folder is missing. An attempt counts as refused only when the exact listed command ran and its tool result is an error (is_error); any command naming the target that exits 0 is a leak and refuses the run; anything else is "model declined". Before, `wc -c '<s>'` exiting 0 counted as refused. Its sentinels are removed on SIGINT and SIGTERM, and the signal then ends the process as before. The README and the driver's header now say where agents may write. Test (fails without): "the sandbox confirmation never touches ~/.claude.json", "refused only when the exact command failed", "removes its sentinels on SIGINT and SIGTERM" and "a task's agents get Edit, Write, MultiEdit and NotebookEdit only in the task's own folder", in test/bench.test.ts. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…so a regression can't hang the run Run against the pre-fix confirm.ts, an assertion failed while the test's child (whose model call never ends) was still running, and the test process waited on it for good. The child is now killed in a finally. Test (fails without): the same test now fails, rather than hangs, on the pre-fix confirm.ts. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 3 (owner-granted): what changedHead a049531. #757's round-3 head (d57acd8) is merged in first with a signed-off merge commit, 9e4481c. That merge holds only the conflict resolution and what #757's API needed:
On its own it gives 1.
2. The verdict is decided per attempt (owner decision 2).
3. Edit, Write, MultiEdit and NotebookEdit stay in the task's folder (owner decision 3).
4. Sentinels are cleaned up on SIGINT and SIGTERM (non-blocking, done).
Local results (measured): Not changed here:
|
Evaluator verdict: CHANGES NEEDED (round 3)I reviewed only this PR's diff against
Summary. All five round-3 items work as the owner's decisions describe (measured). But the docs show three things the PR's own safety text gets wrong, and one of them is a new write route that the change itself opens:
All three are cheap to fix. This is the last owner-granted round, so the owner decides on each (see "For the owner"). Round-3 items
Verdict fixtures (measured,
No sentinel was left in any case (measured). Fail-without (measured)
Protection, scope and hygiene
CImeasured ( Required changes
Suggestions (not blocking)
For the owner
|
… issue-748-bench-driver Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…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>
Round 4 (owner-granted): what changedThe new head is 0faa498:
The PR body is updated, including the corrections round 3 found. Fail-without (measured):
Notes on the table:
Round 3's overclaims, corrected in the README,
Also from the merge (measured): #757's five new stdin tests called the now-async Gates (measured):
CI: on 0faa498 (measured, Not changed:
|
Evaluator verdict: CHANGES NEEDED (round 4)I reviewed this PR's round-4 diff (6585970..0faa498) against the owner's round-4 decision (option A) and round 3's required changes, at head 0faa498.
Summary. Every item the owner asked for is in, and works as described (measured). Two claims are still wider than the code:
Both are one-line fixes with a test. The owner may prefer to take them as blocking items on the RunTask PR (see "For the owner"). Owner's round-4 items
Layer forging (measured,
Fail-without (measured)
The merge and the stdin fix (rule 10)
Protection, scope and hygiene
CImeasured ( Required changes
Suggestions (not blocking)
For the owner
|
…odel can't set the sandbox layer Round 5 of #759 (owner, 2026-10-19), from the round-4 final check: - TaskMeter.run refuses a run whose working folder doesn't resolve, or whose realpath isn't inside the task folder's, and a task folder that is itself a link (the driver builds it on the bench root's real path). A link in the task folder could otherwise take the run's sandboxed Bash writes elsewhere. The cwd is passed as given, with no default: a missing one is refused. - With any extra call, no attempt gets the "OS sandbox" layer and os_sandbox_read_deny is false, in sandbox.json, results.json and the aggregate: an extra command (a chmod through a glob) could make the mark itself. - A listed command sent with any Bash input but command and description (a timeout, a background run) is an extra call, not the listed command. - Any other kind of tool call block (a server_tool_use) is an extra call. - RULE_UNSAFE refuses control characters (NUL to US, and DEL). Test (fails without): on 0faa498's sources, the realpath test (a link out of the task folder starts), the gate test (os_sandbox_read_deny true, node layers "OS sandbox"), the other-inputs test and the server_tool_use test ("all refused"), and the control-character test (no exception for \x00). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 5 (owner-decided): what changedThe new head is 943244a. It fixes every finding in the round-4 final check that affects how the sandbox checks behave. Wording-only findings are deferred to the RunTask PR and listed in the body under "Deferred to the RunTask PR". Fail-without (measured):
Notes on the table:
Also changed:
Gates (measured):
CI: on 943244a (measured, Deferred, wording only: "in Claude Code's own sandbox" in |
Evaluator verdict: CHANGES NEEDED (round 5)I reviewed round 5 (0faa498..943244a) at head 943244a against the owner's round-5 decision: fix every finding that affects the correctness of the sandbox checks, and defer wording to the RunTask PR.
Summary. Round 4's two required changes and suggestions A to C are in, and they work (measured). Two gaps remain, each in a check's correctness:
The second is a one-line fix. The first has a small fix, or it can become a RunTask blocker with the claim narrowed (see "For the owner"). 1. The working folder must really be inside the task foldermeasured,
2. The model can't set the "OS sandbox" layer
3. Extra Bash inputs, other block types, control charactersmeasured,
4. The two changed tests (rule 10)
5. Deferred items
6. The usual checks
Required changes
Suggestions (not blocking)
For the owner
|
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…ap its folder before the spawn Round 6 of #759 (owner, 2026-10-19), from the round-5 final check: - The working folder is checked before the engine spawns claude in it, after awaits (the login lock, MCP registration). Another of the task's runs, whose sandboxed Bash may write its own working folder, could swap a folder in between. TaskMeter now refuses, unstarted, a run whose working folder is nested with a running run's (the same, or one inside the other, either way), or strictly inside an earlier run's (a process it left could still write there). The check and the registration run with no await between them. - A working folder reached through any link is refused, even one that stays inside: its realpath must be itself. - A Bash call counts as a listed command only with a string command and a description absent or a string; any other shape Claude Code would refuse without running it. - RULE_UNSAFE also refuses C1 controls, format characters (zero-width, bidi overrides) and lone surrogates. Test (fails without): on 943244a, the swap test (the nested run starts), the any-link test, the earlier-run test, the input-shape test ("all refused" for an array command) and the C1/format test (no exception for \x80). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 6: what changedThe new head is 45b91ba:
The body is updated, with a new "Adversarial pass" section and an "Open items" section. Fail-without (measured):
The claim, corrected (round 5's finding): "What this can never do" now says the working folder holds "at the meter's check", and that no other run of the task can swap a folder of it before the spawn. What that rests on is labelled inferred: nothing else writes inside the task folder while a run starts. Gates (measured):
CI: on 45b91ba (measured, |
Evaluator verdict: CHANGES NEEDED (round 6)I reviewed round 6 (943244a..45b91ba: a signed-off merge of main, then fix commit 45b91ba).
Summary. All of round 5's required changes and suggestions A to C are in, and they work (measured). The check and the registration are synchronous, and nested runs are refused both ways. One gap remains in the new guarantee. The overlap rules live in one
The second gap is wording: the claim's stated assumption doesn't name that folders are compared as path text. 1. The overlap rulesRe-run (measured):
My own cases (measured,
Synchronous (measured, by reading Why the second meter matters (inferred from
So a repeated key in Reachable today? guess: no. There is no CLI entry point or task selection yet. But Legitimate patterns (measured,
This is a usability limit, not a correctness gap (inferred):
2. The "can never do" claim
3.
|
…e as well as by path Round 7 of #759, from the round-6 final check (decided by the owner's decision guide): - The overlap rules lived in one TaskMeter. A repeated task key, or a run id used again after a crash, gave one task folder a second meter that knew nothing of the first one's runs. runBench now refuses a repeated task key before any check, and makes the run folder, the sandbox check's folder and each task folder fresh (mkdir without recursive; EEXIST is refused), so no task folder holds runs its meter hasn't seen. - Nesting is compared by device and inode as well as by path: each run's folder and its parents up to the task folder are recorded, so a second spelling of one folder (a case- or normalisation-insensitive filesystem, a rename while a run is going) is still the same folder. - The C1/format test writes its characters as code points, and a new test refuses any raw invisible, bidi or control character in the bench sources and this test file. - README: the RunTask constraint (no run below an earlier run's folder, so worktrees go outside the clone), and that names with ZWNJ, ZWJ or a soft hyphen are refused. Test (fails without): on 45b91ba, the two-meter test (a repeated key and a reused run id both start agent runs), the inode test (the renamed folder starts while its run is going), and the raw-character test (line 1605). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 7: what changedThe new head is 9c666ba, one signed-off commit. This round was decided by the decision guide. The body is updated, including a round-7 "Adversarial pass" section. Fail-without (measured):
Notes on the table:
Gates (measured):
CI: on 9c666ba (measured, |
Evaluator verdict: CHANGES NEEDED (round 7)I reviewed round 7 (45b91ba..9c666ba, one signed-off commit).
Summary. Both of round 6's required changes are in, and they work (measured). A task folder now has exactly one meter: the driver makes it fresh, and a repeated key, a reused run id, a run folder made beforehand, a linked run folder and a racing second process are all refused before any agent run. The inode comparison catches a second spelling of a running or earlier folder. What remains is one false claim about the new raw-character test, and two missing labels in the README. Neither is a safety gap in the driver. 1. One meter per task folderCode (measured, by reading):
probe-r6's two-meter cases, now through
My own cases (measured, 9c666ba):
Re-run, the builder's Not blocking (inferred, from
2. Nesting by device and inodeCode (measured, by reading
Builder's rename case, by hand (measured):
Inode reuse (measured, builder's case 4 re-run): after
Renamed out (measured):
One edge, inside the stated assumption (measured, not blocking): each
The case-insensitive claim (inferred; the reasoning is sound):
3. The raw-character scan (rule 8: its own regex, read from
|
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…CR, selectors and fillers Round 8 of #759, from the round-7 final check (the decision guide's principles 4 and 5): - The scan claimed "no raw invisible, bidi or control character" but let through a CR in the middle of a line, U+2028 and U+2029 (each ends a // comment in JS and TS while many viewers show one line), the variation selectors, the Hangul fillers, U+2800 and U+034F. Its regex is now a named constant, RAW_UNSAFE, that catches them all too, and a new test feeds it the review's fixtures (and checks that tab, a line-end CR and visible text pass). The test title says exactly what it checks. - The driver makes the run folder fresh before the probe container, so a reused or racing run id is refused before any container, and a losing racer never removes the winner's probe container. - README: the remaining assumption (nothing outside the task folder's own runs writes inside it while a run starts) and the case-insensitive claim (with its premise: a stable, unique device and inode per folder) are labelled inferred, and it says each run's parent chain is recorded when the run registers. Test (fails without): the review's fixtures (CR mid-line, U+2028, U+2029, U+FE0F, U+E0100, U+3164, U+115F, U+2800, U+034F) pass 9c666ba's regex and are caught by RAW_UNSAFE; on 9c666ba's driver a reused run id still starts and removes a probe container. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 8: what changedThe new head is 6ffe56d:
The body is updated.
Correction (round 7 check): the body's round-7 Adversarial pass said the scan refused any Gates (measured):
CI (measured, on 6ffe56d;
|
Evaluator verdict: CHANGES NEEDED (round 8)I reviewed round 8: 9c666ba..6ffe56d, which is a signed-off merge of origin/main (c6994d1) plus one signed-off fix commit.
Summary. Suggestion A is in, and it works (measured). The README labels are in. The scan is wider, strictly tighter than before, and refuses nothing legitimate (measured). Three things remain:
None of these is a safety gap in the driver.
1.
|
| Round | 6ffe56d | 9c666ba |
|---|---|---|
| 1-5 | exactly 1 ran each round (z, y, x, x, z). Each loser was refused with "run folder … already exists", with 0 agent runs and 0 container operations | 1 ran each round, but each loser made create, start and rm --force bench-r1-probe, the same name as the winner's probe |
- So a loser can no longer remove the winner's probe container (measured: no loser makes any
create,run,startorrm).
4. Nothing earlier regressed (measured)
- Bench tests on the head:
# tests 61,# pass 61,# fail 0. They cover:- the meter (one per task folder, nesting by inode, the overlap and earlier-run rules);
plain,RULE_UNSAFEand the layers tests.
- Round 7's
probe-r7.mjs, re-run on 6ffe56d. Every result matches the round-7 check:- restarted driver, a second
runBenchwith a run in flight, and a repeated key: refused, with 0 agent runs; t01andt001: ran;- the race: 1 of 3 each round;
- linked bench root, linked runs folder, and a run folder that links to an earlier run: refused;
- folders made mid-run: refused;
- a task folder swapped for a link:
error, with 0 worker runs; - nesting by inode: renamed, moved out, moved under a new parent (inside the assumption), and inode reuse.
- restarted driver, a second
- Driver diff since 9c666ba: one block moved (+8/-3 in
driver.ts), plus README text. No other source file changes.
5. The usual checks
- Fail-without (measured). Method: a scratch worktree at 9c666ba, plus
git checkout 6ffe56d0 -- test/bench.test.ts, then build and run with temp state; no guard edited.- Result:
# pass 60,# fail 1. - The failing test is
round 7: one task folder never gets two meters…:15 !== 12(expected 12, actual 15). That is the builder's result. - The scan's fail-without is the fixture table in §1: all nine of the builder's fixtures pass 9c666ba's regex, and all are caught by
RAW_UNSAFE.
- Result:
- Gates on the head (measured):
npm run typecheckexits 0;check:denylistprintsdenylist: 1299 files clean;check:dco -- origin/main 6ffe56d0printsDCO: all commits signed off.
- Attribution (measured): the only trailers in the 16 commits are
Signed-off-by. There is no attribution line in the body or in the round-8 comment. - Rule 10 (measured): nothing is removed or weakened.
- The old
unsaferegex is replaced by a strict superset (§1). - The renamed scan test keeps its scan and assertion.
- The reused-id case gains an assertion.
- No exception, skip or allowlist entry is added.
- The old
- Protection (measured):
- the 10 files are under
scripts/bench/**ortest/; - the title has
[PROTECTED: owner merges], the body has**Risk class:** protected, and the PR has theneeds-ownerlabel; - "What this can never do" and "Compromised component" are present, and unchanged in substance this round.
- the 10 files are under
- Rule A (measured): the tests and probes left nothing in the temp home, state or held-out folders, and the round-8 change adds no state access.
- No regression for existing installs (inferred): nothing in
src/or any installed path changes. - Merge with origin/main (measured):
- CI (measured): run 39217283406 is a
pull_requestrun withheadSha6ffe56d, and its conclusion issuccess.- Passing: all 8 shards, both full test jobs (Node 22 and 24), dco, denylist, scope, push-gate, new-deps, agentshield, desktop and both scans.
- Skipped, as usual: the real-systemd, bootstrap and sign-in-probe jobs.
- Adversarial pass (measured): missing for round 8.
- The body has round 7's and round 6's sections only.
- The round-8 comment has none.
- The round-7 section points to "round 8 below", but round 8 is above it.
- The evaluator brief makes a hand-off without this section a required change. A class-level self-attack (a sweep over
\p{Default_Ignorable_Code_Point}or\p{Variation_Selector}) would have found §1.
Required changes
- Fix the class in
RAW_UNSAFE, and correct the claim (principles 4 and 5).- Add
\p{Default_Ignorable_Code_Point}to the character class. The explicit variation-selector and Hangul-filler ranges and U+034F can then go, since it covers them, but keep U+2800,\p{Zl},\p{Zp}, the CR rule and the control-character rule. - Add fixtures for U+17B4 and U+180B.
- Correct the body's "the variation selectors … catches those too" and the matching commit-message wording in the next commit's message, unless the regex then makes it true.
- It costs nothing today (measured: no hits in the 16 files).
- Add
- Add an "Adversarial pass (round 8)" section to the body and to the hand-off comment, with each attack, its measured result, and what changed because of it. Also fix the "see round 8 below" pointer.
- README
:52, wording (principle 5):- drop "and so do vfat and exFAT while mounted", or say that they keep a folder's number only while its inode is in memory (inferred, as above);
- drop "is on ext4 here", or label it with what it rests on. As written, it is an unlabelled claim about one machine.
Suggestions (not blocking)
- The new test's title says "separator". "line or paragraph separator" says exactly what it checks.
For the owner
There is no new trade-off. All three changes are wording, a wider lint, or a missing section, and none loosens anything.
…de point Round 9 of #759, from the round-8 final check (the decision guide's principles 4 and 5): - Correction to the previous commit's message: it said the scan "catches them all", the variation selectors included. It caught U+FE00-FE0F and U+E0100-E01EF only; the Mongolian free variation selectors (U+180B-180D, U+180F) and the Khmer inherent vowels (U+17B4, U+17B5) still passed, and each makes a second identifier that looks like the first. RAW_UNSAFE now also refuses the whole class, \p{Default_Ignorable_Code_Point}, assigned or not, and the test feeds it U+17B4, U+17B5, U+180B, U+180F, U+2065 and U+E0002. The earlier explicit ranges stay. - The scan test's title names exactly what it checks ("line or paragraph separator", not "separator"). - README: vfat and exFAT keep a folder's inode number only while its inode is in memory (inferred), so the "so do vfat and exFAT" sentence is corrected, and the unlabelled "is on ext4 here" becomes advice to keep the bench root on a filesystem with stable inode numbers. Test (fails without): over every code point, 6ffe56d's regex lets U+17B4-17B5, U+180B-180D and U+180F (and 3769 unassigned default-ignorable code points) through; RAW_UNSAFE refuses them and lets no default-ignorable, format or variation-selector code point through. Nothing the old regex caught is let through now, and the visible samples (Latin, Greek, Cyrillic, CJK, Hangul, Arabic, Hebrew, Devanagari, emoji, U+2801, U+0301) still pass. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 9: what changedThe new head is 002d7c9, one signed-off commit. origin/main (c339175) is already an ancestor. The body is updated.
Adversarial pass (round 9, covering round 8's changes too)Round 8's hand-off had none. Every result here is measured unless labelled, with fixtures and stand-ins only.
Gates (measured):
CI: on 002d7c9 (measured, |
|
Owner decision 2026-10-19: RunTask putting worktrees outside the clone is fine; the README constraint stands. |
Evaluator verdict: CHANGES NEEDED (round 9)I reviewed round 9: 6ffe56d..002d7c9, one signed-off commit that changes only
Summary. The round-9 fix itself is right. One thing blocks: origin/main moved to 3d24fc3 (#804) after this head's CI ran, and the branch no longer merges.
1.
|
Brings in #804 (3d24fc3). test/bench.test.ts conflicted where main added five tests (#797, #804) before the 512-byte tar test, which this branch had made async. Both are kept. On this branch TaskRunner.syncTree is async, so main's four new stdin tests are made async and await each stdinSends call; none of main's assertions changes. scripts/bench/hidden.ts is main's, unchanged. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…vate use and U+FFFC Round 10 of #759, the round-9 final check's nits: - RAW_UNSAFE also refuses every \p{Zs} space but the ASCII one, private-use characters (\p{Co}) and U+FFFC, which show as something other than what they are; the fixture test adds U+00A0, U+2009, U+3000, U+E000, U+F0000 and U+FFFC, and the scan test's title names them. - The comment's Mongolian variation selectors read "U+180B-180D and U+180F": U+180E is a format character. Test (fails without): over every code point, 002d7c9's regex lets 137485 code points through that RAW_UNSAFE refuses (the private-use planes, the 16 non-ASCII spaces and U+FFFC); none it caught is let through now, and the 16 files scan clean. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 10: main mergedThe new head is 242f663:
The body is updated. The merge (measured):
The nits (measured):
CI (measured, on 242f663;
|
Evaluator verdict: CHANGES NEEDED (round 10)I reviewed round 10: 002d7c9..242f663. That is the merge ab9bfc8 (parents 002d7c9 and 3d24fc3) and one commit, 242f663, that changes only
Summary. The code is right. The merge is a faithful resolution, and none of main's assertions changed. #804's One thing blocks, and it is text only: round 10's hand-off has no Adversarial pass section. It is missing from both the body and issuecomment-6263149184. Under EVALUATOR.md and the DECISION GUIDE, a hand-off without one is itself a required change. The same finding was made in round 8.
1. The merge (ab9bfc8)The resolution is faithful (measured):
All five of main's #797/#804 tests are present (measured,
Main's four stdin tests now
Main's tests still catch what they were written for (measured). I put older versions of
#804 is intact (measured): 2. The new
|
Round 11 (text only): the round-10 Adversarial pass, and correctionsThere is no code change: the head stays 242f663. The body now has an "Adversarial pass (round 10)" section, a correction note in the round-9 section, and an updated base line. Adversarial pass (round 10)Run in round 11 against 242f663, with fixtures, a scratch worktree (since removed) and stand-ins only. Every result is measured unless labelled.
|
Evaluator verdict: PASSThis is round 11, a text-only round. The head is unchanged at 242f663, which Method:
1. The section is present and covers both changes (measured)
2. The claims match what I measured
3. The correction note is correct (measured)
The base line: it now names round 10's signed-off merge ab9bfc8 of 3d24fc3 (#804). It says earlier merges of main were in rounds 6 and 8.
4. Characters and attribution (measured,
|
Intent: This is the third of three stacked PRs for #748 Phase 0. It adds the bench driver, which runs the task set under the caps:
It also adds:
The tests run no model, no Docker and no download. In a real run, the sandbox confirmation is one model call.
Risk class: protected (every path is under
scripts/**).Part of #748. Base:
main. #756 (preflight) and #757 (containers and limits) have merged, and main is merged in here: last in round 10, at 3d24fc3 (#804), by the signed-off merge ab9bfc8. Earlier merges of main were in rounds 6 and 8. Round 4 merged #757's head.Size: well over the ~700 lines the owner asked to hear about.
git diff --shortstat 0faa4988 943244ab: 181 insertions, 34 deletions, 124 of the insertions in tests). Round 7 added 125 lines and removed 18, with 55 of the insertions in tests (measured,git diff --shortstat 45b91baf 9c666ba0).git diff --shortstat 6585970a 0faa4988): 399 insertions and 104 deletions; 178 of the insertions are in tests.What's in it
scripts/bench/driver.ts,runBench:checkCaps: finite, and the mode byisMode, own keys only, soconstructor,toStringand__proto__are refused), research caps all 0, bench user, CLI pin, claude.ai login, the srt sandbox probe (bench: task containers in rootless Docker with required limits, exec-only, no daemon for agents [PROTECTED: owner merges] #757'sprobe.ts, aspreflight.sandboxProbe), rootless Docker, then a probe container's limits.confirm.ts, the run's first model call): described below.sandbox.json,results.jsonand the aggregate.BenchOptions.baseTree(required) gives the task's base commit as a tar stream, before any agent runs. It goes to the task'sTaskRunner, whose stdin check (bench: task containers in rootless Docker with required limits, exec-only, no daemon for agents [PROTECTED: owner merges] #757) compares against it;RunTaskgets the metered runner, the abort signal, the exec-only runner, its work dir and the research-off settings;TASK_WALL_MS, 3 h):meter.task(perTaskUsd, workDir, signal)adds the task's signal to every run request, whatever the task passes, soCliRunner's kill fires, and it refuses new runs once the signal fires.TaskRunner.execis async: at the signal itsdocker execis killed, and none starts after;settled, 5 min bound). If one is still going after that, the bench halts rather than overlap, and every later task isnot run (halted);stopped (usage limit)and the rest asnot run (usage limit). A per-task limits failure stops the run, and the rest arenot run (limits).results.jsonandaggregate.jsonare rewritten after every task. They record the CLI version, the plan, the limits, the usage-equivalent dollars, and both sandbox checks (per attempt, with the layer).scripts/bench/usage.ts:scopeEditTools, run byTaskMeter.runon every request (owner's decisions of 2026-10-18 and 2026-10-19).cwdis outside the task's folder is refused, unstarted, whatever its tools.cwddoesn't resolve, or whoserealpathisn't inside the task folder's, is refused. That includes a link in the task folder that leads out of it.benchRoot).cwdis passed as given, with no default, so a missing one is refused.A
cwdreached through any link is refused, even one that stays inside: itsrealpathmust be itself.The window between the check and the spawn is closed for the task's own runs. The engine's runner spawns claude in the
cwdafter awaits (the login lock, MCP registration). In between, another of the task's runs, whose sandboxed Bash may write its own working folder, could swap a folder of the new run'scwdfor a link out (round 5 check, measured with a real spawn). So the meter now refuses, unstarted:cwdis nested with a running run's: the same folder, or one inside the other, either way;cwd, which a process it left behind could still write (guess that one can be left).The check and the registration run with no
awaitbetween them, so two runs started together can't both pass (measured: a probe starting three in one tick).What is still assumed (inferred): nothing outside this task's runs writes inside the task folder while a run is starting. The driver writes only the run's own records and the patches folder, between tasks. The container has no mount. Other tasks run one at a time, in their own folders.
For RunTask: runs of one task can be concurrent only in disjoint folders, and a later run can't be strictly inside an earlier one's folder. So a run at the clone's root followed by worktrees under the engine's default
.claude/worktreesis refused: worktrees go outside the clone, side by side in the task folder (README).runBenchrefuses a task key that appears twice (before any check), and makes the run folder, the sandbox check's folder and each task folder fresh:mkdirwithoutrecursive, andEEXISTis refused. A run id used again (after a crash, say) is refused, and so is a run folder that is a link. So no task folder holds runs its meter hasn't seen (measured, tests and the adversarial pass below). Round 8: the run folder is made before the probe container, so a reused or racing run id is refused before any container, and a losing racer can't remove the winner's probe container (measured, test).Edit,Write,MultiEditorNotebookEditbecomesEdit(//<the run's cwd>/**), not the whole task folder (round 4). Claude Code's Edit rules cover all its file-editing tools (inferred from its permissions docs).Edit(//<p>)orEdit(//<p>/**)stays whenpis a plain, normalized path in the run'scwd.~or globbed path,.., or a trailing slash;Write/MultiEdit/NotebookEdit;Read,Edit), or a wrong-case name.cwdmust be normalized absolute paths, with no trailing slash and no glob or rule character (* ? [ ] { } ! ( ) \, a comma, whitespace, any control character (\x00-\x1fand\x7fsince round 5, and C1\x80-\x9fsince round 6), any format character such as U+200B or U+202E, and lone surrogates (round 6)).scripts/bench/confirm.ts(the owner's option D): one call onclaude-haiku-5-5, the cheapest pinned product model.usage.sandbox_check.curl --unix-socket <sock> http://localhost/_pingfor each socket the probe tries;cat '<s>'of a sentinel at each protected path. Correction (round 3's finding):catis a file command Claude Code's permission layer parses, so the lane'sRead(...)deny most likely refuses it before the OS sandbox runs anything (inferred from the permissions docs). Socattests the permission layer, not the bundled sandbox'sdenyRead;node -e "<READ_JS>" '<s>'for each sentinel, a read the permission layer doesn't parse (inferred from the same docs: deny rules don't apply to "a Python or Node script that opens files itself"). The OS sandbox's owndenyReadis what has to stop it.fstats the descriptor. It exits 3 (bench-check: masked, not a regular file) when the file isn't regular, which is the sandbox's/dev/nullmask, the same reading as the probe'sPROBE_JS. Otherwise it reads and prints the file./dev/nulllink, a missing path and a real sentinel).~/.netrc,~/.npmrcor~/.pypircdoesn't exist, the driver creates one there (random content, created exclusive so never over anything) and removes it after;~/.claude.jsonis never touched, whether or not it exists (round 3).~/.claude/, which the login folder's rule covers (Read(//<home>/.claude/**)and the sandbox's denyRead of~/.claude).is_error, which Claude Code sets on a non-zero exit: inferred; the engine reads the same field,src/run-record.ts). Round 5: exact means the same command with no Bash input butcommandanddescription. A listed command sent with atimeout,run_in_backgroundor any other input is an extra call. Round 6:commandmust be a string, anddescriptionabsent or a string. Any other shape Claude Code would refuse at input validation without running anything (inferred), so it can never pass as "refused";_pingOKcame back. Any reached attempt, or any sentinel's content anywhere in the transcript, makes the statusleaked, and the run is refused;layer), only from what the stream-json carries, failing closed:permission_denialslists the call'stool_use_id, or the call's error result says "Permission to use … has been denied" (or "… has been denied because Claude Code is running in don't ask mode"). inferred from public docs and Claude Code's known wording, not verified against 2.1.303. The smoke round's first call (the Haiku call) will measure them;EACCES,EPERMorENOENT, or forcat"Permission denied", "Operation not permitted" or "No such file or directory". It also needs no permission-layer marker. Round 5: it also needs no extra call. The model could make that mark itself, for example with achmod 000through globs, so with any extra call no attempt gets this layer, andos_sandbox_read_denyis false insandbox.json,results.jsonand the aggregate;extra_calls:$HOME,~, a glob or a link;server_tool_use(round 5);sayprints it as a WARNING with the label "sandbox-runtime 0.0.79: close to, not identical with, the build bundled in Claude Code 2.1.303";os_sandbox_read_denyrecords whether the OS sandbox itself refused everynode -eread. When it didn't (say the permission layer caught them after all), the WARNING says the sandbox's own read-deny rests on the srt probe.scripts/bench/grade.ts:exportPatchdiffs in a bare repo the driver makes for it, with the clone's object store as its only source (GIT_ALTERNATE_OBJECT_DIRECTORIES).GIT_CONFIG_NOSYSTEM,GIT_CONFIG_GLOBAL=/dev/null).-c core.fsmonitor=false -c core.hooksPath=/dev/null,src/comment-policy.ts) and--no-textconv --no-ext-diff --src-prefix=a/ --dst-prefix=b/..gitorobjectsthat is a link is refused. No agent-written config, attribute, hook or pager is read.readPatchesreads only the patches folder: plain folders named by task key, and plain<label>.difffiles. A link or any other entry is refused.writePredictionsbuilds the SWE-bench predictions from those files and the held-out manifest, into the held-out store.scripts/bench/aggregate.ts:wilson;aggregate: completeness, counts (not_run_haltedadded), usage (withsandbox_check), the sandbox checks, andowner_reapproval_neededfor a smoke mean over $8 per task. Once graded, it adds the confusion matrix, merge precision, auto-merge yield, false holds, caught bad fixes, and the B0, B1 and bare baselines.scripts/bench/store.ts: the held-out store (parseDataset, the manifest,TASK_KEY).scripts/bench/srt-fingerprint.mjs(option C): compares a sandbox-runtime release's[Sandbox Linux]log strings with a Claude Code binary's. It is read-only and executes neither.scripts/bench/README.md: what the bench measures, how it is isolated, the caps, the sandbox checks, the data layout, and the owner's one-time setup. It covers:bubblewrapandsocat;chmod 750 "$HOME", checked withsudo -u wl-bench ls "$HOME"failing);npm install -g --prefix ~/.local), checked withsrt --version;test/bench.test.ts: 66 tests on this branch, 31 of them from main (bench: preflight for the SWE-bench run (subscription-only login, CLI pin, caps, usage meter) [PROTECTED: owner merges] #756, bench: task containers in rootless Docker with required limits, exec-only, no daemon for agents [PROTECTED: owner merges] #757, Test: list scripts/bench/hidden.ts on the changed-files reader list (main red after #688 + #757) #797 and bench: hidden.ts counts added lines starting "++ "; pin its two lines on the reader list (#797 follow-up) [PROTECTED: owner merges] #804).Round 9:
RAW_UNSAFEalso refuses every\p{Default_Ignorable_Code_Point}, assigned or not: the whole class that renders as nothing, including the Mongolian variation selectors (U+180B-180D and U+180F; U+180E between them is a format character, refused since round 7) and the Khmer U+17B4 and U+17B5. Each of those makes a second identifier that looks like the first, as U+3164 does. The round-8 ranges stay.Round 8:
The raw-character scan's regex is a named constant,
RAW_UNSAFE. It catches:\p{Cf},\p{Cs},\p{Zl}and\p{Zp};Tab, LF, a line-end CR and visible text pass. A new test feeds it the round-7 check's fixtures, and the scan's title says exactly that.
The reused-run-id case also checks that no container is made, started or removed.
Round 7 adds three:
The round-6 character test now writes its characters as code points.
Round 6 adds five: the run-overlap swap (the reviewer's probe-scope5 case), any link in the
cwd, an earlier run's folder, the Bash input shapes, and C1, format and surrogate characters.Round 3 added four.
Round 4 added five: the cwd scope, the rule-path refusal, the
node -eread, the layers, and "all refused" only with nothing extra.Round 5 adds five: control characters, the realpath check, the model unable to set the OS-sandbox layer (also through the driver's three records), other Bash inputs, and
server_tool_use. Two scope tests now use real temporary folders instead of/srv/...paths, since a run's working folder must now exist. Their assertions are unchanged.It extends two: the signal test adds SIGHUP, and the driver's acceptance test checks every attempt's form and layer plus the warning.
bench: task containers in rootless Docker with required limits, exec-only, no daemon for agents [PROTECTED: owner merges] #757's new stdin tests now
awaitsyncTree, which is async on this branch.Where an agent may write
This is in the README (the "Where an agent may write" bullet) and at the top of
driver.tsandusage.ts..claude/settings.jsonor a bare remote'shooks/. The sandbox protects those only in and above the working folder (inferred).~/.docker/config.json(acredsStorehelper the driver's owndockerwould run),~/.config/systemd/user/or~/.local/bin/.~/.profileand~/.bashrc. IndontAskmode, writes to protected paths such as those,~/.claude/and.git/are denied even with a matching allow rule (inferred from the permission-modes docs).scopeEditTools, above). A run that grants them anywhere else, or runs from outside the task's folder, never starts (measured, tests).dontAskmode, which refuses what would ask (inferred from the permissions docs).Still ahead in #748 Phase 0:
src/harness-evals/replay.tswith stub CI (theRunTaskthis driver calls, and thebaseTreeit is given);claude -pbaselines;How it was verified
test/bench.test.tsconflicted. I kept main's five Test: list scripts/bench/hidden.ts on the changed-files reader list (main red after #688 + #757) #797/bench: hidden.ts counts added lines starting "++ "; pin its two lines on the reader list (#797 follow-up) [PROTECTED: owner merges] #804 tests and this branch's async 512-byte tar test.asyncandawaiteachstdinSendscall. Every assert line of main's file is in the branch's, up to thatawait, apart from five older lines (assert.throwsonexecandsyncTree) that this branch madeassert.rejectsin earlier rounds (work/fix-759-r4/main-asserts.mjs).scripts/bench/hidden.tsis byte-identical to main's (git diff origin/main -- scripts/bench/hidden.tsis empty), so bench: hidden.ts counts added lines starting "++ "; pin its two lines on the reader list (#797 follow-up) [PROTECTED: owner merges] #804'saddedLines,importLinesandstrict && looseare intact.# pass 66,# fail 0, and so does the head.npm run typecheckexits 0,check:denylistprintsdenylist: 1299 files clean, andcheck:dco -- origin/main HEADprintsDCO: all commits signed off.git merge-tree --write-tree origin/main HEADis clean, and Merge: idempotent across a crash: merge.attempted before the API call, then reconciled, so the engine never reverts its own good merge (#733) [PROTECTED: owner merges] #793 touches no bench file, so it isn't merged again.RAW_UNSAFEalso refuses every non-ASCII\p{Zs}space, private use (\p{Co}) and U+FFFC, with fixtures for U+00A0, U+2009, U+3000, U+E000, U+F0000 and U+FFFC. Over every code point, 002d7c9's regex let 137,485 of those through. None it caught is let through now, visible text still passes, and the 16 files scan clean (sweep9.mjs).# pass 61,# fail 0.npm run typecheckexits 0,check:denylistprintsdenylist: 1299 files clean, andcheck:dco -- origin/main HEADprintsDCO: all commits signed off.a<c>b, fed to 6ffe56d's regex and toRAW_UNSAFE(work/fix-759-r4/sweep9.mjs).# pass 61,# fail 0. Main's newno-clock-helpersandno-wall-clock-durationstests pass on this tree.npm run typecheckexits 0,check:denylistprintsdenylist: 1299 files clean, andcheck:dco -- origin/main HEADprintsDCO: all commits signed off.RAW_UNSAFE. Nine pass the old regex and are caught by the new one: CR mid-line, U+2028, U+2029, U+FE0F, U+E0100, U+3164, U+115F, U+2800 and U+034F. The ten the old regex caught are still caught. Tab, a CRLF line end and U+00A0 pass both.# pass 60,# fail 1. A reused run id still made and removed a probe container:no container made, started or removed for a reused run id,15 !== 12.# pass 60,# fail 0with temp HOME, state and held-out dirs and the refusing stand-ins.npm run typecheckexits 0,check:denylistprintsdenylist: 1295 files clean, andcheck:dco -- origin/main HEADprintsDCO: all commits signed off. Main (682c8ce) merges cleanly (git merge-tree), and its one change since the last merge is a test line, so it isn't merged again.test/bench.test.ts. No guard was edited. Since removed.# pass 58,# fail 2:Missing expected rejection.(the repeated key started agent runs);Missing expected rejection: …/runs/r1/t01/renamed.test/bench.test.tsback on disk, it fails with'test/bench.test.ts:1605'.# pass 57,# fail 0with temp HOME, state and held-out dirs and the refusing stand-ins.npm run typecheckexits 0,check:denylistprintsdenylist: 1295 files clean, andcheck:dco -- origin/main HEADprintsDCO: all commits signed off.test/bench.test.ts. No guard was edited. Since removed.# pass 52,# fail 5:'/repo/sub: succeeded'whererefused (nested)was expected;Missing expected rejection: …/repo/up;'all refused' !== 'model declined';Missing expected exception: "\x80";Missing expected rejection.# pass 52,# fail 0with the same temp dirs and stand-ins as below.npm run typecheckexits 0,check:denylistprintsdenylist: 1290 files clean, andcheck:dco -- origin/main HEADprintsDCO: all commits signed off.test/bench.test.tschecked in on top. No guard was edited. Since removed.# pass 47,# fail 5:Missing expected exception: "\u0000";Missing expected rejection.(acwdthat is a link out of the task folder started);OS sandboxafter the extrachmod(andos_sandbox_read_denytrue);'all refused' !== 'model declined';server_tool_use:'all refused' !== 'model declined'.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 47,# fail 0.npm run typecheckexits 0.npm run check:denylist:denylist: 1290 files clean.npm run check:dco -- origin/main HEAD:DCO: all commits signed off.test/bench.test.tschecked in on top.confirm.ts,usage.ts,driver.tsand the README are byte-identical to a049531's (measured,git diff --stat a0495314 6585970a -- …is empty), so these are round 3's sources.# pass 40,# fail 7:[kind, form, refused, layer]list differs (nonodeattempts, no layers);'SIGHUP: no .netrc left',true !== false;Edit(//srv/bench/runs/r1/t01/**), not…/t01/repo/**;Missing expected exception: /srv/b*/runs/r1/t01;node -e:masked with /dev/null: one node -e read per sentinel,4 !== 0;the result line's permission_denials, matched by tool_use_id(nolayerfield);'all refused' !== 'model declined'for the read through$HOME.awaitsyncTree, the five of them failed on this branch (1 !== 0, nothing sent, because the promise wasn't awaited). With theawaitthey pass.# pass 37,# fail 0.scripts/bench/{usage,confirm,driver}.tsfrom the merge 9e4481c.~/.claude.json never exists while the model runs,true !== false;cat '<s>' > /dev/null && echo(statusall refused, notleaked);cat '<s>' > /dev/null && echo,wc -c '<s>'andcurl -o /dev/null, each exiting 0:all refused, thenleaked;~/.claude.jsonabsent: it existed during the model call, then never;driver.ts,grade.ts,usage.tsandaggregate.ts(63a69ff). No guard was edited.# pass 25,# fail 5:Missing expected rejection: constructor;nothing the clone configured ran,false;end worker: killed;expected: 'all refused'/expected: 'model declined'.usage.totalis 3.execis refused.catis refused by the permission layer (permission_denials), eachnode -eread by the mask, and each curl with no marker. The 13 attempts record those layers,os_sandbox_read_denyis true, and every sentinel is removed.os_sandbox_read_denyfalse, and the WARNING says so._pingOK: the run is refused, with no agent run and no task container, and the sentinels removed.TypeError: Cannot read properties of undefined (reading 'spendUsd'); the grading file checks off gaveMissing expected exception.; the hidden-field check off gave0 !== 6.not ok"the driver stops cleanly at a usage limit"; the subscription refusal off gavenot ok"the driver refuses a login that is not a subscription".permission_denials, the wording), and whether the OS sandbox masks the sentinel fornode -e(guess until then).Deferred to the RunTask PR (wording only, owner's decision of 2026-10-19)
These don't change what the checks do. They are left as they are here:
aggregate.ts:39(a doc comment);driver.ts:189(a comment);The confirmation now tests both Claude Code's permission layer and its OS sandbox, and records which one refused each attempt. "In Claude Code, with the agents' lane" is the exact wording.
leakeddetail starts "from inside Claude Code's sandbox:" (confirm.ts). That is the same wording point.Open items (not sandbox-check findings; listed, not changed here)
TaskRunnerfalls back to the blockingcallwhencallAsyncis unset andcallis set (container.ts). Keep that fallback to tests, so a future entry point can't bring back a blocking exec.readPatches: refusenlink > 1and open withO_NOFOLLOW;errorandstoppedrows in the rates;Adversarial pass (round 10)
Round 10's hand-off had none (the round-10 check's finding). This one was run in round 11, against the round-10 head 242f663, with fixtures, a scratch worktree and stand-ins only. Every result is measured unless labelled.
Main's assertions: every one of the 161 assert lines in main's
test/bench.test.tsis in the branch's file, up to an addedawaitbeforestdinSends(main-asserts.mjs). The only exceptions are five older lines (assert.throwsonexecandsyncTree) that this branch madeassert.rejectsin earlier rounds, because those functions are async here.No call left unawaited: no
stdinSends(call lacksawait(grep: 0). So no test compares a Promise withfalseand passes by accident.Main's new tests are live here, not vacuous: with the head's tests and c339175's (pre-bench: hidden.ts counts added lines starting "++ "; pin its two lines on the reader list (#797 follow-up) [PROTECTED: owner merges] #804)
scripts/bench/hidden.ts, in a scratch worktree since removed:# pass 63,# fail 3. These fail:argv: an added test-patch line starting "++ " …(Test: list scripts/bench/hidden.ts on the changed-files reader list (main red after #688 + #757) #797);stdin: a test-patch run with an added line starting "++ " …(Test: list scripts/bench/hidden.ts on the changed-files reader list (main red after #688 + #757) #797);stdin: an open import paren goes on only through names …(bench: hidden.ts counts added lines starting "++ "; pin its two lines on the reader list (#797 follow-up) [PROTECTED: owner merges] #804).The other two (
… 682c8ce5's rule says so too …anda +++ line with no name …) pass on the old code too. inferred: they guard cases the earlier rule already handled, and their own titles say so ("as before Test: list scripts/bench/hidden.ts on the changed-files reader list (main red after #688 + #757) #797").git diff origin/main -- scripts/bench/hidden.tsis empty on the head, so bench: hidden.ts counts added lines starting "++ "; pin its two lines on the reader list (#797 follow-up) [PROTECTED: owner merges] #804'saddedLines,importLinesandstrict && looseare main's.git merge-tree) and touches no bench file.RAW_UNSAFE's additions (r10-attacks.mjs, the regex read from the compiled test):\p{Zs}: all 16 non-ASCII spaces are refused (U+00A0, U+1680, U+2000-200A, U+202F, U+205F, U+3000). Only U+0020 passes.sweep9.mjs): nothing 002d7c9's regex caught is let through, and no default-ignorable, format or variation-selector code point gets through.test/bench.test.tschanged in round 10, besides the merge;Adversarial pass (round 9, covering round 8's changes too)
Round 8's hand-off had no Adversarial pass section (the round-8 check's finding). This one covers both rounds. It used fixtures and stand-ins only; every result is measured unless labelled.
sweep9.mjs,RAW_UNSAFEread from the compiled test).Left through, now none: no
\p{Default_Ignorable_Code_Point}, no\p{Cf}and no\p{Variation_Selector}code point gets through.Round 8's regex let through U+17B4-17B5, U+180B-180D and U+180F. That was round 8's miss: a sweep like this one would have found it. Fixed by adding the whole class.
Rule 10: nothing the old regex caught is let through now.
Visible text still passes: Latin, Greek, Cyrillic, CJK, Hangul, Arabic, Hebrew, Devanagari, emoji, a non-blank braille letter (U+2801), an em dash, box drawing and a combining acute (U+0301).
Still let through, by design (inferred: each shows as a space or a glyph):
\p{Zs}spaces (U+00A0, U+2009, U+202F, U+3000…);None is default-ignorable. The test title doesn't claim them.
Correction (round 10 check): both statements above are false about the head since 242f663. Round 10 made
RAW_UNSAFErefuse every non-ASCII\p{Zs}, private use and U+FFFC, and the scan test's title now names them. See the round-10 Adversarial pass.runBenchmakes no container call. It is refused at the run folder, afterdocker infoonly. The reused-run-id test checks it (15 !== 12on 9c666ba).test/bench.test.tsand the README change this round;Adversarial pass (round 7)
Fixtures, temp folders and stand-ins only (
work/fix-759-r4/adversarial7.mjsin the session scratchpad). Every result is measured, unless labelled.runBenchcalls with one run id, started in the same tick: the first ran its two tasks; the second was refused ("the run folder … already exists").mkdirwithoutrecursiveis atomic.runBenchthrows after recording the first task.b, then a run atb/c: refused, sincebcarries the earlier run's inode. This is a false positive, and it fails closed.bitself is allowed, as the same folder again.repo:repois allowed.repodoesn't hold it any more, and a process left in it can't writerepo's parent.scripts/bench/*andtest/bench.test.ts. It failed on the old file (line 1605). Correction (round 7 check): this line used to say the scan refused "any\p{Cf},\p{Cs}or control character other than tab and newline". In round 7 it also let through CR anywhere in a line, the line and paragraph separators, the variation selectors and the Hangul fillers.\p{Default_Ignorable_Code_Point}class, which covers them.test/bench.test.tsabove, and the round-9 Adversarial pass.scripts/benchandtest/changes;Adversarial pass (round 6)
Run against the decision guide with fixtures, temp folders and stand-ins only; no model, no root, no real state. The probe is
work/fix-759-r4/adversarial6.mjsin the session scratchpad. Every result below is measured, unless labelled.repo, thenrepo/suband the task folder while it awaits): the two nested ones are refused, and the first runs. There is no await between the check and the registration.bstarted afterathrew), so a failure doesn't block the rest of the task.repo2whilereporuns): started. They are not nested, andrepo's writes can't reachrepo2.//,/./and a link torepothat stays inside are all refused.repo/subreplaced by a fresh real folder): the next run inrepo/subis refused by the earlier-run rule. Fixed because of this pass: the earlier-run rule itself was added in this pass. Before it, the in-flight rule alone left this case open (inferred: a background process outliving its run is a guess).plain: these all count as extra calls, never as the listed command: anullinput, a string input, an own__proto__key, an object with atoString, an array command. A stringdescription, even an empty one, is still the listed command.RULE_UNSAFE: every BMP code point was tested.( ) * , ? [ \ ] { }, U+007F-00A0, U+00AD, the Arabic and Syriac format characters, U+1680, U+180E, U+2000-200F, U+2028-202F, U+205F-206F (except U+2065, which is unassigned), U+3000, all surrogates, U+FEFF and U+FFF9-FFFB; and the astral U+1D173, U+E0001 and U+E0020.\p{Cs}is added. inferred: the run-time realpath check would have refused such a path anyway.scripts/benchandtest/only);win32skips match the file's existing POSIX skips, and CI runs Linux.git diff 943244ab 45b91baf -- test/bench.test.tsadds tests and one import only).New dependencies
None in the repo. sandbox-runtime 0.0.79 is installed for the bench user only, by the README.
What this can never do
node -eread, which only the OS sandbox can stop (round 4, measured). What it does not claim:catalone says nothing about the OS sandbox, since the permission layer most likely stopscatfirst (inferred);~/.claude.jsonduring the sandbox check (round 3, measured), or leave a sentinel behind after SIGINT, SIGTERM or SIGHUP (measured). inferred limit: SIGKILL or a power loss can still leave one, and the next check then lists that path as a skipped real file.os_sandbox_read_denytrue, when the model ran anything beyond the listed commands (round 5, measured).~/bench, outside every checkout (bench: preflight for the SWE-bench run (subscription-only login, CLI pin, caps, usage meter) [PROTECTED: owner merges] #756'sbenchRoot).Compromised component
catof sentinels, which most likely tests Claude Code's permission layer (inferred);node -eof sentinels, which tests its OS sandbox's owndenyRead(round 4; whether the sandbox or the permission layer stopped it is recorded per attempt, from markers inferred from the docs);~/.docker/config.json,~/.config/systemd/user/,~/.local/bin/or a sibling folder's.claude/settings.jsonor a bare remote's hooks through them.~/.profile,~/.bashrc,~/.gitconfigand~/.claude/are protected bydontAskanyway.$HOME,~, a glob or a link can no longer pass as "all refused". Since round 5 (measured):server_tool_use;unknownwhenever it ran anything extra;src/imports it, and the package doesn't ship it.scopeEditTools. The guard is this review: the owner mergesscripts/**.chmod 750, checked withsudo -u wl-bench lsfailing).