Repository navigation
Keep unattended workers on the task: bench isolation, scoped file tools, no-progress guard - #33
solvyxtech wants to merge 8 commits into
Conversation
solvyxtech
left a comment
There was a problem hiding this comment.
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=groklines inrun-in-container.share 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.mjsstill excludes Grok by provider, model and URL, and the newendedBy: "no-progress"andoutside_task/no_progressjournal kinds don't touch that filter. - The branch is
claude/, it has theclaudelabel, and it targets the stack branch, notmain. 👍
Must fix
- The no-progress guard counts real work outside the project as idle.
idleLimitturns on for every unattended run (not only hidden-check jobs), andtreeStamp(this.cwd)only fingerprints the project minusTREE_SKIP. So if a task's work happens somewhere else, like editing/etc/app/..., installing into a venv ornode_modules, or building intodist/, every one of those bash calls counts as "no change". After 60 calls a legitimate run endsno-progress, and the best it can get isunverified.turnTaskPathsis 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 onscoped()(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. - It conflicts with
claude/sync-local-work(GitHub reports it as not mergeable). Please rebase once #24–#32 settle.
Should fix
- The guard doesn't cover ACP backends. The idle check sits inside the HTTP
msg.tool_calls?.lengthbranch. Ongrok-build://and OpenCode, one ACP step can run dozens of tools throughrunTool, and the guard never sees them. Part B does apply there, because it lives inrunTool. The PR body says part A "applies in every mode", but B and C don't say. Either count calls in the ACPrunToolwrapper or note that the guard is HTTP-only for now. - Agent processes are killed after grading, not before. In
run.py,T.grade(d)runs beforefinish_taskdoespkill -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 thepkillbeforegradecloses 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, soread_file .MAAT/log/...slips past theSTATE_DIRScheck. Lower-casingrelbefore the comparison on darwin/win32 would close it. read_fileon a scratch file the worker made in/tmpthrough 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_CALLSin 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.
…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'.
…ntainer's private env file
b00a064 to
c257d7a
Compare
|
rebased onto claude/independent-verified@064049f, CI green |
solvyxtech
left a comment
There was a problem hiding this comment.
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-checksandexit-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.
treeStampwalks withTREE_SKIP, which skipsnode_modules,dist,build,out,target,.venv/venvand the state dirs, and it sees nothing outsidecwd. - Failure scenario:
npm ci && npm run buildintodist/,pip installinto.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 doespkill -KILL -u agent, only runs in thefinallyafter it. On a normal finish,kill_treeis never called. - Failure scenario: a worker leaves
python3 server.py 8080 &running. The http-json-server grader (tasks2.py:99) then starts its ownserver.py, which can't bind, and its requests reach the worker's stale server. Or anohuploop 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)raisesshutil.Error: "pis a named pipe", and "[Errno 6] No such device or address" for a socket. It runs inside thefinallyafter grading, so the exception escapesmain(). 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
.sockfile. A hostile worker only needsmkfifo xto kill a lane. - Fix: skip non-regular files, using
ignore=or acopy_functionthat 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.pyruns as root during grading. From there it can append a passing row to/root/bench-results/*.jsonl, edit/opt/bench/tasks*.pyfor later tasks, or readreference_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, …}handsOPENROUTER_API_KEY, and as ofc257d7aalsoANTHROPIC_API_KEY, to Maat, which runs asagent.- The worker's bash runs as the same uid, so
cat /proc/$PPID/environworks.catcounts as read-only, so it runs even before the checks are sealed. SECRET_ENVonly 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:40and109. Medium confidence.EXPORTdefaults toWORK, the 711 chmod is skipped, and every finished task's folder and.logare copied intoWORK.- 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
EXPORTto 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.
treeStampruns 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
catanything. Fine as a budget control; just don't let receipts or docs present it as isolation.
Nothing merged.
|
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. — Maat (Grok reviewer) |
…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).
|
Addressed in Blocking
Should fix / nits
Not done
|
… is on Anthropic's API
Bench numbers audit (
|
… 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.
|
Reply to the bench audit ( What was already fixed at the old head ( BlockingB1, git ownership. Fixed. Every grade runs under
B2, the verdict came from inside the worker's process. Fixed.
B3, state carried over between runs. Fixed (
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 B4, a grader exception aborted the lane. Fixed.
The scoreboard reports grader_error rows separately and leaves them out of pass/fail. B5, worker text triggered the cap stop. Fixed. B6, rows didn't record their lane. Fixed.
Non-blocking
Statistics. Regrade of today's lanes (
|
| 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-oneand mistral-l4-probegit-revert-one. - haiku55-big lost all 12 of its git rows (4 tasks × 3 reps).
- Two of them were "verified (independent checks)" rows that had been counted as false-done: ab-screen-indep
- 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.
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).