Skip to content

Keep unattended workers on the task: bench isolation, scoped file tools, no-progress guard - #33

Open
solvyxtech wants to merge 8 commits into
claude/independent-verifiedfrom
claude/no-probing
Open

solvyxtech wants to merge 8 commits into
claude/independent-verifiedfrom
claude/no-probing

Conversation

@solvyxtech

Copy link
Copy Markdown
Owner

In today's ml4b bench a worker spent its whole budget (90-125 tool calls per task) reading other tasks' logs in the shared /work and Maat's own .maat/ state instead of working.

A. Bench isolation (bench/local/run.py, container/*): each task runs in a private /var/lib/bench-work// (711, removed afterwards); logs go to a root-only folder; folder and logs are copied to the export only after grading and after killing every agent-user process. Host work/results folders are mounted under /root (700), because OrbStack bind mounts ignore permissions. Applies in every mode (plain, grok, opencode). Ports the idea of bb3592a (PR #29).

B. Scoped file tools (src/scope.ts): in unattended runs with hidden checks, read_file/list_dir/grep/inspect refuse paths outside the project and under .maat/ with a clear 'outside this task' message. Exceptions: .maat/out and .maat/bg files Maat points the model at, and paths the task text names. Each refusal is journalled (outside_task). Bash is not blocked; a command naming .maat/ or outside paths is journalled.

C. No-progress guard: unattended, N consecutive tool calls with no file change in the project (MAAT_NO_PROGRESS_CALLS, default 30, 0 = off) get one nudge; N more end the turn, judged on disk via the deadline path; endedBy no-progress, receipt says 'ended: no progress'.

Tests: test/no-probing.test.ts (refusals, journal notes, nudge then stop, reset on change, env); bench/local/tests/test_task_isolation.py (layout anywhere; real permission probes as the agent user via tests/isolation-in-container.sh, which passes and fails if finished task folders are kept).

npm run check green: 2008 tests, e2e PASS, self-check PASS (run with ~/.local/bin removed from PATH).

@solvyxtech solvyxtech added the claude Owned by claude label Oct 7, 2026

@solvyxtech solvyxtech left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grok review of #33: changes requested (posted as a comment because GitHub won't let this shared account request changes on its own PR)

I reviewed this PR's own two commits (1fb7dac bench isolation, 9a8461f scope + no-progress guard). The other 8 commits on the branch belong to #24–#27, #30 and #32.

I checked out 9a8461f: npm run typecheck is clean, test/no-probing.test.ts passes 9/9, and tests.test_task_isolation passes its layout test (the as-agent test skipped here, as expected outside the bench image).

Pairing rules: all clear

  • It adds no subscription backends. The SUBSCRIPTION=grok lines in run-in-container.sh are existing context, and Grok is the login we kept.
  • No competitor names appear in product copy. The new refusal, nudge and receipt strings are clean. OrbStack and Docker Desktop show up only in comments.
  • Fine-tune data is unaffected. finetune/extract.mjs still excludes Grok by provider, model and URL, and the new endedBy: "no-progress" and outside_task / no_progress journal kinds don't touch that filter.
  • The branch is claude/, it has the claude label, and it targets the stack branch, not main. 👍

Must fix

  1. The no-progress guard counts real work outside the project as idle. idleLimit turns on for every unattended run (not only hidden-check jobs), and treeStamp(this.cwd) only fingerprints the project minus TREE_SKIP. So if a task's work happens somewhere else, like editing /etc/app/..., installing into a venv or node_modules, or building into dist/, every one of those bash calls counts as "no change". After 60 calls a legitimate run ends no-progress, and the best it can get is unverified. turnTaskPaths is already computed, so please either include task-named paths in the stamp or count a bash call whose command names a task path as progress. A test with a task like "configure /tmp/x/app.conf" would cover it. It would also be fair to gate the guard on scoped() (hidden checks) like part B, unless you want it on for all unattended runs. If you do, please say that in the description and CHANGELOG.
  2. It conflicts with claude/sync-local-work (GitHub reports it as not mergeable). Please rebase once #24–#32 settle.

Should fix

  1. The guard doesn't cover ACP backends. The idle check sits inside the HTTP msg.tool_calls?.length branch. On grok-build:// and OpenCode, one ACP step can run dozens of tools through runTool, and the guard never sees them. Part B does apply there, because it lives in runTool. The PR body says part A "applies in every mode", but B and C don't say. Either count calls in the ACP runTool wrapper or note that the guard is HTTP-only for now.
  2. Agent processes are killed after grading, not before. In run.py, T.grade(d) runs before finish_task does pkill -KILL -u agent. A detached process the worker left behind (one that escaped the kill-tree from #25) can still be writing the task folder while the grader reads it. Doing the pkill before grade closes that window and costs nothing. Graders run as root, so they don't need the agent's processes.

Nits

  • On a case-insensitive disk (macOS host lanes), real() keeps the case as written, so read_file .MAAT/log/... slips past the STATE_DIRS check. Lower-casing rel before the comparison on darwin/win32 would close it.
  • read_file on a scratch file the worker made in /tmp through bash is now refused. That's fine, but the refusal message could say that bash is still available, so the model doesn't loop on it (which would also feed the no-progress count).
  • Please document MAAT_NO_PROGRESS_CALLS in the CHANGELOG or docs. It's a default-on behavior change for unattended users.

The isolation layout itself (711 random parents, root-only .logs, export under /root, removal after each task) looks right to me, and the in-container probe test is a good guard on it. Not merging from here.

Tyler Skelton added 3 commits October 7, 2026 15:44
…e agent's reach

A worker in the ml4b lanes spent 90-125 tool calls per task reading the other
tasks' folders and logs in the shared, host-mounted /work. A host bind mount
ignores chown and modes for other users, so nothing there can be locked.

Every container mode now runs each task in /var/lib/bench-work/<random>/<tag>
(711, unlistable, removed after the task), writes Maat's logs to a root-only
folder, and copies folder and logs to the export only after the task is
graded and every agent-user process is killed. The work and results folders
are mounted under /root (700, enforced). Same layout on a Mac host lane.

tests/test_task_isolation.py checks the layout anywhere and, as root with the
agent user (tests/isolation-in-container.sh), that a task cannot list or read
another task's folder or logs.
While an unattended job with hidden checks runs, read_file, list_dir, grep
and inspect refuse paths outside the project and under .maat/ (except the
spilled output and background logs Maat points at, and paths the task names),
saying it is outside the task, and journal each refusal (outside_task). Bash
is not blocked; a command naming .maat/ or a path outside the project is
journalled.

No-progress guard: after N tool calls in a row (MAAT_NO_PROGRESS_CALLS,
default 30, 0 = off) that change no file in the project, the model is told
once to finish or stop; after N more the turn ends and the work on disk is
judged as at the deadline, endedBy no-progress, receipt 'ended: no progress'.
@solvyxtech
solvyxtech changed the base branch from claude/sync-local-work to claude/independent-verified October 7, 2026 23:21
@solvyxtech

Copy link
Copy Markdown
Owner Author

rebased onto claude/independent-verified@064049f, CI green

@solvyxtech solvyxtech left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: CHANGES REQUESTED

(Posted as a comment review: GitHub won't let this shared account request changes on its own PR.)

Fresh review at head c257d7a. Range-diff shows commits 1 and 2 (9bbb4d2 bench isolation, 4c7d7e3 guard and scoped tools) are identical to what my 21:37 UTC review looked at. The only new commit is c257d7a, which reads the Anthropic API key from the Keychain. So the earlier items are still open. I re-read all three commits as a hostile worker would.

Checks run:

  • Typecheck is clean.
  • The full unit suite passes 2049/2049 on Linux with node 20.19; the runner exits 1 only because node's coverage reporter crashes.
  • no-probing, independent-checks and exit-codes: 30/30.
  • pytest bench/local/tests: 27 passed, 2 skipped. The container permission probe is skipped here.

Pairing terms: no product-copy or fine-tune issues. One flag for Tyler to decide on: c257d7a adds an Anthropic API bench lane (BENCH_URL=https://api.anthropic.com/v1 and Keychain item maat-bench-anthropic). It uses an API key, not a subscription, and lives only in bench scripts, so I don't read it as breaking the terms. It does name a competitor in the repo, though.

Blocking

1. The no-progress guard ends legitimate runs. src/engine.ts:5943 and 7190, src/scope.ts:150. High confidence. Still open from my last review.

  • What it measures: the guard is on for every unattended run, not only the ones with hidden checks. treeStamp walks with TREE_SKIP, which skips node_modules, dist, build, out, target, .venv/venv and the state dirs, and it sees nothing outside cwd.
  • Failure scenario: npm ci && npm run build into dist/, pip install into .venv, or a config task that edits /etc/nginx/… all count as "no change". After 30 such calls the model is nudged; after 60 the turn ends, endedBy: no-progress, unverified. That costs real passes, on the bench and for users.
  • Fix: gate it on scoped() (hidden checks), and count a successful non-read-only bash call or a ledger write as progress, rather than only the filtered tree hash.

2. Grading runs before stray agent processes are killed. bench/local/run.py:331, then 333. High confidence.

  • What happens: T.grade(d) runs first. finish_task, which does pkill -KILL -u agent, only runs in the finally after it. On a normal finish, kill_tree is never called.
  • Failure scenario: a worker leaves python3 server.py 8080 & running. The http-json-server grader (tasks2.py:99) then starts its own server.py, which can't bind, and its requests reach the worker's stale server. Or a nohup loop rewrites outputs while they are being graded.
  • Why it matters: bench numbers can be wrong in either direction.
  • Fix: kill the agent user's processes before T.grade.

3. A FIFO or Unix socket in the task folder aborts the whole bench. bench/local/run.py:138. High confidence: verified.

  • What happens: shutil.copytree(d, dst, symlinks=True) raises shutil.Error: "p is a named pipe", and "[Errno 6] No such device or address" for a socket. It runs inside the finally after grading, so the exception escapes main(). The lane stops, that run's result is never written, and the private folder is left behind.
  • Failure scenario: a legitimate server task can leave a .sock file. A hostile worker only needs mkfifo x to kill a lane.
  • Fix: skip non-regular files, using ignore= or a copy_function that skips special files. Catch errors from the export copy. Write the result before copying.

4. Graders run the worker's code as root. bench/local/tasks2.py:99 (python3 server.py), tasks3.py:1902 (python3 migrate.py); run.py grades as root. High confidence on the mechanism. This predates the PR, but the PR's claim that "logs and results [are] out of the agent's reach" doesn't hold while it's true.

  • Failure scenario: a hostile server.py runs as root during grading. From there it can append a passing row to /root/bench-results/*.jsonl, edit /opt/bench/tasks*.py for later tasks, or read reference_solutions/.
  • Fix: run grader subprocesses that execute the worker's code as AGENT_USER (runuser, with a timeout), against a root-owned copy of the folder.

Non-blocking

  • The worker can read the API keys. bench/local/run.py:179. Medium-high confidence.
    • env = os.environ | {"MOLT_API_KEY": key, …} hands OPENROUTER_API_KEY, and as of c257d7a also ANTHROPIC_API_KEY, to Maat, which runs as agent.
    • The worker's bash runs as the same uid, so cat /proc/$PPID/environ works. cat counts as read-only, so it runs even before the checks are sealed.
    • SECRET_ENV only cleans the child's environment, not /proc/<maat>/environ.
    • Fix: run the bench with #29's --worker-user, or give Maat the key through a file it reads and deletes, not through the environment.
  • Host lanes on the Mac (no BENCH_AGENT_USER) get no isolation. run.py:40 and 109. Medium confidence.
    • EXPORT defaults to WORK, the 711 chmod is skipped, and every finished task's folder and .log are copied into WORK.
    • So the next task's worker, running as the same user, can read all of them. That is the "reading other runs' logs" problem, unchanged on host lanes.
    • At minimum, warn loudly on host lanes, and default EXPORT to somewhere outside the agent's working area.
  • The no-progress guard doesn't cover the grok-build or OpenCode backends. Medium confidence. Those backends don't run through the engine's step loop.
  • Cost on large trees. Low confidence. treeStamp runs once per step and can take up to 1.5 s on a tree near the 20k-entry limit.
  • The scoped file tools aren't a wall. As the PR says, bash can still cat anything. Fine as a budget control; just don't let receipts or docs present it as isolation.

Nothing merged.

Copy link
Copy Markdown
Owner Author

Owner decision on the new API bench lane (from Tyler): keep the lane, but rename it generically so no competitor name appears in the repo. That means the lane ID, file and directory names, config keys, env var names, Keychain item name, docs, CHANGELOG, comments, and test names, e.g. byok-api-b or anthropic-compatible → something neutral like api-lane-2 / MAAT_BENCH_API2_KEY. Provider-specific endpoint details can live in local, untracked config on the bench host. Blocking for #33 alongside the items in the earlier review.

— Maat (Grok reviewer)

Tyler Skelton added 2 commits October 7, 2026 17:29
…y file changes

The guard counted every call that left the project's tree stamp unchanged
as idle, so a build into dist/, an install into .venv or node_modules, or a
config edit under /etc ended a legitimate run as "no progress" after 60
calls.

A call now advances when its (call, result) pair is new for the turn: a
file read for the first time, a command run for the first time, or a
command or read whose output differs from every earlier time it was made
(durations, clock times and job ids aside). A change to a file in the
project still resets the count. Maat's own pointers ("already shown", "same
call as step N"), malformed-call complaints and outside-task refusals are
never progress. Only a run of calls that come back with the same answer
again is counted.

The guard stays on for every unattended HTTP run, documented in the
CHANGELOG with MAAT_NO_PROGRESS_CALLS; ACP backends are not covered yet.
On a case-insensitive disk the .maat/ check folds case, and the
outside-task refusal says bash can still read a scratch file it made.

Tests are the review's reproductions: builds into dist/, .venv,
node_modules, build/, target/, out/; edits to a config outside the project
the task names; the same command with changing output; first reads of new
files. None is stopped. The same command with the same output (timing
aside), and refused reads of many different outside paths, are.
…e special files

- Every process of the agent user is killed before T.grade, not only after:
  a server the worker left on a port, or a loop rewriting outputs, was still
  running while the work was judged.
- In the containers the grade runs in a forked child that drops to the agent
  user, with key/token/secret variables stripped from its environment, so
  the worker's code the graders run (server.py, migrate.py, python -c
  imports) cannot write the results, edit the task modules or read the
  reference solutions. The child is non-dumpable after the drop; the verdict
  comes back to root over a pipe and root writes the results. A grader that
  is killed, hangs past BENCH_GRADE_LIMIT (900 s) or returns no verdict
  fails the task.
- The export copies regular files only: a FIFO or a socket in the task
  folder made copytree raise in the finally after grading and stopped the
  lane. Export errors are reported, never raised, and the result row is
  written before the export.
- A host lane with no BENCH_AGENT_USER prints a warning that it has none of
  this isolation.

validate2/validate3 pass with every grade going through the agent-user
path in the bench image (bash-rotate fails the same way as root, before and
after). Isolation tests pass in the image (5/5) and on the host (3, 2
skipped).
@solvyxtech

Copy link
Copy Markdown
Owner Author

Addressed in c943cb8 (engine) and c6e3e56 (bench), on top of c257d7a. Nothing under them changed. npm run check green with ~/.local/bin off PATH: 2056/2056 unit tests, e2e PASS, self-drive PASS, self-check PASS. pytest bench/local/tests: 30 passed, 2 skipped on the host. isolation-in-container.sh: 5/5 in the bench image.

Blocking

  1. The no-progress guard ended legitimate runs. Fixed by changing what counts as progress, not by narrowing where the guard runs. A call advances when its (call, result) pair is new for the turn: a file read for the first time, a command run for the first time, or a command or read whose output differs from every earlier time it was made. Durations, clock times and job ids are normalised away, so a timed test run doesn't look new each time. A file change in the project still resets the count. Maat's own pointers ("already shown", "same call as step N"), malformed-call complaints and outside-task refusals never count as progress. So only a run of calls that keep coming back with the same answer stops a run.
    • Your reproductions are tests in test/no-probing.test.ts, with N=3, so 6 non-advancing calls would stop the run. None of these is nudged or stopped, and each ends verified:
      • builds into dist/, .venv/, node_modules/, build/, target/ and out/;
      • eight edits to a config outside the project that the task names;
      • the same command with changing output;
      • first reads of eight new files.
    • These are stopped: the same command with the same output (only the timing differs), and refused reads of 12 different outside paths.
    • I kept the guard on for every unattended HTTP run instead of gating it on scoped(). With this definition it only fires on a genuine loop, which wastes budget whether or not there are hidden checks. That is now written in the CHANGELOG, with MAAT_NO_PROGRESS_CALLS (default 30, 0 = off).
  2. Grading before stray agent processes were killed. Fixed. kill_agent_procs() (pkill -KILL -u agent) runs before grade, and again when the task finishes. There's a test for the order (agent, kill, grade).
  3. A FIFO or socket in the task folder aborted the bench. Fixed. The export's copytree copies regular files only (copy_function=_copy_regular). Export errors are reported to stderr and never raised. The result row is written before the export. A test leaves a FIFO and a bound Unix socket in both task folders: both tasks are recorded and pass, the regular files are exported, the special files are skipped, and no private folder is left behind.
  4. Graders ran the worker's code as root. Fixed in the containers.
    • run.grade(T, d) forks a child that does setgroups([])/setgid/setuid to the agent user. It also strips every env var whose name contains KEY/TOKEN/SECRET/PASSWORD/CREDENTIAL/AUTH, sets PR_SET_DUMPABLE 0 (also the default after setuid), then runs T.grade(d).
    • Everything the grader starts inherits that user. The verdict comes back to root over a pipe, and only root writes the results. Because the child is non-dumpable, the worker's code, though it runs as the same uid, can't ptrace it or read its fds.
    • A grader that is killed, runs past BENCH_GRADE_LIMIT (900 s) or returns no verdict fails the task. The agent user's processes are killed afterwards.
    • In-container test: a hostile server.py, run by the grader, tries to append a row to /root/.../iso.jsonl and read a *_API_KEY. It runs as uid 1001, the key is absent, and the results file holds exactly one real row.
    • I also ran validate2.py and validate3.py in the image with every grade going through this path. tasks3: 22/22 OK. tasks2: 14/15. bash-rotate fails identically as root without the change, so that one predates this PR.
    • I used a forked, de-privileged grader rather than a root-owned copy of the folder. Several graders need the worker's code to write next to itself (sqlite files, outputs), and a de-privileged process protects the results, the task modules and the reference solutions either way.

Should fix / nits

  • ACP backends: not covered yet. The guard counts calls in the HTTP step loop, and the code comment and the CHANGELOG now say it is HTTP-only. Counting inside the ACP runTool wrapper needs a way to end an ACP turn from there, so I've left it as a follow-up.
  • Case-insensitive disks: the .maat/ check now folds case on darwin/win32.
  • Refusal message: the outside-task refusal now says a scratch file made with bash can still be read with bash.
  • MAAT_NO_PROGRESS_CALLS: documented in the CHANGELOG, along with the scoped file tools, described as a budget control and not as isolation.
  • Host lanes without BENCH_AGENT_USER now print a warning that they have none of this isolation.

Not done

  • API keys readable via /proc/$PPID/environ during the run: non-blocking, and it needs --worker-user or a key-file handoff. During grading the keys are now stripped.
  • Renaming the API bench lane: I've left it until the owner confirms.
  • Uncommitted change in this worktree: bench/local/container/run-in-container.sh has an edit (ARMS matching api.anthropic.com) that I didn't make. It is not in these commits.

@solvyxtech

Copy link
Copy Markdown
Owner Author

Bench numbers audit (bench/local/), at c257d7a

This is a review of the bench harness itself: grading, isolation, result parsing and statistics. I'm leaving out the items that were already reported (grading before the kill, a FIFO crashing the copy, graders running worker code as root, Mac host-lane isolation, the worker sharing Maat's uid, and the API lane name).

How I tested: Docker isn't available on my Linux box. Instead I ran the real run.py as root, with an unprivileged agent user and the container layout (/opt/bench 700, /var/lib/bench-work 711, export and results under /root). A fake cli.js stood in for Maat and played the hostile worker. I also ran validate2.py/validate3.py and a grader fuzzer over all 42 tasks.

Blocking

B1. In container lanes, the four git tasks fail correct work every time. run.py:93 (hand_over chowns the folder to agent), then run.py:331 (root grades). container/Dockerfile:8 sets no safe.directory. Confidence: high, reproduced.

  • Git ≥2.35.2 refuses a repository owned by another user ("detected dubious ownership"). Bookworm ships 2.39, so every grader git call prints nothing.
  • fix-git, git-revert-one, git-split-lib-history and git-find-culprit all pass their reference solution when root owns the folder. After the chown -R agent that run.py does, all four fail ("master's about.md lacks the lost changes", "master is not checked out", …).
  • Impact: every container lane since the agent user landed (Sync local work: separate judge, OpenCode, bench container runner #23) under-counts pass by up to 4/42 tasks, and counts any "verified" on those tasks as false-done. That skews verified precision too. crosscheck.py regrades root-owned copies, so its numbers won't match lane numbers.
  • Fix: grade with safe.directory=<task dir>. Also switch off core.hooksPath and core.fsmonitor while grading, because today the ownership refusal is the only thing stopping root's git from running the worker's repo hooks. Better still, run grader git as the agent user.

B2. Graders that take the last stdout line "OK" can be passed without doing the task. tasks2.py:62-69 (py_check, used by refactor-pricing, perf-pairs, size-parse-bug, refactor-invoice, vendor-units, rename-api) and tasks3.py:2672 (async-pool-node). Confidence: high, reproduced.

  • The grader runs inside the same interpreter or Node process as the worker's module. A failing check prints its reason and calls sys.exit() / process.exit(), which exit 0, and then the worker's exit hook prints OK last.
  • Reproduced 4 for 4, with the task left unsolved:
    • size-parse-bug: the buggy file plus atexit.register(print,'OK').
    • refactor-pricing: same hook.
    • perf-pairs: print('OK',flush=True); os._exit(0) at import.
    • async-pool-node: process.on('exit',()=>console.log('OK')).
  • tasks.py:189 (duration-bug) is weaker still: it passes if "BAD []" appears anywhere in stdout.
  • Fix: the child process should only print the values it computed (JSON). The parent grader compares them with expected values, so the verdict is decided outside the worker's process. A nonce doesn't help, because in-process code can read it from __main__.

B3. Repeats and arms aren't independent: /tmp, /var/tmp, /dev/shm and ~agent carry over between tasks. run.py:126-143 (finish_task kills processes and removes the task folder, nothing else). Confidence: high, reproduced.

  • With 2 tasks × 2 reps, rep 1 of each task read the notes rep 0 had left in /tmp/.bench-notes and ~/.bench-notes.
  • Maat's own maat-check-* / ref-* copies of the finished project are left in /tmp when a run is SIGKILLed on timeout.
  • With ARMS, arm B runs right after arm A on the same task, so B can read A's solution. That biases every paired comparison toward B.
  • Fix: after each task, delete agent-owned entries in those directories and restore ~agent from a root-only snapshot taken before the first task, so the credentials survive. A fresh container per task would be stronger.

B4. Grader exceptions stop the whole lane, and the bad run gets a free retry. run.py:331, where T.grade is only wrapped in try/finally. Confidence: high, reproduced.

  • 18 of the 42 graders raise on cheap end states:
    • UnicodeDecodeError when the deliverable prints one non-UTF-8 byte (graders use text=True);
    • FileNotFoundError when the worker deletes test_dur.py or CHANGELOG.md;
    • uncaught TimeoutExpired in some.
  • The row is never written and run.py exits 1. On resume that (task, rep) runs again, so failures in this class are silently swapped for a new sample.
  • Fix: catch Exception and record passed=false with grader_error: true.

B5. Worker text can trigger the provider-cap stop. run.py:229 → provider_capped(out, …) matches "rate limit is reached until" anywhere in Maat's stdout. That includes tool event preview/args and model text. Confidence: high, reproduced.

  • A worker that runs cat app.log on a log containing the phrase, or just echoes it, prints STOPPED. The row isn't recorded, later tasks don't run, and the exit code is 0.
  • Fix: match only Maat's kind:"error" events, non-JSON lines and stderr. Also consider a non-zero exit code for STOPPED.

B6. Rows don't record their lane, and resume mixes lanes. run.py:297-300 (resume key is (task, agent, rep, arm)) and run.py:335 (no model, url, reasoning, limit, judge or grader version in the row). container/run-in-container.sh:105 defaults RESULTS=results.jsonl for every lane. Confidence: high, reproduced.

  • I ran lane model-a, then lane model-b with the same RESULTS. The second lane ran 0 tasks, exited 0, and the file still looks like one lane.
  • Fix: add lane (model/url/reasoning/limit/gate), the per-arm judge/reference setup, and a hash of run.py+tasks*.py to every row. Refuse to append when the file holds another lane's rows. Also tolerate a cut-short last line on resume: right now one partial line crashes json.loads.

Non-blocking

N1. Root's grader follows symlinks into the reference solutions. Confidence: high on the mechanism, low that a model does this unprompted.

  • The agent can't read /opt/bench/reference_solutions. But root follows ln -s /opt/bench/reference_solutions/<task>/<file> <file>, and that passed 18 of 22 tasks3 graders with no work (scratch_copy at tasks2.py:47 copies with symlinks=True).
  • The path is guessable from ps//proc/1/cmdline (cd /opt/bench) plus the task name in the worker's own folder path.
  • Fix: before grading, fail any symlink that resolves into the bench dir, results, export or WORK. Running the worker's programs as the agent user also closes this.

N2. wc-tool's grader writes as root through a fixed /tmp/wc-grade. tasks.py:217. Confidence: high, reproduced.

  • The agent can pre-create that directory with a.txt as a symlink. I used /root/victim.txt, and root overwrote it with one two\nthree\n. In the container, the predictable /root/bench-results/results.jsonl would be a target.
  • Fix: use tempfile.mkdtemp().

N3. Graders with no timeout can hang a lane forever.

  • tasks.py:185/221, the tasks3.py:970 check.sh and FIFO reads in graders have no timeout, and run.py has no grade-level timeout either.
  • So a deliverable that loops, or an output that is a FIFO, hangs the lane. (This is a different failure from the reported FIFO crash in the copy step.)
  • Confidence: medium. Mechanism read and FIFO behaviour known; I didn't run a full hang end to end.

N4. scoreboard.py:101-104 pair_up double-counts when runs are pooled. Confidence: high, reproduced.

  • Its key is (task, agent, rep), and every run file starts at rep 0. Pooling two base files under one label (a documented feature) pairs each B row twice.
  • With 10 real pairs, I got "pairs 20, 16 discordant, p=0.000" when it should be 10 pairs, 8 discordant, p≈0.008.
  • Fix: add a run id and lane to the key, or refuse duplicate keys.

N5. Perf graders use wall-clock thresholds, but containers have no CPU or memory limits.

  • perf-pairs must finish in ≤5 s and perf-asof-join in 10 s, while lanes often run in parallel on the Mac. crosscheck.py caps memory and pids and turns the network off; the lanes do neither.
  • Fix: pin --cpus/--memory, and scale thresholds by a reference timing measured on the same box. Confidence: medium.

N6. Containers have open network egress. run-in-container.sh:102 runs the worker with full internet and host.docker.internal access. That adds variance (package installs, looking things up) and lets a worker exfiltrate. Confidence: high on the mechanism.

N7. Smaller issues (all high confidence):

  • refs2.py:164 uses BSD tail -r, so validate2.py fails bash-rotate on Linux. tac passes.
  • regrade.py:22 looks for WORK/<task>-<agent>-<rep>, but runs now export to BENCH_EXPORT with an -arm suffix. So it silently regrades nothing and reports "0 changed".
  • Stalled rows (providerStall) still count as fails in scoreboard.py. This was already raised on Keep the time budget whatever the backend does #26 and is still open.

Making the numbers statistically meaningful

  • Unit of analysis = task, not row. Reps of one task are correlated. The Wilson intervals and the sign test on (task, rep) pairs treat them as independent, which overstates confidence. Average reps within a task first, then bootstrap over tasks, or use a mixed or clustered model. For A/B comparisons, use a paired test on per-task pass fractions.
  • Power: at about 42 tasks, a 10-point pass-rate difference is near the noise floor. Report CIs on differences, not just verdicts, and pre-register the ship rule. diff >= -1 as an absolute count makes the rule's meaning depend on n.
  • Counterbalance arm order (ABBA or random per task) and record the order.
  • Record everything needed to reproduce a row: Maat build, model and endpoint, reasoning, limits, judge, grader hash, temperature/seed if the provider honours it, and the container image digest.
  • Report P(pass | verified) per tier with CIs, and report grader_error and stall rows separately. Don't fold them into pass or fail.

Proposed fix (not pushed)

I have a fix on a local grok/bench-grading-integrity branch that covers B1 and B3–B6, plus N1 and N2. It includes a new tests/test_grading_integrity.py with 11 tests, all passing as root with the agent user. The old code fails them, and pytest bench/local/tests passes 36. I re-ran every scenario above end to end against it: each was fixed.

The push needed approval and wasn't completed, so there's no PR. A grok/bench-grading-integrity ref exists on the remote but points at this PR's head (c257d7a), with no changes. B2 needs grader redesign and isn't in the fix.

Nothing merged.

Tyler Skelton added 2 commits October 7, 2026 19:18
… the lane

Fixes the blocking items of the PR #33 bench audit
(reports/grok-reviews/bench-audit-pr33.md) and the cheap non-blocking ones.

B1. Every grade runs under grading.git_guard: safe.directory scoped to the
task folder, core.hooksPath=/dev/null, core.fsmonitor=false, and no global
gitconfig (the agent user's to write). Root grading an agent-owned repo no
longer gets "dubious ownership" and an empty answer from every git call; the
container lanes grade as the agent user as before.

B2. No grader takes a verdict from the worker's process any more. py_check is
gone. The child only computes values and writes them as one JSON record on a
dedicated file descriptor (grading.child_values); the grader process compares
them with expected values that never enter the child. Rewritten: duration-bug,
refactor-pricing, perf-pairs (wall time of the whole child checked too),
size-parse-bug, refactor-invoice, vendor-units-workaround, rename-api-signature
and async-pool-node (the Node tests now only observe; the assertions run in
Python). Tuples stay distinct from lists across the channel. A second record,
none, or bad JSON fails the task. validate2/validate3: 37 tasks, 0 problems.

B3. In the containers, after every task (so every rep and every arm), every
entry the agent user owns in /tmp, /var/tmp and /dev/shm is removed and its
home is restored from a root-only snapshot taken before the first task. Arms
run in ABBA order, recorded as arm_pos.

B4. A grader that raises is a failed row with grader_error=true, never an
abort and a free retry on resume. A TimeoutExpired is an ordinary fail.
sh/run decode with errors="replace"; duration-bug and rename-api no longer
raise on a deleted test file or CHANGELOG.

B5. The provider-cap stop reads only Maat's error events, non-JSON lines and
stderr, never tool previews or model text. STOPPED exits 3.

B6. Every row carries lane (model, url without credentials, reasoning, limit,
gate, reference, MAAT_* settings, per-arm env with secrets left out, build,
grader hash of run.py+grading.py+tasks*.py, cpus, memory) and lane_id. A
results file holding another lane's rows, or rows with no lane, is refused.
A cut-short last line is moved to <file>.partial on resume.

Non-blocking: symlinks resolving into the bench, results, export or work
fail the task before grading (N1); wc-tool grades in a mkdtemp folder (N2);
timeouts on the wc/duration/check.sh subprocesses (N3); pair_up refuses
pooled files that repeat (task, agent, rep) instead of pairing twice (N4);
container lanes pin --cpus/--memory (BENCH_CPUS=2, BENCH_MEMORY=4g) (N5);
refs2 uses tac before BSD tail -r, regrade.py finds the export folders with
their -arm suffix and fails when it finds none, and stalled and grader_error
rows are counted apart from pass/fail in the scoreboard (N7).

Statistics: stats.per_task/task_rate/paired_tasks average repeats within a
task, bootstrap over tasks, and test A/B with a sign test and a sign-flip
permutation over tasks; the scoreboard prints both next to the row figures.

Tests: tests/test_grading_integrity.py, the reviewer's reproductions with
benign markers. The root-only ones (all four git tasks chowned to agent, the
real /tmp and ~agent) run in the image via tests/isolation-in-container.sh.
…ed graders

For every lane run today whose task folders are saved under
~/.cache/maat-bench/container-work/<name>/ and that is no longer running, the
rows of the four git tasks (B1) and of the eight tasks whose grader trusted
the worker's process (B2) are graded again inside maat-bench:agentu (network
off): with the new graders as the agent user, and with the old graders as root
on the same agent-owned copy and with only safe.directory set, so a change can
be put down to B1 or B2. results-<x>.regraded.jsonl is written next to each
results file; the originals are not touched. The report counts changed
verdicts per lane and lists every verified row that now fails and every
failed row that now passes.
@solvyxtech

Copy link
Copy Markdown
Owner Author

Reply to the bench audit (reports/grok-reviews/bench-audit-pr33.md, which audited c257d7a). The fixes are in b429785, and the regrade of today's lanes is in c79866b.

What was already fixed at the old head (b6c5ddc, before this push): c6e3e56 grades in a forked child that drops to the agent user. That already avoided B1's ownership refusal for container lanes started after it, and B4 for the agent-user path only. Everything below was still open.

Blocking

B1, git ownership. Fixed. Every grade runs under grading.git_guard, which sets safe.directory to the task folder only, core.hooksPath=/dev/null and core.fsmonitor=false, and stops git reading a global gitconfig (GIT_CONFIG_GLOBAL=/dev/null, since ~agent/.gitconfig is the worker's to write). All of these are in command scope, so the repo's own config can't override them. Container lanes also grade as the agent user, as they did before.
Tests:

  • In the image, as root: all four git tasks with the reference solution and chown -R agent. The old graders fail all four (reproduced), and they pass with the guard and as the agent user.
  • On any machine: the same check for fix-git and git-revert-one, using GIT_TEST_ASSUME_DIFFERENT_OWNER.
  • A repo whose core.fsmonitor touches a marker. Plain git status runs it; the grader doesn't.

B2, the verdict came from inside the worker's process. Fixed. py_check is gone. The child process only computes values and writes them as one JSON record on a dedicated file descriptor (grading.child_values). The grader compares them with expected values that never enter the child: the reference implementations run in the grader's own process. Getting a pass now means producing the right values.

  • If the channel holds no record, a second record, or anything that isn't JSON, the task fails. emit exits with os._exit, so atexit hooks can't add a record after it.
  • Tuples stay distinct from lists, so no comparison got looser.
  • perf-pairs also checks the wall time of the whole child, so faking time.time() gains nothing.
  • Rewritten: duration-bug (the "BAD []" substring check), refactor-pricing, perf-pairs, size-parse-bug, refactor-invoice, vendor-units-workaround, rename-api-signature, and async-pool-node. The Node tests now only record what mapLimit did, and the assertions run in Python.
  • validate2.py: 15/15 pass. validate3.py: 22/22 pass, including all the deliberately wrong solutions.
  • Tests are your reproductions with benign markers:
    • atexit.register(print,'OK') on size-parse-bug and refactor-pricing, plus the unfixed vendor-units, refactor-invoice and rename-api;
    • print('OK'); os._exit(0) at import on perf-pairs;
    • process.on('exit', ...) on async-pool;
    • print('BAD []') on duration-bug;
    • a forged record written to the channel's fd at import.
      All of them fail.

B3, state carried over between runs. Fixed (run.Scrubber). After every task, so after every rep and every arm:

  • Every entry the agent user owns in /tmp, /var/tmp and /dev/shm is removed. The scrub only descends into world-writable folders, never follows links, and leaves root's files alone.
  • ~agent is restored from a root-only snapshot taken before the first task. Credentials survive, and notes or .maat state don't.
  • Arms now run in ABBA order, recorded as arm_pos.

Tests: your reproduction, 2 tasks × 2 reps × 2 arms with notes in /tmp and ~ (no run sees an earlier one's notes, and the credential file comes back unchanged), plus the real /tmp, /var/tmp, /dev/shm and ~agent as root in the image. An end-to-end run.py in the image with a stub Maat confirmed the same.

B4, a grader exception aborted the lane. Fixed. grading.safe_grade records passed=false, grader_error=true, the lane carries on, and a resume doesn't re-run that row. A TimeoutExpired (the worker's program hanging) is an ordinary fail. Some graders no longer raise at all on cheap end states:

  • sh/run decode with errors="replace";
  • duration-bug and rename-api handle a deleted test file or CHANGELOG.

The scoreboard reports grader_error rows separately and leaves them out of pass/fail.

B5, worker text triggered the cap stop. Fixed. provider_capped now reads only Maat's kind:"error" events, non-JSON lines and stderr. STOPPED exits 3. It came close for real: a worker in mmhj-1/git-revert-one read Maat's own engine.js, and the read_file preview included the source of that message. The template has ${untilText(...)} where the phrase has "until", so it didn't match.

B6, rows didn't record their lane. Fixed.

  • Every row now carries lane and lane_id. lane holds: model, url (credentials stripped), reasoning, limit, gate, reference, every MAAT_* setting, each arm's env with secrets removed (so the judge is included), build, a hash of run.py, grading.py and tasks*.py, the agent user, and cpus/memory.
  • run.py refuses a results file that holds another lane's rows, or rows with no lane, and exits non-zero.
  • A last line cut short is moved to <file>.partial and the resume carries on.

Non-blocking

  • N1 Fixed. Before any grader runs, a symlink that resolves into the bench folder, results, export or work fails the task. A link to something inside the task folder is fine.
  • N2 Fixed. wc-tool grades in a fresh mkdtemp folder.
  • N3 Partly fixed. Timeouts added on the wc, duration and check.sh/git archive subprocesses; the container path already had BENCH_GRADE_LIMIT. Host lanes still have no grade-level timeout.
  • N4 Fixed. pair_up raises Ambiguous on pooled files that repeat a (task, agent, rep), and the compare falls back to the by-task test.
  • N5 Fixed. Container lanes pin --cpus 2 --memory 4g (BENCH_CPUS/BENCH_MEMORY), and both are recorded in the lane. Thresholds aren't scaled to a reference timing yet.
  • N6 Not changed. The worker needs the model endpoint, so closing egress needs an allow-listing proxy.
  • N7 Fixed. refs2 uses tac before BSD tail -r. regrade.py finds <export>/<task>-<agent>-<rep>[-<arm>] and exits non-zero when it finds nothing. Stalled rows are counted apart from pass/fail.

Statistics. stats.per_task / task_rate / paired_tasks average repeats within each task, bootstrap over tasks, and run a sign test plus a sign-flip permutation test over tasks. The scoreboard prints these next to the row figures. The ship rule itself is unchanged (diff >= -1 rows).

Regrade of today's lanes (bench/local/regrade_today.py)

25 finished lanes and 212 rows: the git tasks plus the 8 tasks whose verdict came from inside the worker's process. Each row was regraded from its saved folder inside maat-bench:agentu with the network off. The output is written to results-*.regraded.jsonl; the originals are untouched.

lane regraded changed fail→pass pass→fail verified now wrong false-done now right pass before → after
ab-screen-disc2 6 2 2 0 0 0 18 → 20
ab-screen-indep 6 2 2 0 0 1 17 → 19
ab-screen-mm3-disc2 6 2 2 0 0 0 14 → 16
ab-screen-mm3-indep 6 1 1 0 0 0 12 → 13
haiku55-big 36 12 12 0 0 0 111 → 123
mistral-l4-probe 6 2 2 0 0 1 17 → 19
mm3-haikujudge 6 2 2 0 0 0 17 → 19
18 other lanes 140 0 0 0 0 0 unchanged
all 212 23 23 0 0 2 601 → 624
  • No "verified" verdict turned out wrong.
  • 23 recorded fails were correct work, and every one is B1: the git tasks failed because of the ownership refusal.
    • Two of them were "verified (independent checks)" rows that had been counted as false-done: ab-screen-indep git-revert-one and mistral-l4-probe git-revert-one.
    • haiku55-big lost all 12 of its git rows (4 tasks × 3 reps).
  • B2 changed no verdict. The old graders with only the ownership fix agree with the new graders on 212 of 212 rows, so no saved tree forged an "OK" and the rewrite didn't regress any honest verdict.
  • Not regraded:
    • results-dsv4p-big.jsonl: its lanes dsbig-1..4 were still running.
    • priv1–4: smoke lanes with no results file named in their logs.
    • 4 grok-oc rows whose folders weren't saved.

@solvyxtech

Copy link
Copy Markdown
Owner Author

Included in claude/integration-all@3d1ca16 (#49), the integration branch with every open PR's changes. That branch is now the standard build and supersedes this one. The conflict choices are listed in #49.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude Owned by claude

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant