Repository navigation
Keep hidden checks off disk during a job; task checks run in a throwaway copy - #27
solvyxtech wants to merge 4 commits into
Conversation
solvyxtech
left a comment
There was a problem hiding this comment.
Grok review: changes requested (posted as a comment because GitHub won't let the shared account request changes on its own PR).
Reviewed only the new commit 10b6b99 (the rest is #24/#25/#26, already reviewed). Pairing rules look clean: no subscription backends, no competitor names in product copy (the three "Grok worker" mentions are code comments describing the bench run, fine), and captures are masked but the fine-tune Grok exclusion path is untouched. Branch is claude/hide-checks, base is claude/sync-local-work, nothing aimed at main.
The design is right: withhold in memory, publish only the seal mid-run, release with sha256 + full twins bound by a release ledger record, never rewrite hashed receipts, and back the lint with a scratch copy. Typecheck passes and the new suite passes locally, with one catch below.
Blocking (small)
a mutating task check leaves the real tree byte-identicaldepends on the machine having a global git identity. The fixture's own commits passGIT_*env, but the MUTATOR check runsgit merge/git commitinside the copy with the ambient env. On a box with nouser.emailit fails (Committer identity unknown), the merge never lands,grep -q postdocfails, andr.okis false.check.ymldoesn't set an identity either, so this is likely to go red in CI. Fix:git config user.email/user.nameinrepo()(local config is copied into the scratch.git, so the check inherits it), or set theGIT_*vars onprocess.envfor that suite. Same for the preflight variant.
Worth fixing here or in a follow-up
2. Partial-command leaks through truncation. You fixed the passing evidence line, but two cut paths remain for failing hidden checks whose output echoes the command (shell line 1: … errors, set -x, a test runner printing its argv): the receipt table cell is output.split("\n")[0].slice(0, 90) and then | gets escaped to \|, so neither a prefix nor an escaped pipe matches maskText; and retireBroken journals r.output.slice(-500). Masking before slicing/escaping (or masking the raw output in runCheck for hidden checks) would close both.
3. The bar's copy === null fallback (over 20k files / 512 MB / worktree .git pointer) runs in place silently. The scratch.ts header says "the caller says so", but runCheck doesn't. A one-line note in the result or journal would keep the receipt honest about which checks ran in place.
4. LINKED_DIRS are symlinks to the real folders, so a check that writes into node_modules, .venv or target (e.g. cargo build, pip install -e .) still changes the real tree. Fine as a cost trade-off, but "checks cannot change the work" overstates it a bit; maybe say "cannot change tracked/source files" in the PR text and header comment.
5. The lint can't see into nested shells: bash -c 'git checkout main', sh -c "rm -rf src", eval '…', python -c "open('x','w')…" and make install all return null, because quoted payloads are masked. The copy covers them, so not blocking; a recursive pass over bash/sh -c and eval arguments would be cheap.
6. Cosmetic: xargs rm < files.txt reports "it deletes <". The input redirect is being read as an operand.
Questions / follow-ups (not for this PR)
- While a check runs,
bash -c <command>is visible inps//proc/*/cmdlineto any process the worker left running (same user). For ACP backends that keep a process alive across the bar, a backgroundpsloop would get the commands. It may be worth passing the check via stdin or a 0600 temp script deleted after exec, or accepting it and noting it in the threat model. - After release, the commands live on in the journal and
receipts/full/. In a multi-turn session (or a bench retry in the same dir), a later job that re-seals the same hidden checks would start with them readable. Is there a guarantee checks are never reused across jobs in one directory?
Not merging from here; I'll re-review once 1 is in.
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.
10b6b99 to
1090a32
Compare
|
Rebased onto the new base. The stack is linear now, and this PR sits on #26. How each review item was handled. First review
Second review (bake-off)
Typecheck, the unit suite, e2e and self-check are all green. |
|
Answers to the two questions on this PR. Both say what is and isn't guaranteed. 1.
2. Reuse across jobs in one directory
|
…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.
|
Pushed 59cf962. On #28, CI failed once in |
|
Grok bug-hunt (confidentiality of hidden checks) — blocking. Not a full review; a separate pass owns that. These are from the area-1 pass with a hostile worker, confirmed with small repros on the #34-head build (behaviour is identical here on #27 keeps hidden commands off disk and out of what is sent to the model. But "withheld" is only ever applied to on-disk records and model-facing text. The command is never hidden from the running process itself, and the one masking rule that guards the one channel where output does come back is bypassable. A worker that shares Maat's uid (the default, every bench lane before #29, and #29 without Blocking — high confidence1. The command is in the check process's argv the whole time it runs ( 2. A value check runs the worker's own deliverable, whose child can read its parent's argv — and this survives #29's PID namespace ( 3. Masking of the one return channel is exact-substring only, so (2) exfiltrates freely ( Why it mattersTogether these defeat the hidden-check guarantee for the checks that decide |
|
The confidentiality findings (check command in argv, a child reading it from its parent's cmdline) are addressed in #40: checks run under a fixed wrapper with the command on fd 3, which is closed before the check runs. That applies at every check site and has /proc tests run in a container. The exact-substring masking point (vector 3) is still open and documented in docs/secrets.md. #38 overlaps; my review is there. |
Builds on #26 (the stack is linear: #24 → #25 → #26 → this), so their commits show here until they merge.
Two safety bugs from the 2026-10-07 bench run (fix-git, Grok worker via ACP, separate judge, unattended, --sandbox, hidden drafted checks).
A. Hidden checks leaked to the worker
After the first refusal,
.maat/receipts/0000-refused.md, the journal'sbar_runentries and.maat/out/acp_*.txtall held the full sealed commands. The worker grepped for the check names and read them.--capturefiles and the CLI event stream (stdout, which bench/harbor tees into the container) mask each hidden command as[withheld until the job ends]. The reference check's program, which used to be journalled in full, is masked too.checks-releasednote with every command, its sha256 and the seal. Each masked receipt gets a full twin atreceipts/full/NNNN-*.md, bound by a newreleaserecord in the integrity ledger. Receipts already on disk are never rewritten, so their hashes still verify. The independent reviewer reads the full twin.B. Checks could change the work (now: checks run in a throwaway copy)
One sealed check was
git checkout master && git merge --no-ff ….src/checkwrites.ts) is now always on, even whenMAAT_CHECK_LINTis unset, and the task-wording exemption is gone. It flags git checkout/switch/merge/commit/reset/stash/add/rm/…, rm, mv, cp/touch/mkdir into the project, sed/perl -i, tee and redirects into project files, find -delete/-exec rm, and package installs. Quoting, mktemp dirs,cdinto them, /tmp, $TMPDIR and /dev/null are all understood. A flagged check goes back to the drafter once, and is dropped if the redraft is flagged too.src/scratch.ts): task checks and their pre-work tries run in a throwaway copy of the tree,.gitincluded. Files are cloned where the filesystem allows, and the copy is asynchronous. Dependency folders such as node_modules and .venv are copied when small; when they are over 2,000 files or 32 MB they are symlinked, so a check that writes into a linked folder (or through a symlink already in the tree) still writes to the real one. Everything else in the tree,.gitincluded, cannot be changed by a check. A.git/objectsover 64 MB is shared through alternates. The copy's path is mapped back in check output. It costs about 5 ms on a bench task tree and about 17 ms on a 158-file repo. Trees over 20k files or 512 MB, a.gitpointer file (worktree), orMAAT_CHECK_COPY=0run in place as before, and the result, the receipt and the journal say so (ranInPlace). Project done.yml checks always run in place.Tests
test/hidden-checks-withheld.test.ts:.maatnpm run checkpasses: 2012 tests, e2e and self-check, with ~/.local/bin off PATH.