Repository navigation
Integration: all 2026-10-07 fixes - #49
Conversation
…ner runner Squash of the local work since 2026-10-05 onto main (after PRs #14-#22): - --judge / --judge-url and MAAT_JUDGE_MODEL / MAAT_JUDGE_URL: checks and reviews run on a separate model; receipts name the judge. Short names `grok` and `opencode`; removed backends are refused at parse time. - OpenCode as an ACP worker and judge (opencode://subscription). - Check quality: lint + redraft, edge checks, late-seal and lazy-seal fixes, leftover-files nudge, reveal/bend guard, provider-error retries, lenient tool-argument JSON, provider-cap stop, local speed profile. - Grok Build fixes: seal gate inside the subprocess, late-join wait. - Bench: container runner with the answer-key lock (agent runs unprivileged), paired arms, 42 local tasks with reference solutions, analysis scripts. - finetune/extract.mjs excludes runs with a Grok worker or judge (xAI AUP).
- The OpenCode backend runs only OpenCode Zen models (opencode/...). Any other model id is refused at parse time (--model, --judge, MAAT_JUDGE_*) and again at run time before the CLI is spawned, for worker and judge. The config handed to the CLI enables only the opencode provider, other providers' credentials are scrubbed from its environment, and with no model named Big Pickle is asked for rather than OpenCode's own default. - opencode://subscription is now opencode://zen (short name opencode); the old URL still works for this release with a deprecation notice. - Bench container: only the opencode entry of auth.json is mounted. - docs/provider-terms.md: OpenCode Zen section (free tier OK as worker, judge and fine-tune data, per the owner). - Competitor comparisons in docs and comments cut to neutral text. - Raw Grok output (bench/local/ambiguity-results) removed and gitignored.
A claim of done waited a fixed 120 s for checks still being drafted. A separate judge drafting for 170-300 s (a local model past 6 min) finished after that, and correct work was judged without task checks and labelled plain unverified. Before waiting longer, the drafts had to be independent of the work, and they were not: every drafter stage re-read the work folder. A redraft or cover step running after the cut saw the worker's output (a task naming out.txt got "out.txt ... first line: 3" in its prompt), and the second draft attempt re-read scripts, commands and lessons live. - The drafter's inputs (project view, tree, commands, scripts, lessons) are frozen once before the first step and used by every stage: first draft, lint redraft, critic, cover step, redraft, and the second try. - drafterInputsHash covers that snapshot. Each stage reports the hash of what it actually put in its prompt; the join compares them with the turn-start hash and refuses drafts built from anything else. - A subprocess (ACP) drafter starts in an empty temp folder, not the work. - With frozen inputs and a time budget, a claim waits for late drafts until what is left less max(60 s, 15% of the budget), never less than before. No budget, or inputs that differ: the old wait. Only after a claim; the deadline without a claim is unchanged. - Late checks join the same seal and bar; a failing one refuses the claim. Journal and info lines say how long it waited and whether the drafts arrived.
…eout test On Node 22 AbortSignal.timeout's timer does not hold the event loop open and the mock stream has no socket, so the loop emptied before the 30 ms timeout and the runner cancelled the rest of local-ask.test.ts. That is why check was red on every PR in the stack, #23 included.
…past a 600 s limit
A bench run given --for 540s on Grok Build logged job_end durationMs 3569961: one ACP prompt turn hung for an hour and nothing bounded the wait for its reply. - Every wait on an ACP agent (session start, session/prompt, the closing summary) is bounded by the turn's deadline. At the deadline Maat sends session/cancel, gives the agent 500 ms, then ends its whole process tree: backends now lead their own process group (src/proctree.ts), SIGTERM then SIGKILL, and any group left at exit is killed. - The job then ends with the normal deadline outcome: the sealed bar judges the tree on disk, and a pass there stays unverified without a claim (7abf0fa). - Stall watchdog: an ACP agent that sends nothing for MAAT_BACKEND_STALL_MS (default 5 min, 0 = off; Maat's own tool time does not count) is a provider stall. Its turn is cancelled, the journal records an error with providerIssue/providerStall, and job_end carries providerStall: a provider issue, not a task failure. - The same pattern elsewhere: the HTTP closing summary had no deadline at all; it now gets a short grace past the budget (a tenth of it, 1-30 s). askModel takes deadlineAt and cuts each ask, retry and overload pause at the budget; review, arbiter, criteria drafting and the reference draft pass it. OpenCode asks and the auth probe are bounded and kill their process trees. - bench/local/run.py records providerStall from job_end.
…the reference Backends lead their own process group, so Ctrl-C, a hangup or a harness's SIGTERM to Maat no longer reached them, and Node runs no exit handlers on a signal nobody listens for: the agent and its helpers were orphaned. proctree now installs SIGINT/SIGTERM/SIGHUP listeners where none exist (as background.ts does), ends every tracked group and exits 130/143/129. - A leader that exits gets the same SIGTERM-then-SIGKILL for its group as killTree, instead of an immediate SIGKILL, and keeps killTree's timer. - The criteria drafter and the reference draft share one deadline taken when the job starts them, instead of each taking its own Date.now(). - Doc comments put back above deadlineGraceMs/criteriaWaitMs and send().
…ools are not a stall - A deadline or stall closed the agent but left the bash/write_file it was waiting on running, so the tree went on changing while it was judged. The interrupt now aborts the in-flight tool call (the per-call controller the engine already uses for Ctrl-C), waits up to 5 s for it to end, and refuses any tool call that arrives after it. - The stall clock excused only Maat's own tool time. A builtin the agent announced and has not finished (GROK_OWN_TOOLS running a test suite) is now excused the same way; the deadline still bounds it. - Test helper: a zombie counts as gone.
A worker refused once grepped the filesystem for the check names it was shown and found every sealed command in the refused receipt, the journal's bar_run entries and Maat's copy of its own grep output under .maat/out/. - While a job runs, every receipt, journal entry, out/ copy, capture and printed event masks hidden check commands (and the reference program). The text is held in memory and released when the work is over: the journal records each command beside the seal published before the work, and each masked receipt gets a full twin under receipts/full/, bound in the integrity ledger. Receipts on disk are never rewritten. - A passing hidden check's evidence line names the check instead of quoting it, so a truncated table cell cannot leak a prefix. - Seal-time lint L15 now catches checks that change the work (git checkout/merge/commit/reset/stash/add/rm, rm, mv, sed -i, tee or redirects into the project, package installs), always on, with no task wording exemption. Writes to mktemp dirs, /tmp and /dev/null pass. - Task checks and their preflight run in a throwaway copy of the tree, .git included (clonefile where available, dependency dirs linked, large object stores shared via alternates). About 5 ms on a bench task tree; over 20k files or 512 MB, or a .git pointer file, runs in place.
…nt nested shells - The fixture repo sets its own git identity: the mutating-check tests committed with the ambient one and failed on a machine without a global user.email. - A failing hidden check's output is masked of its own command before it is truncated; a receipt's table cell is masked before it is cut to 90 characters and its pipes escaped; a retired check's journalled output is masked before its last 500 characters are taken. maskText also matches a command written with escaped pipes. - A task check that got no throwaway copy (worktree .git pointer, over the size limits, no temp dir) carries ranInPlace with the reason, shown in the receipt and in the journal's bar_run. - The scratch header now says what the copy does not cover: linked dependency folders are the real ones. - The L15 lint reads bash/sh -c payloads and eval arguments as commands of their own; input redirects are no longer read as operands, so 'xargs rm < files.txt' says it deletes its input.
…ad of linking them - copyTree was synchronous (readdirSync/copyFileSync/utimesSync) on the event loop: on a filesystem without reflinks a large tree froze timers, Ctrl-C and the deadline for the length of the copy. It now uses fs/promises throughout. - Every folder named like a dependency folder was linked, so a check that wrote into node_modules, target, .venv or .cache changed the real tree, and so did a source folder that only happens to be called target. Only one over 2,000 files or 32 MB is linked now; a smaller one is copied. The copy lists what it linked. - The scratch header says plainly what the copy does not cover: linked folders and symlinks already in the tree write through.
…ry ended a 2.5k-token run as too large
… every request after one broken call
Measured 2026-10-07 (Grok worker, qwen3-coder-30b-a3b judge, 20 tasks): 18 runs passed the hidden grader but only 2 were verified, and in 5 of the 16 unverified passes the review's objection was false (a misread right-aligned column, an invented email rule, "tests were failing but now pass"). A weak reviewer's prose vetoed correct work. With MAAT_REVIEW_EXECUTABLE=1 or --review-executable, every objection must carry a read-only shell command that demonstrates it: the command exits non-zero, or prints the offending line it names in "shows". Maat lints it with L15 (a command that would change the work is never run), runs it on a throwaway copy of the tree, and counts the objection only when it ran and showed the failure. A missing command, a lint failure, a command that passes, cannot open its input, or times out is recorded as an unsubstantiated objection and vetoes nothing. The grounded-quote rule, vote counting and tier rules are unchanged: this can only remove a contradiction, never make a check assert a value. Objections with their command and result go to the journal's review entry, job_end, an info line each, and the receipt index row; the nudge to the worker is masked while hidden check commands are withheld. With the option off the prompt, the review and every record are as before.
…nds an object
- capture() recorded transcript.wire(), which now holds repaired
arguments, so a model's broken JSON showed up as {} in the training
record. wire() takes repairArgs: false and the capture uses it; the
repair stays on the request path only.
- Valid JSON that is not an object went out unchanged ([] / 42 / "x"),
which the engine refuses and items-iterating chat templates fail on.
Blank and null now go as {}, anything else that is not an object as
{"_unparsed": "<what the model wrote>"}, which is also what an
unreadable payload now gets instead of {}: the next turn sees its own
mistake, not a call that seems to have sent nothing.
copyTree returns null for a worktree's .git pointer, a tree over the copy limits, or no temp dir, and runObjection then ran the reviewer's command in the project itself. A reviewer's command is written by a model that read the task text, so it now fails closed: with no throwaway copy (or MAAT_CHECK_COPY=0) the command is not run and the objection is did-not-run, "could not be run safely", not counted. - Unsubstantiated-objection info lines are masked of hidden commands. - The help and CHANGELOG say a non-zero exit shows the command failed, not that it tested what the objection says. - A test pins one reply format in the executable review prompt.
…in place git's detached maintenance and anything else running in the tree can remove a file between readdir and copy. That failed the whole copy, and the check then ran on the work. ENOENT/ENOTDIR on a listed path now means the path is no longer in the tree. The byte-identical tests assert the check got a copy and, if the tree changed anyway, list what changed. The fixture repo turns off gc.auto and maintenance.auto. CI saw the bar variant fail once (the real tree changed while the check passed), and it failed in 2 of 15 local runs under load before this change; the cause is not confirmed.
…ws the window
GMI Cloud serves qwen/qwen3-235b-a22b-2507 with a 131072-token total
window. The OpenAI-shaped request carried no max_tokens, so the router
asked for the model's whole listed output (131072), and even a
2,454-token prompt was refused. contextOverflow then shed history, which
cannot fix a completion overflow, and 11 of 34 Qwen runs ended "too large
and nothing left to shed".
- completionOverflow reads the window, input and completion counts from
the vLLM/OpenAI wordings ("You requested a total of X tokens: A tokens
from the input messages and B tokens for the completion", and "(A in
the messages, B in the completion)"). When lowering the completion
alone fits (window - input - margin >= 1024), max_tokens is lowered to
that and the same request is sent again, with no shed; the cap is kept
for the session. A prompt that is itself too big still sheds.
- The OpenAI-shaped request now always sends max_tokens: the default is
DEFAULT_MAX_TOKENS (32768), doubled up to 131072 only when a reply is
cut off at it; --max-tokens still wins.
- A provider that refuses the field itself (max_completion_tokens
models) gets requests without it after one retry. The output-ceiling
retry now runs on both request shapes and reads OpenAI's and
DeepSeek's wordings too.
- contextOverflow reads "maximum context length of W", and takes the
input count, not the total, as what the server counted.
…ay whether they were checked - runObjection ran the reviewer's command with Maat's whole environment, API keys included, and the worker can steer the reviewer through its claim. It now gets a credential-free environment (no *KEY/*TOKEN/ *SECRET/*PASSWORD names, no provider or Maat variables), Ctrl-C's signal, and is not run at all once the time budget is spent. - A bare false or exit 1 counted as demonstrated. A failure now counts only from a command that reads the work: names the project (.), a path in it or shaped like one, or runs its tests or build; otherwise the objection is off-the-work. A printed line that is in the command itself (echo) shows nothing either. - Each objection carries a standing: counted, refuted (the reviewer was wrong) or unchecked (it could not be run); the review counts the unchecked ones, and the info line says which it was.
The worker's tools ran as Maat's own uid, so whatever Maat could read the worker could read: the reference program in /tmp, a mission contract in the task folder, the judge's session store under the same HOME, hidden check commands in ps, Maat's memory, earlier runs' logs in /work. A Grok worker was seen grepping /, /opt, ~/.grok, Maat's dist and .maat/ for its checks. --worker-user <name> (MAAT_WORKER_USER), opt-in and Linux-first, for containers and unattended runs; Maat runs as root or with passwordless sudo: - bash, background jobs and an ACP worker agent (with every tool it runs itself) run as the worker uid/gid with a scrubbed environment, its own HOME and /tmp. - read/write/edit/list/grep/inspect are done by a helper process running as the worker (dist/fs-helper.js), so the kernel decides what they reach. - Maat's records (journal, receipts, integrity, out/, mission contract and state, reference and check temp dirs via TMPDIR) live in a 700 state dir outside the project (MAAT_STATE_DIR, default /var/lib/maat/<session> or ~/.local/state/maat/<session>) and are copied into .maat/ when the job ends. done.yml, bg/ and the mission library stay in the project. - Judge and ask subprocesses stay Maat's, with a HOME the worker cannot read (tightened to 700 or refused). Hidden checks run as Maat in the copy-on-run tree; files a check leaves in place are handed back. - With root and CAP_SYS_ADMIN the worker's commands share one PID namespace with a private /proc and no capabilities; otherwise Maat says ps is visible. docs/privilege-separation.md. Bench: the container runs Maat as root with --worker-user agent and --cap-add=SYS_ADMIN (BENCH_PRIVSEP=0 / BENCH_PIDNS=0 turn them off); judge credentials go to /root, /work is 711, logs are 600 and finished task folders are locked to root. A Linux CI job runs the root-only tests.
The first container run showed every git command of the worker failing with "getcwd() failed: Permission denied". nsenter --wd opens the directory before entering the mount namespace, so inside it the cwd is unreachable and getcwd() falls back to walking the parents, which the worker cannot read under /var/lib/bench-work (711). The worker now enters the directory by path inside the namespace. The test project sits under a 711 parent and runs git, so this cannot pass by luck again. /work is a host bind mount: on OrbStack chown is ignored and modes are not enforced for other users, so locking finished tasks there did nothing. Tasks run in a container-local /var/lib/bench-work (711, finished folders and logs root-only) and are copied to /work (BENCH_EXPORT) when run.py ends.
On the container's own filesystem the task folder really is owned by the agent user, and the root graders' git refused it as dubious ownership, so fix-git graded a correct merge as a failure. The old /work bind mount ignored chown, which hid this. run.py sets safe.directory for itself and the graders through GIT_CONFIG_*; Maat strips it from the worker.
- Background job logs are created by the worker: its own shell makes .maat/bg and opens <id>.log, so a planted symlink only redirects the worker's own write. Maat no longer opens or chowns anything there. - handBack walks by descriptor (/proc/self/fd/<fd>/<name> with O_NOFOLLOW|O_DIRECTORY, fchown on the open fd), checks the project is the same folder by device and inode, skips anything that moved out of it, multiply-linked files, symlinks, special files and other file systems. - seed reads the project's .maat/ through the file helper as the worker (fs-helper pack) and gets back only regular files and folders; the mission contract is removed by the worker too. Symlinks are skipped and reported, so none can appear in the state dir. - publish reads Maat's state dir and has the helper write the copies as the worker (fs-helper unpack), so Maat writes nothing into the worker's tree. - ACP specs name the worker agent's own credential (OpenCode: OPENCODE_API_KEY), which alone passes the worker env scrub for that agent. - CI runs on pull requests into claude/** as well as main. - Root-only tests: a planted .maat/bg log link, a folder flipped to a symlink during handBack, a hard link, seed with symlinked records.
- Maat no longer sets safe.directory. Its own git calls in the project (snapshots, commits, ls-files, the local exclude file, brief probes) run as the worker under --worker-user, so the repository's config is only ever read with the worker's permissions. Maat's own git keeps refusing a repository another user owns. - bench run.py: graders run on a root-owned copy of the task folder with the repository config reduced to its format lines, hooks and attributes removed, special files skipped, and git started without system or global config and with hooks off. The agent user's processes are stopped first. - Tests: root container test that the worker's repository config runs, if at all, as the worker and that root git refuses the repository; bench tests that marker-writing config and hooks leave no marker on the copy.
- --check-user <name> (MAAT_CHECK_USER), with --worker-user: hidden checks, drafted and --criterion task checks, the reference check's try, mission assertions and pre-work criterion tries run as that account in their copy of the tree. Environment is an allowlist: PATH, a fresh empty HOME per run, LANG, TERM. As root with CAP_SYS_ADMIN each run is the init of its own PID namespace with its own /proc and an empty tmpfs on /tmp, /var/tmp and /dev/shm, dropped to the account with setpriv --no-new-privs and an empty bounding set. - Copies and reference-check files live in <state dir>.check (Maat, group the check account, 710): copies are handed to the check account, the reference check's files are group-readable (750/640). The worker cannot enter the folder. - Checks that run in the project itself (project checks, task checks too big to copy) and mutation runs run as the worker. - The check account must not be root, Maat or the worker, and its group must not contain the worker. - --worker-strict refuses to start (exit 2) unless the worker account, the check account and the PID namespace can all be set up. Without it Maat carries on, and every receipt and the journal carry one line with the isolation in effect, e.g. "isolation: worker uid 1001, check uid 1002, pid namespace on". - Docs: the check account, strict mode, and a Known limitations section. - Tests (root, Linux): a check runs with the check uid and no provider or Maat variables; a project check runs as the worker; a mission assertion runs as the check account; the worker cannot read or enter the reference check folder while the check account can; the isolation line is in every receipt and once in the journal; --worker-strict refuses with no PID namespace (library and CLI); a check account equal to the worker is refused; with CAP_SYS_ADMIN the check sees its own /proc and an empty /tmp.
…tests The container gets --cap-add=SYS_ADMIN (and AppArmor unconfined, whose Docker default denies mount even with the capability), and MAAT_TEST_REQUIRE_PIDNS=1 makes the PID namespace assertions fail there instead of skipping.
- The bench image adds `checker` (uid 1002, own group, no home) beside `agent`; image tags move to :agentcu so older images without it are not reused. run.py passes --check-user checker (BENCH_CHECK_USER) when privilege separation is on. - crosscheck.py no longer sets safe.directory=*. The grader runs through run.grade_safely and each check on a copy from run.sanitized_copy (the helper grading_copy now uses), so a tree's planted git config never runs. - Test: a hostile repository's fsmonitor, filters, hooks, pager, alias and include fire nothing during a crosscheck grade and check run, and no git config is put in the environment.
The job container's HOME is /github/home, a runner bind mount readable by the test's worker user and not owned by root, which Maat refuses as the judge's HOME. Every root test in the job was cancelled by that refusal.
A run was labelled verified on checks the worker model drafted for itself. On the 2026-10-06/07 container bench, self-judged arms were right in 125 of 174 verified claims (72%) and separate-judge arms in 48 of 55 (87%); the wrong verifieds on the latest builds were all self-judged. Who wrote each task check is now recorded when it is sealed: the worker, a separate judge (--judge / MAAT_JUDGE_*), a person (done.yml, a mission contract, criteria approved in the window or given with --criterion), or the reference writer. A run is verified only when a passing check that ran the work and asserted a value came from someone other than the worker model, compared by provider-normalized id, or from a person. Otherwise the tier is passed-own-checks: outcome unverified, exit code 3. Claims read "verified (independent checks: <judge>)", "verified (your checks)" or "passed own checks (<worker>), not verified" on the terminal, the window, job_end's new claim field, the receipt index and the receipt. Receipts say who wrote each check. bench/local/run.py carries job_end's claim and checkAuthors; stats.py reads both the new and the old text.
…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.
solvyxtech
left a comment
There was a problem hiding this comment.
Grok review of #49 (integration build) at 3d1ca16
Verdict: changes requested. I'm posting this as a comment because this account opened the PR, so GitHub won't accept a formal "request changes". The blockers below come first. The conflict choices in the description (#40 over #38, #41's evidence cut, the #32 reference driver, #33's bench folders) look sound to me. Pairing rules otherwise hold: there are no subscription backends (Claude Code, agy and Gemini CLI appear only in "removed / never routed" notes and guard tests), finetune/extract.mjs still drops Grok output, and no real secrets show up in the diff (only dummy test keys). At review time privsep-linux passes and typecheck-and-test is still running.
Blocking
B1. By default, a root run on Linux permanently gives every root-owned file in the project to a shared maat-worker account and creates that account. Confidence: high.
src/cli.tsx:2336-2343and:2369-2373: when Maat runs as root with no--worker-user,defaultWorkerUser()is used and thenps.handBack()runs on the whole project.src/privsep.ts:1313-1319runsuseradd --system --create-home ... --shell /bin/sh maat-workerwith no prompt.src/privsep.ts:890-985:handBackgives every root-owned regular file and folder in the tree (up to 100k entries) to the worker. Nothing records the old owners or restores them when the job ends.publish()at:1019hands back again.- Failure scenario: an admin runs
sudo maat run "fix the nginx config"in/etc/nginx, or in/opt/appon a server. After the run, those files belong tomaat-workerfor good. Any later process running asmaat-workercan rewrite them, including the worker of an unrelated Maat job in a different project, because every default run shares that one account. The code comment atcli.tsx:2369assumes the project belongs to the run ("a container's, a CI checkout's"), but nothing checks that. - Why it matters: a hardening feature quietly widens write access on the host and leaves a new login-shell system account behind. It's also a new default added inside the integration PR itself rather than in any of the PRs it merges, so it hasn't had its own review.
- Suggested fix (any one): (a) turn on the default handover only where the run is clearly disposable, such as a container or CI marker, or a project folder Maat created; (b) record the original owners and restore them on
close()/exit, signals included; or (c) keep separation as the default but require--worker-user maat-worker(or an env opt-in) before Maat creates accounts or changes ownership. Please add a test that runs as root in a root-owned folder and shows ownership is unchanged afterwards, or restored.
B2. The api2 bench lane still names the provider in tracked files. Confidence: high that the names are there. Medium-high that this breaks the agreed rule, since the PR deliberately chose this.
bench/local/container/run-in-container.sh:80-82matches*api.anthropic.com*and writesANTHROPIC_API_KEY=into the env file.bench/local/run.py:591readsANTHROPIC_API_KEYwhen"api.anthropic.com" in URL.- Why it matters: the #33 blocker asked for the rename everywhere, environment variables included, with provider-specific details kept in untracked local config on the bench machine. The Keychain item (
maat-bench-api2) and the script (set-api2-key.sh) were renamed, but the host and the variable name are still in the repo. - Suggested fix: use a generic tracked interface such as
BENCH_API2_HOST(orBENCH_API2_URL), put the key in the container asMOLT_API_KEY/BENCH_API2_KEY, and do the provider-specific variable mapping in an untracked local file (for example a git-ignoredbench/local/.api2.env). Maat's own provider support can keep reading its usual variable. This is only about the bench lane.
Non-blocking
- N1. Bench scripts default to branches that will be deleted. Confidence: high.
bench/local/exact_replay.py:103(origin/claude/value-classifier),bench/local/value_replay.py:347(origin/claude/post-work-audit) andbench/local/regrade_today.py:45(OLD_REV = "b6c5ddc") will break or point at the wrong code once the stack branches are cleaned up. Use commit SHAs or make--basea required argument. - N2. Close #38 as superseded by #40 (and #37 by #41's cut) once this lands as the standard build, so the stack doesn't drift. Confidence: high.
- N3. The default fails open. When
useraddis missing or fails, the run continues as root with only a stderr notice. That's fine for interactive use, but for unattended runs consider marking the receipt or isolation line as "separation was the default but unavailable", so bench and CI results show it. Confidence: medium. - N4. Merge process. The PR targets
mainand includes #23. Fine as a standard build, since it says "do not merge yet". Merging tomainremains Tyler's call.
Nothing was merged or pushed by this review.
… worker are given back #49 review B1. Separation by default applies only as root on Linux in a container (/.dockerenv, /run/.containerenv, or PID 1's cgroup), in CI, or in a folder made for the run (MAAT_DISPOSABLE_PROJECT=1); elsewhere the run is as before and a notice suggests --worker-user. MAAT_ASSUME_CONTAINER=1/0 overrides the container check. The maat-worker account is made only inside a container, with no login shell. Every entry handBack gives the worker is recorded (device, inode, owner, mode) and given back on close, at exit and on SIGINT/SIGTERM/ SIGHUP, through the open entry, never by path; what the worker created stays its own. Tests run as root in node:22-bookworm: a run on a machine that is not disposable changes no ownership, a container run gives every handed file back, and so does a Maat killed by SIGTERM.
#49 review B2. bench/local/.api2.env (git-ignored) sets BENCH_API2_HOST and optionally BENCH_API2_KEYVAR; run-in-container.sh and run.py match the lane on that host. The key variable defaults to the provider's own documented name, which is what Maat reads.
|
Item by item. Fixed in claude/integration-all@7fabdd7. B1 (the default worker user takes over a host's files): fixed (2ad7626).
B2 (provider names in the bench lane): partly changed, by the owner's standing choice (7fabdd7). The lane's host and key variable now come from the untracked, git-ignored N1 (bench scripts defaulting to stack branches): not changed in this pass. Verification at 7fabdd7: |
Grok: status of tonight's open blockers at
|
| # | Item | Status | Evidence | Conf. |
|---|---|---|---|---|
| 1 | #32 builtin files-changed counted as a person's check |
fixed | tiers.ts:1251 personCheck needs kind:"command" and no session tag; :1537. Repro: builtin + worker-drafted value check → passed-own-checks |
high |
| 2 | #32 advisory mode prints "verified" when the review disagrees or doesn't run | fixed | tiers.ts:1571-1572 (reviewGap), :1296-1303 claimLabel, :1377 jobEndWords, used at cli.tsx:1553 and ui/app.ts:1143. Repro prints verified (independent checks: j), unconfirmed / , unreviewed |
high |
| 3 | #32 exit-3 change undocumented | fixed | CHANGELOG.md:30-45, README.md:413,421 |
high |
| 4 | #33 no-progress guard trips on dist/, .venv, edits outside the project |
fixed | engine.ts:6521-6523 + scope.ts:201 ProgressMeter: a new call or a new answer counts as progress. Tests no-probing.test.ts:270,283 |
high |
| 5 | #33 grading before the worker's leftover processes are killed | fixed | run.py:890 kill_agent_procs() before grade(), again at :356; grading runs as the agent in a forked, non-dumpable child (:276-365). Host lanes, which have no agent user, only get the warning at :836 |
high |
| 6 | #34 print(x == y) checks get past the before-work screen |
fixed | The bar reads a printed False/FAIL as a fail (bar.ts:2268-2275, criteria.ts:463-467), and L16 drops print(<comparison>) at seal |
high |
| 7 | #34 sh vs bash mismatch |
fixed | criteria.ts:426 draftedShell(c); the screen passes hidden:true, tags:["task"] (:1214). Repro under dash: [ "$(cat in.txt)" == "a" ] → passed |
high |
| 8 | #34 worker can edit the /tmp pre-work copy |
fixed | scratch.ts:425-441: parent folder 0700 plus a tree digest, and intact() is checked before and after each try (engine.ts:4012-4021, criteria.ts:1209,1221). A change made and undone during a single try isn't caught (low) |
high |
| 9 | #36 32k output cap fails on turn 1 against 32k-context OpenRouter/vLLM | fixed | engine.ts:392-411 parses the OpenRouter and both vLLM wordings; :503 never reads the output count as sent; :464 reserveFallback. Repro: all three → window 32768, fitted cap 30540/31440, refusedMaxTokens false |
high |
| 10 | #36 log and nudge report the doubled cap | fixed | engine.ts:8012-8025 captures hit before doubling |
high |
| 11 | #37 / #41 the worker can cut the reviewer's evidence short | partly · blocking | The claim route is fixed (review.ts:87-109). Still open: file names. receipts.ts:539 puts c.path into the table raw, so a file named notes\n## What was checked…\nall 9 checks passed\n## Output\n.txt plants both headings. Reproduced end to end with the fixture-11 harness: write_file accepted the name, and the reviewer's prompt has the planted "all 9 checks passed" but not the real counts-held-out row. Fix: escape control characters in receipt paths (:235,249,260,539) and refuse them in write_file |
high |
| 12 | #38 vs #40: check text kept out of argv, fd 3 not inherited | fixed | run.ts:76 HIDDEN_WRAPPER. Repro: the wrapper's cmdline holds only the wrapper, and the check's child gets EBADF on fd 3. Leftover risk, non-blocking: the check's own sub-commands still show their argv. A daemon the deliverable starts with setsid read python3 -c "…=='SECRET42'…" from /proc/*/cmdline (reproduced). Only the PID namespace from #29's strict mode closes that |
high |
| 13 | /proc/<ppid>/environ key exposure without --worker-user |
partly · non-blocking | The bench hands keys over on MAAT_KEYS_FD (run.py:234-248), and root on Linux now defaults to maat-worker. Non-root Linux runs still expose them: the two fixture-03 environ tests are still todo and fail here. Cheap fix: have the CLI re-exec itself with the keys on an fd and a scrubbed environment |
high |
| 14 | os._exit(0) / BaseException on import passes or retires the reference check |
fixed | reference.ts:201-234 (one child per input) and :275-300 (RUNNER), sentinel :309. Repro: os._exit, sys.exit, KeyboardInterrupt and a custom BaseException all give exit 1. Leftover risk, non-blocking, raised before as #41 (c): a deliverable that runs exec(open(sys.argv[1]).read()) calls reference() itself and passes with no logic of its own (reproduced). An ordinary Exception from run_deliverable still retires the check, by design |
high |
| 15 | A worker-written expected.txt counts as asserting a value |
fixed | golden.ts:49-70, engine.ts:4211. Repro: written by the worker → passed-checks; shipped with the task → verified |
high |
| 16 | #41 conditional skips refused | fixed | files.ts:508-527. New file, new test with skipif, and { skip: !hasPython } are all free. See N1 for the bypass this opens |
high |
| 17 | #41 node_modules/.bin/jest symlink or in-place edit beats the runner check |
partly · blocking | control.ts:146-218 now catches both reported attacks. Two variants still get through plantedBins (reproduced): (a) edit jest/bin/jest.js in place, then touch -d 1985…, which passes because its package declares that bin (:210-217); (b) edit a module the bin requires (jest/lib/run.js), since only the link target is checked. Either way the runner exits 0 on wrong work. Fix: hash node_modules files a script's bin resolves into at turn start, or compare against the lockfile's integrity values |
high |
| 18 | #42 audit overrules a failing check the judge sealed before the work | fixed | engine.ts:5115-5119 (independentFailing, :4169-4178); test post-work-audit.test.ts:248 |
high |
| 19 | #42 --help "usage" output leaks answers to the judge |
partly · blocking with --post-work-audit |
The reported "disk usage" case is fixed (interface-view.ts:156-162 line-start usage:; :245-250 must differ from a plain run). A worker can still choose the text on purpose: a script that special-cases --help, or has a usage: string in its source (:142-144 signature extractor), gets any text it likes, answers included, to the judge (reproduced). Opt-in feature only |
high |
| 20 | #43 /app rewritten inside grep/jq patterns |
fixed | scratch.ts:121. Repro: grep -q 'root /app/public;' /app/nginx.conf → only the file operand is rewritten. Non-blocking: a literal inside python3 -c code (== '/app/data\n') is still rewritten |
high / med |
| 21 | #44 ! cmd | grep -q X counted as a value check |
fixed | assertsValue false for ! and for && exit 1 |
high |
| 22 | #44 is not None / not in counted |
fixed | Both now untagged | high |
| 23 | #44 Python checks that can't fail | partly · blocking | cannotFail (checklint.ts:404) now drops the tuple assert, or True, if False else True, -O/PYTHONOPTIMIZE and print(cmp). Still open: a bare comparison statement, e.g. python3 -c "import tool; tool.f('a') == 'x'". It's tagged task,value (tiers.ts:555), lint returns nothing, it exits 1 before the work and 0 on wrong work, and tierOf with a judge author gives verified (independent checks: j) under the default rule (reproduced) |
high |
| 24 | #45 deadlock in the temperature retry on the single local slot | fixed | ask.ts:263-267 calls release() before retrying, and the release is idempotent (localgate.ts); test ask-temperature.test.ts:33 |
high |
| 25 | #45 provider naming in src/judge.ts |
partly · needs the owner's decision | Comments are neutral, but judge.ts:23 still hardcodes the provider's API host and key variable, and so does test/judge.test.ts:66. The diff against main adds provider names in about 20 files, a provider-specific source module among them. Whether product code may name a provider is still the owner's call |
high |
| 26 | #46 exact-value rule on by default | fixed | tiers.ts:1332-1336 (MAAT_REQUIRE_EXACT === "1"), :1545 |
high |
| 27 | #46 a crash whose error repeats the input passes an exact check | fixed (exact gate) | assertsExact false for … 2>&1 | grep -q '2024-06-13'. With the rule off (the default), it's still a value check and earns verified. That was true before #46 |
high |
| 28 | #46 named-constant ok=True dodge |
fixed (exact gate) | Not tagged exact. Same caveat as row 27 under the default |
high |
| 29 | #46 provider names in test/exact-values.test.ts |
fixed | None left. The commit messages are unchanged, and changing them means rewriting history, which is the owner's call | high |
| 30 | #46 uncapped 4x retry of cut-off replies | fixed | ask.ts:83-85,157: capped at 16k, so a 16k ask isn't retried |
high |
| 31 | #47 six-bad-calls stop recorded as a provider failure | fixed | engine.ts:6591-6600 endedBy:"malformed", wording at receipts.ts:586; test malformed-cap.test.ts:113 |
high |
| 32 | Bench: git tasks fail correct work in containers (ownership) | fixed | Grading runs as the agent (run.py:304-331). Repro as root: reference solutions in agent-owned folders pass 3/3 (git-revert-one, git-split-lib-history, git-find-culprit) |
high |
| 33 | Bench: graders that pass on a last line of OK |
fixed | Repro: 4/4 cheats from the audit (atexit OK, os._exit after OK) now fail |
high |
| 34 | Bench: /tmp and home not cleaned between repeats and arms |
fixed | run.py:374-449 Scrubber. Repro, 1 task × 2 reps × 2 arms with the worker as agent: every later run's seen.txt is 0 bytes, and nothing is left in /tmp or ~agent. ABBA arm order confirmed |
high |
| 35 | Bench: a crashing grader stops the lane | fixed | run.py:325-326, :892-897. Repro: invalid UTF-8 output gets a recorded row, and the next task runs |
high |
| 36 | Bench: "rate limit is reached until" in worker output stops the bench | fixed | run.py:537-564 (maat_said ignores tool previews and model text). Repro: the lane finished both tasks |
high |
| 37 | Bench: result rows missing model and endpoint | fixed | run.py:753-777 (lane_meta on every row, with model, url, grader hash, build), :847-850 refuses to mix lanes in one file. Repro rows carry all of them |
high |
New findings
- N1 (blocking, high, reproduced end to end). Renaming an existing test and adding an always-true conditional skip turns it off with nobody noticing.
files.ts:518treats a skip on a test name that wasn't there before as free, and:526countsskipIf(True, …)and{ skip: 1 }as conditional. Through the fixture-01 harness, withcount.pyleft buggy andtest_example→@unittest.skipIf(True, 'posix only')+def test_example_posix, the run endsverified (your checks). The same edit without the rename is refused. Fix: treat a literal-true condition (True,1,not 0) as unconditional, and don't count a test as new when its body matches a test removed in the same edit. - N2 (blocking on Linux, high).
missingDeliverabledoesn't understand dash's error from inside Keep check text and API keys away from the worker #40's wrapper. ThroughHIDDEN_WRAPPER'seval, dash printssh: 1: eval: ./backup.sh: not found, and the regex atcriteria.ts:331doesn't allow theeval:part. So on Linux, any check run undersh(person or--criterionchecks) of a script the work will create is dropped as "the command was not found" before the work starts. That's Stop dropping good drafted checks; keep pre-work passes as refuse-only guards #43's false-drop fix undone, and it's whyfalse-drops.test.ts:124fails on Linux while CI on macOS stays green. Fix: accept(?:eval:\s*)?there, and add a Linux job that runs the full suite. - N3 (non-blocking, medium). The no-progress guard now treats every new read as progress, so on its own it no longer stops the 2026-10-07 loop of a worker reading other runs' logs. Isolation now has to cover that, and it doesn't yet on host lanes (the Keep unattended workers on the task: bench isolation, scoped file tools, no-progress guard #33 todo).
Pairing rules: no subscription backends were added, and the fine-tune Grok exclusion is untouched. Provider naming is covered in row 25 and B2. Nothing was merged or pushed.
…ux CI job
1. spec-intact: an always-true skip condition (skipIf(True), { skip: 1 }) is
unconditional, and a new-named test whose assertions were already in the
file is the old test renamed; skipping it counts.
2. A Python comparison as a bare statement (python3 -c "...; f(x) == y")
asserts nothing: L16 refuses it and it carries no value tag.
3. write_file/edit_file refuse a file name with a control character, and
receipts print paths with line breaks escaped.
4. dash's "sh: 1: eval: ./x: not found" (through the hidden-check wrapper)
reads as a missing deliverable, as on macOS.
5. A file inside node_modules changed during the turn after its package was
installed (touch -d cannot set ctime) shadows the runner, bin or required
module alike.
Row 19: the audit's interface view shows how to run each script and nothing
it prints; --help text and usage strings are the work's to choose.
N3: when separation is the default but cannot be set up, the receipt's
isolation line says so. MAAT_DEFAULT_WORKER_USER names the default account.
CI: test-linux runs the whole suite on ubuntu-latest (/bin/sh is dash).
…o checks writes no receipt)
|
Status sweep, item by item. All fixed in claude/integration-all@64c3870 (ac070f9 + 64c3870) unless marked otherwise. CI on 64c3870 is green: Blockers
Partly-fixed items
N3 (separation was the default but could not be set up): fixed. The isolation line on every receipt and in the journal now reads Verification at 64c3870
|
One branch with every open PR's changes. It replaces the stack as the standard build. Do not merge yet.
Base:
claude/sync-local-work(#23). This PR targetsmain, so the diff also includes #23.Included
endedByunion)Conflict choices
withhold.tsnote on exact-substring masking, reworded for Keep check text and API keys away from the worker #40's mechanism. With privilege separation, the wrapper and fd 3 also go throughcheckSpec/commandSpec, so a check stays hidden when it runs as the check or worker account.## Outputonly after the check table, and its cap trims the claim, never the table. Both tests are kept. The round-3\r/bypass of review: cut receipt evidence at the real ## Output heading (worker could hide failing checks from the reviewer) #37 applied to both versions; it is fixed here (see the review fixes below).runner.py), sends results back on their own pipe rather than on stdout, removesreference/INPUTSfrom the module before the deliverable runs, kills the process group after 90 s, and needs theREFERENCE COMPARED n/nsentinel for a pass. Close the adversarial gaps with no PR yet #41's single-child, nonce-on-stdout driver was not taken. Close the adversarial gaps with no PR yet #41's driver tests and fixture 12 were adapted to writerunner.pyand to match its messages. They all pass./var/lib/bench-work, its per-task export under/root, the scrubber and grading as the agent user in a forked child. From Run the worker's tools as another user: --worker-user (builds on #27) #29 I keptBENCH_PRIVSEP,--worker-user/--check-user,SYS_ADMIN, the grok and OpenCode judge credentials in/root, andsanitized_copy/grade_safely, which crosscheck.py uses. Run the worker's tools as another user: --worker-user (builds on #27) #29's end-of-run copy of the whole WORK folder was dropped: it would also have exported.logsand.home-snapshot. Logs are written mode 600. Run the worker's tools as another user: --worker-user (builds on #27) #29'sO_NOFOLLOW_copy_regularreplaces Keep unattended workers on the task: bench isolation, scoped file tools, no-progress guard #33's.BENCH_KEYS_FILEand then to Maat onMAAT_KEYS_FD. The api2 key comes from the same file.expected.txt, a seed file) no longer verifies on its own. The Verified needs a check the worker did not write #32 test and one Redraft checks that pass before the work; discrimination gate opt-in #34 test were updated to say so.tierOfdrops discounted checks andguard-tagged checks, and strips the value tag from unproven goldens.hideCommandandasCheck: "copy", and are added to Keep check text and API keys away from the worker #40's source guard.preWorkCopyuses Keep check text and API keys away from the worker #40'scopyRegularand Run the worker's tools as another user: --worker-user (builds on #27) #29'scheckTmpDir.print(x == y)fixture (04). The bar now reads a printedFalseas a failure, so the worker's own check is refused outright where it used to earnpassed-own-checks. The fixture now asserts "never verified".Defaults
MAAT_REQUIRE_EXACT=1. Off by default, the tier is decided as before Exact tag: verified needs a check that compares the output with an exact value #46, the drafter and auditor get the earlier prompts (draftSystem()/auditSystem();*_EXACThold Exact tag: verified needs a check that compares the output with an exact value #46's), andV-property-onlydoes not drop checks. Theexacttag is still recorded. Exact tag: verified needs a check that compares the output with an exact value #46's cut-off retry in ask.ts is kept, now with a cap.--worker-userby default, on disposable machines only (Integration: all 2026-10-07 fixes #49 review B1). This covers unattended runs (run,ask,mission run) as root on Linux in a container (/.dockerenv,/run/.containerenv, or PID 1's cgroup), in CI (CI=true), or in a folder made for the run (MAAT_DISPOSABLE_PROJECT=1); there, the tools run asmaat-worker. The account is created only inside a container, with--systemand shell/usr/sbin/nologin. Anywhere else,sudo maat runin/etc/nginxincluded, nothing changes and a one-line notice suggests--worker-user.MAAT_ASSUME_CONTAINER=1/0overrides the container check, and--worker-user noneopts out. Every entryhandBackgives the worker is recorded (device, inode, owner, mode) and given back on close, at exit and on SIGINT/SIGTERM/SIGHUP. The restore works through the open entry, never by path. Whatever the worker created stays its own.maat-bench-api2, set withcontainer/set-api2-key.sh. The lane's host and key variable come from the untrackedbench/local/.api2.env(BENCH_API2_HOST,BENCH_API2_KEYVAR). The variable defaults toANTHROPIC_API_KEY, the provider's own documented name and the one Maat reads; keeping it is the owner's standing choice.Round-3 review fixes (on this branch)
--helptext is used only when it is real usage and differs from a plain run, anddefault=/help=values are cut..bintargets in node_modules are checked by realpath, mtime and a declared bin; 3, a non-ExceptionBaseException fails the work. Also the review: cut receipt evidence at the real ## Output heading (worker could hide failing checks from the reviewer) #37 line-break bypass.!/&& exit 1/then exit 1greps; 2,not in/is not None; 6, short or alternated grep patterns.Verification (at 64c3870)
npm run checkwith~/.local/binoff PATH: 2412 tests, 2407 pass, 0 fail, 5 todo; e2e and self-check pass. lean-budget 7/7, and malformed-cap passes with no ceiling raised./bin/sh= dash): 2411 tests, 2404 pass, 0 fail, 7 todo. CI's newtest-linuxjob runs it on every push.SYS_ADMIN(andMAAT_TEST_REQUIRE_PIDNS=1), 32/32; without it, 31 pass and 1 skipped. secret-hygiene: 14/14 in both.typecheck-and-test,test-linuxandprivsep-linuxall green.~/.cache/maat-bench/maat-all-64c3870.tgz.Review fixes for #49 itself (B1 disposable-only default plus owner restore, B2 api2 local config, and the status-sweep blockers) are listed item by item in the PR comments.
🤖 Generated with Claude Code
https://claude.ai/code/session_01D63hP8FwZTxcamaLRq4jRt