Repository navigation
Bench: pinned dataset and image downloads, verified before use (#748 slice 2) [PROTECTED: owner merges] - #863
Conversation
…ne per check and no secret (#748) The owner set up the bench user on 2026-10-18 and runs the remaining steps once there is a command to check them. `node dist/scripts/bench/cli.js preflight` (a `bench` wrapper in the README) runs the driver's model-free, container-free checks as the bench user and prints PASS, FAIL or SKIP for each: bench user, home, bench root, held-out store (absent, or a 0700 folder of this user, never a link), research-on-demand off, the claude pin, the claude.ai login (method and plan only), the srt probe, a rootless daemon through an absolute DOCKER_HOST, and the container limits. It exits 0 only when every check passes. When the user or home check fails nothing else runs: as another user it would ask that user's claude about that user's login and probe that user's home. It starts no container, makes no model call, downloads nothing and keeps nothing on disk. Every printed line goes through scrub(): values of variables that may hold a credential or switch the login, token-shaped strings and email addresses are redacted, control characters dropped, each detail capped. requireBenchUser now refuses root (uid 0 or the name root) even when the bench user variable names it: before, setting it to root made root the bench user. The README's setup gains the srt install from a checksum-pinned tarball, the bench clone at a pinned commit, the wrapper, and the preflight run as `sudo -iu wl-bench` then `bench preflight`. Test (fails without): test/bench-cli.test.ts. Written first and run on the unchanged tree: 9 of 9 fail (8 with ERR_MODULE_NOT_FOUND for scripts/bench/cli.js, the root test with "Missing expected exception"). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…LOG bullet into v0.5.3 (unreleased) Main's v0.5.3 is undated again and its generated PR lists are gone until the release (54b3eb1). The conflict was this branch's bullet inside those lists: they are dropped as on main, and the bullet is now a hand-written note at the end of "Guardrail changes: what each one does", above the section's closing empty "### Guardrail changes". No code change. Test (fails without): none new; test/changelog.test.ts, bench-cli and bench pass on the merged tree (85 of 85). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…, secrets scrubbed whatever cut them, README steps fail closed The #851 round-1 review measured four gaps, each fixed by design: - The held-out check lstat'ed only the store itself, so a linked <root>/state passed, made or not, and 0500, 02700, 01700 and 04700 passed as "0700". Every folder from the bench root down is now lstat'ed (a link anywhere fails, whether or not the store is made), the real path must equal the written one, and the mode must be exactly 0700. - The driver's checks cut a child's stderr at 200 or 300 characters before the preflight saw it, so a secret straddling the cut printed its first characters. main() now wraps the one output sink in scrub(), which runs on the full line before any cut, and scrub() also redacts every piece of 6 or more characters that starts or ends a secret value, so a value cut short anywhere upstream prints nothing of itself. Paths and socket addresses are redacted whole only. An email needs a letters-only last label, so `sandbox-runtime@0.0.79` stays readable. - README step 4 ran `npm install` on a checksum mismatch or with no openssl when pasted as a block. The install is now chained on the check, with --ignore-scripts. Step 5's placeholders held an apostrophe and a `<` (bash waited for a quote, or read a redirect); they are plain words now, and every command is chained on the pinned commit. - test/bench-cli.test.ts imports ./helpers.js, the isolation-preload guard. Also: requireBenchUser refuses an unknown uid (-1, not an integer) and a root effective uid. Test (fails without): test/bench-cli.test.ts. The five new tests, written first and run on e8d99f4: 5 of 14 fail (uid -1 "Missing expected exception"; linked state "'PASS' !== 'FAIL'"; a piece of the secret printed; step 5 "unexpected EOF while looking for matching `''"; step 4 "a checksum mismatch installed srt"), the 9 others pass. The secret test's canary is a plainly fake, low-entropy value; with it, on e8d99f4 the piece "benchc" printed. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…ELOG bullets Main's #855 bullet and this branch's #851 bullet were appended at the same spot, the end of "Guardrail changes: what each one does" in v0.5.3 (unreleased). Both are kept, main's first, neither reworded. No code change. Test (fails without): none new; test/changelog.test.ts passes on the merged tree. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…test runs its bash in its own temp folder Round-2 review notes. `git clone` refuses a non-empty ~/src/bench-engine and the chained step then stops, so the README says to move an earlier clone aside first. The step-4 test's bash now starts in the test's temp base, so nothing the block does before its own `cd` lands in the test runner's folder. Test (fails without): none; a README line and a test's working folder. bench-cli, bench and changelog pass (90 of 90). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…ore, and images are pulled by digest only Slice 2 of #748. The smoke run needs the SWE-bench Verified file and the task images on the bench machine, and both must be exactly what was reviewed: a moved branch, a changed file or a retagged image would change the benchmark without anyone seeing it. - scripts/bench/pins.json is the one place a pin lives: the dataset's revision and sha256, and a set of image digests. pins.ts parses it on a closed schema. An unknown key, a half pin, or anything shaped like an instance id or an instance image name refuses the whole file, even behind a JSON escape: the agents can read this clone. Only pins.ts builds a pin, frozen, and the download and the pull refuse anything it didn't build (a module-private WeakSet, so a copy or an object made on the class's prototype is refused too). The dataset is unpinned (null) until the owner records it, and until then bench download fetches nothing. - download.ts downloadDataset: refuses a store that fails the preflight's held-out check (moved from cli.ts to store.ts so both use it), fetches into memory over https from the pinned hosts only (at most five redirects, each checked), checks the sha256, and only then makes the store (0700, one folder at a time, no link) and writes a new O_EXCL|O_NOFOLLOW 0600 temporary file. It reads that file back and renames it only when the read-back matches. It never follows a link, and never chmods or replaces what it finds. - download.ts pullImage: `docker pull <name>@sha256:<digest>` then `docker image inspect` to read the digest back, nothing else (checkImageArgs), with the driver's credential-free docker environment. The argv is built only from an ImageRef, which pinnedImage makes from a digest in the pins and never from a tag. - cli.ts: `bench download`, which takes no argument (no flag sets a pin). Test (fails without): test/bench-pins.test.ts, 13 of 13 tests failed on the tree before this change; the two adversarial-pass tests added after (a prototype-made pin, a mutated digest list, an escaped instance id) failed on the first version of pins.ts. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
One bullet at the start of v0.5.3's "Guardrail changes: what each one does", so it doesn't collide with the bullets other PRs append at the end. Test (fails without): none; a changelog entry (test/changelog.test.ts passes with it). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (round 1/2)Independent final review of head Acceptance (slice 2 scope)
Fail-without (method b)measured: in a fresh worktree at Other checks
Required changes
Notes (non-blocking)
|
…ge argv from frozen fields, no real task id in tests #863 round 1. Four holes the review measured, each closed by design: - A repeated key got past the closed schema (JSON.parse keeps the last copy, so an earlier copy, such as a hidden 4-digest selection, went unchecked). The pins file must now equal its canonical form, JSON.stringify(pins, null, 2) and a newline, so every byte of it is what was parsed and checked. pins.json is reformatted to it. - A percent-escaped instance id in dataset.base got past the scan. base must now be written exactly as the url it is (no :443, no dot segment, no upper-case host), and each of its path segments is held to the same plain-name rule as dataset.path, so no percent escape is left. - pullImage built the docker argv through ImageRef's toString, which a swapped prototype method could change: a pull for an unpinned digest went out before the read-back refused it. The argv now comes from the reference's own frozen fields, and the three pin classes' prototypes are frozen. - test/bench-pins.test.ts named a real SWE-bench instance and its image name. They are now the repo's synthetic example__proj-4242 and sweb.eval.x86_64.example_1776_proj-4242. A grep of the whole branch diff against main for instance-id shapes and the task repos' names finds no other. The first commit's message said 13 of 13 tests failed without it; that was the count when it was written. The file had 14 tests at that commit (14 of 14 fail on 9ab22b4, the reviewer measured), and has 17 now. Test (fails without): test/bench-pins.test.ts's three round-1 tests failed on b61f0fa (# tests 17 / # pass 14 / # fail 3): a repeated image_digests parsed, a %5F%5F base parsed, and docker was sent `pull evil.example.net/other@sha256:<unpinned>`. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…-bench-pins: keep both CHANGELOG bullets, the bench download one first in its list Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 1 fixes (head 2a3dcab)I wrote each test first and ran it on b61f0fa's code:
Count correction: the first commit message's "13 of 13" was the count when I wrote it. The file had 14 tests at f4fc274, as you measured. It has 17 now. The PR body is corrected too. After the merge (measured, 2a3dcab):
Not done: the non-blocking notes (pinning the registry, a kind check in |
Evaluator verdict: PASS (round 2, with notes)Head The four round-1 items
Merge
|
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…-bench-pins: keep both CHANGELOG bullets, the bench download one first in its list Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
… to changelog.d/851-bench-preflight.md Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…GELOG.md as the base's, #863's note moved to changelog.d/863-bench-pinned-downloads.md Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…is the base #884 already built on #863 (8e74e4e) merged as squash a5df225, so its six bench files came back as add/add and content conflicts against #884, which descends from 8e74e4e. Main's side of each conflicting file is byte-identical to 8e74e4e's (measured: git rev-parse blob ids), so each hunk is #863's original line next to #884's extension of it. Every hunk keeps #884's side, and each resolved file is byte-identical to #884's head 170e70a: scripts/bench/README.md, cli.ts, download.ts, pins.json, pins.ts and test/bench-pins.test.ts. Every #863 pin and check is kept. src/merge-policy-weakening.ts merged cleanly: main's entries plus #884's SELECTION_RULES line. Test (fails without): none new; this is a merge with no behaviour change over 170e70a. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Intent: slice 2 of #748. It adds
bench download, which fetches the SWE-bench Verified file at a pinned revision and sha256 into the 0700 held-out store, and it checks the bytes before anything is kept. Task images are pulled by pinned digest only. Every pin lives in one data file,scripts/bench/pins.json, and nothing else sets one. No engine change: onlyscripts/bench/**, one new test file and a CHANGELOG bullet.Stacked on #851 (slice 1, branch
issue-748-bench-cli), so the diff againstmainshows #851's commits too. Merge this after #851. This PR's own commits:Risk class: protected. It touches
scripts/**, and the owner merges.Refs #748 (a slice; it doesn't close the issue).
What it does
scripts/bench/pins.jsonis the only place a pin lives. It holds:dataset:base,path,revision,sha256,max_bytesandhosts;image_digests: digests only.The dataset is unpinned (null) for now, and the digest list is empty. So
bench downloadrefuses and fetches nothing, and no image can be pulled, until the owner records the pins.scripts/bench/pins.tsparses that file against a closed schema. It refuses the whole file on:owner__repo-N) or an instance image name (_1776_), checked on both the raw text and the parsed value, so a JSON escape doesn't hide one.The url is
base + revision + "/" + path, so it is at the pinned revision by construction.loadPins()reads onlyPINS_FILE.DatasetPin,PinsandImageRefcan only be built inside the module (a module-private token), are frozen, and are recorded in a module-private WeakSet.requirePinnedrefuses anything outside that set.scripts/bench/download.tsdownloadDatasetworks in this order:heldOutCheckmoved, unchanged, from cli.ts to store.ts; cli.ts re-exports it.dataset/<sha256>-<name>is kept and nothing is fetched. A file that doesn't match, or a link at that name, is refused and left in place.max_bytes, and the sha256 must match. Nothing is made or written before this point.O_CREAT|O_EXCL|O_NOFOLLOW0600 temporary file, fsyncs it and reads it back through a freshO_NOFOLLOWdescriptor. It renames the file only if the read-back matches; any failure removes the temporary file.The fetcher is injectable. The default is Node's own
fetchwithredirect: 'manual'; the redirect checks are in our code, not the fetcher's.download.tspullImagesends onlydocker pull <name>@sha256:<64 hex>, thendocker image inspect --format {{json .RepoDigests}} <same>to read the digest back.checkImageArgsrefuses every other argv. The argv is built only from anImageRef, whichpinnedImagemakes only from a digest in the pins: no tag, no port, no option. Docker getscontainer.tsdockerEnv(credential-free, with an absolute DOCKER_HOST).container.tschanged only to exportspawnDocker.cli.ts:bench downloadtakes no argument (download --sha256 xexits 2), as the bench user only.mainreturnsnumber | Promise<number>, and the script entry handles both.README: a new step 7, "Still to come" updated.
Release note:
changelog.d/863-bench-pinned-downloads.md, under "Guardrail changes: what each one does". Since the changelog.d conversion (Release notes: one changelog.d file per change, moved into the dated section by the changelog step [PROTECTED: owner merges] #870, merged at 8e74e4e through bench: the bench command and bench preflight, a pass/fail line per check with no secret (#748 slice 1) [PROTECTED: owner merges] #851's 6441aba),CHANGELOG.mdis unchanged here: its diff against 6441aba is empty (measured). The fragment's five note lines are byte-identical to the five lines this PR added to CHANGELOG.md before (sha256 compared, measured).How it was verified
Fail-without (measured): I wrote
test/bench-pins.test.tsfirst, built it and ran it on the unchanged tree (9ab22b4) withnode --import ./dist/test/isolate.js --test dist/test/bench-pins.test.js:# tests 13 / # pass 0 / # fail 13at that time.# tests 14 / # pass 12 / # fail 2) before the fix.Round 1 (measured): the three new round-1 tests failed on b61f0fa (
# tests 17 / # pass 14 / # fail 3):image_digestskey parsed;%5F%5Fbase parsed;pull evil.example.net/other@sha256:<unpinned>.All 17 pass at 3cbe2df.
After round 1 and the merge of bench: the bench command and bench preflight, a pass/fail line per check with no secret (#748 slice 1) [PROTECTED: owner merges] #851 4313ef8 (main d9855a9), measured at 2a3dcab:
bench-pins + bench-cli + bench + changelog:# tests 107 / # pass 107 / # fail 0.npm run typecheck: exit 0.npm run check:denylist: "1347 files clean". DCO: all commits signed off.After (measured):
node --import ./dist/test/isolate.js --test dist/test/bench-pins.test.js dist/test/bench-cli.test.js dist/test/bench.test.js:# tests 94 / # pass 94 / # fail 0. After two more redirect cases were added,bench-pinsalone gave# tests 14 / # pass 14.dist/test/changelog.test.js: 10/10 pass.npm run typecheck: exit 0 (measured).npm run check:denylist: "denylist: 1339 files clean" (measured).node scripts/check-dco.mjs origin/main HEAD: "DCO: all commits signed off" (measured). The push gate reported "2 commit(s) and 1 tip(s) clean" (measured).No network, no docker, no real state (inferred from the test code, which I read):
/nonexistent;./helpers.js, which enforces the preload.httpsFetcheris not exercised by any test, since that would need the network.Not run locally:
fullsuite.sh. Some suite files set PATH to/usr/binwithout stand-ins for every host tool, and this PR touches none of what they cover. CI runs the full suite.New dependencies
None. It uses
node:crypto,node:fs,node:pathandnode:url, plus Node's built-infetchfor the real fetcher.What this can never do
Each item is inferred from the code and backed by the named test (measured).
Scope: each "never" holds against input from outside this process's code: the pins file, the network, the filesystem, the command line and the environment. It does not hold against code running in the same process. Such code could swap a built-in (the reviewer measured that swapping
Array.prototype.includesmakespinnedImagebuild a genuineImageReffor an unpinned digest), or callspawnDockerorfsdirectly. The bench runs only its own code in this process (inferred).writeVerifiedbefore the write, and on read-back before the rename (test "bytes that do not match…").state, store,dataset/folder or file name is refused, and nothing appears where the link points (two tests). The temporary file isO_EXCL|O_NOFOLLOW.scripts/bench/pins.json: no flag, argument or variable. A hand-made, copied or prototype-made pin is refused, and a parsed one can't be changed (frozen). This holds against input from outside this process's code; see Scope.pull <name>@sha256:…orimage inspect --format {{json .RepoDigests}} <name>@sha256:…, throughpullImage. The argv recorded in the test is exactly those two. Same-process code can still callspawnDockerdirectly; see Scope.Compromised component
bench downloadruns before any agent run, not during one (inferred: no driver code calls it).max_bytes(256 MiB in the repo's file).image inspect.pins.jsonto a hostile url and a matching sha256. That is a reviewed change to a protected path, which the owner merges. The parser still refuses non-https, unknown keys and task-shaped text, but cannot judge whether a pinned file is the right one. That is the owner's review.src/changed, andscripts/bench/isn't packed (see Package size).Adversarial pass
Every attack below is measured by a test in
test/bench-pins.test.tsunless marked otherwise.A pin built past the constructor.
Object.create(DatasetPin.prototype)plus the fields passesinstanceof. My first version only checkedinstanceof, so this attack got through (test failed, measured). Fixed: genuine pins are recorded in a module-private WeakSet, andrequirePinnedchecks that set. Now refused.Adding a digest after parsing.
pins.digestswas a mutableSet, so this got through. Fixed: it is now a frozen array. The pin objects andhostsare frozen too, and writes throw TypeError.An instance id behind a JSON escape (
django__django-11099). The raw-text scan missed it, so this got through. Fixed: the scan now also runs on the parsed value.Round 1 (the reviewer's findings, all fixed by design, each test shown failing on b61f0fa):
JSON.stringify(pins, null, 2)plus a newline.basegot through.basemust now equalnew URL(base).href, and each path segment is a plain name.ImageRef.prototype.toStringchanged the pull argv. The argv now comes from the reference's own frozen fields, and the prototypes are frozen.example__proj-4242. A grep of the whole branch diff, README included, finds no other.Redirect tricks. All refused:
https://evil<host>;https://<host>.elsewhere.example.net;elsewhere\@<host>;Image references. All refused:
localhost:5000/…;--platform=x@…;Docker argv refused:
pull --platform,pull --all-tags,image rm,run,tag, an inspect format other than RepoDigests.Store attacks. All refused, with nothing fetched:
state;dataset/;A pin override from the command line:
download --sha256 <x>exits 2, and nothing is fetched.A leak through shell history (design choice, inferred): no command takes an image name as an argument, because the bench user's shell history isn't on the sandbox's read-deny list and an image name carries its instance id. Task selection (slice 3) will hand references over from the store.
Not defended (inferred): a process running as the bench user that swaps a store folder for a link between
makeStore's checks and the rename. Node has noopenat, so each step is checked by path. A final check after the rename catches a swapped store, but the bytes would already be there. Only the bench user can do this, the agents' sandbox denies the store, and the download runs before agents do.Package size
Delta: 0 bytes (measured). I ran
npm pack --dry-run --json(through npmw.sh) on this branch, and on a separate worktree at main db2e3df (this branch's merge base with main), each afternpx tsc:dist/webisn't built in either, and neither side touchesweb/.Per file:
scripts/bench/{pins.json,pins.ts,download.ts,cli.ts,container.ts,store.ts,README.md},test/bench-pins.test.tsandCHANGELOG.mdare all not packed. The pack list has no entry underscripts/bench,dist/scripts,dist/test,test/orCHANGELOG(measured), so every one is +0. No budget change.For the owner
revision(40 hex) andsha256inpins.jsonin a reviewed change. Then runbench downloadas wl-bench.baseandpathin the file are my guess at the upstream location (princeton-nlp/SWE-bench_Verified,data/test-00000-of-00001.parquet). Check them when you pin.hosts(huggingface.co,hf.co) are a guess at where its downloads redirect. If a redirect is refused, it names the host.store.tsparseDataset(JSON lines) can't read it. Slice 3 (selection) has to settle that, with no new dependency. This PR only stores the verified bytes.name@digestover from the held-out store belong to slices 3 and 4.container.ts'sIMAGEpattern fordocker create/runstill accepts a tag.test/bench.test.tsusesswebench/example:latest. Tightening it to digest-only (anImageRef) belongs in slice 4, where task containers get their images frompinnedImage. I left it as is, since this lane owns only new test files and it isn't a loosening.