Skip to content

Keep hidden checks off disk during a job; task checks run in a throwaway copy - #27

Open
solvyxtech wants to merge 4 commits into
claude/acp-deadlinefrom
claude/hide-checks
Open

solvyxtech wants to merge 4 commits into
claude/acp-deadlinefrom
claude/hide-checks

Conversation

@solvyxtech

@solvyxtech solvyxtech commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

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's bar_run entries and .maat/out/acp_*.txt all held the full sealed commands. The worker grepped for the check names and read them.

  • While a job runs, receipts, journal entries, out/ copies, --capture files 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.
  • The commands are held in memory and released once the work is over. The journal gets a checks-released note with every command, its sha256 and the seal. Each masked receipt gets a full twin at receipts/full/NNNN-*.md, bound by a new release record in the integrity ledger. Receipts already on disk are never rewritten, so their hashes still verify. The independent reviewer reads the full twin.
  • The seal journalled before the work is unchanged: a hash over the full commands, now the only form published during the run.
  • Checks a person approved (not hidden) are written as before.

B. Checks could change the work (now: checks run in a throwaway copy)

One sealed check was git checkout master && git merge --no-ff ….

  • Seal-time lint L15 (src/checkwrites.ts) is now always on, even when MAAT_CHECK_LINT is 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, cd into 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.
  • Defence in depth (src/scratch.ts): task checks and their pre-work tries run in a throwaway copy of the tree, .git included. 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, .git included, cannot be changed by a check. A .git/objects over 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 .git pointer file (worktree), or MAAT_CHECK_COPY=0 run 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:

  • scans every file under the task dir and every printed event mid-run after a refusal and after the worker greps .maat
  • checks that the full receipt exists after job end, the integrity chain verifies, and the released seal matches
  • checks that the lint flags each mutating pattern and lets reads and scratch writes through
  • checks that a mutating check (bar and preflight) leaves a git repo byte-identical

npm run check passes: 2012 tests, e2e and self-check, with ~/.local/bin off 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: 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)

  1. a mutating task check leaves the real tree byte-identical depends on the machine having a global git identity. The fixture's own commits pass GIT_* env, but the MUTATOR check runs git merge/git commit inside the copy with the ambient env. On a box with no user.email it fails (Committer identity unknown), the merge never lands, grep -q postdoc fails, and r.ok is false. check.yml doesn't set an identity either, so this is likely to go red in CI. Fix: git config user.email/user.name in repo() (local config is copied into the scratch .git, so the check inherits it), or set the GIT_* vars on process.env for 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 in ps//proc/*/cmdline to any process the worker left running (same user). For ACP backends that keep a process alive across the bar, a background ps loop 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.

Tyler Skelton added 3 commits October 7, 2026 14:31
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.
@solvyxtech solvyxtech changed the title Keep hidden checks off disk during a job; checks cannot change the work Keep hidden checks off disk during a job; task checks run in a throwaway copy Oct 7, 2026
@solvyxtech

Copy link
Copy Markdown
Owner Author

Rebased onto the new base. The stack is linear now, and this PR sits on #26.

How each review item was handled.

First review

  1. Blocking: the test depended on a global git identity. Fixed. repo() in the fixture now sets user.name and user.email in the repo's own config, and the throwaway copy inherits it. I reproduced the failure with HOME=<empty> GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=user.useConfigOnly GIT_CONFIG_VALUE_0=true and it passes with the fix.
  2. Partial commands leaked through truncation. Fixed:
    • A failing hidden check's output is masked of its own command in runCheck, before truncate.
    • The receipt table cell is masked before it is cut to 90 characters and before | is escaped.
    • retireBroken masks before it takes the last 500 characters.
    • maskText also matches a command written with escaped pipes (\|).
    • Tests cover all three paths with a long, piped command.
  3. The copy === null fallback was silent. Fixed. copyTreeOrWhy returns the reason. A task check that got no copy carries ranInPlace: <why> on its result. The receipt prints ran in place: no throwaway copy of the tree — <why>, and the journal's bar_run row carries it too. Tested with a worktree .git pointer file.
  4. "Checks cannot change the work" overstated it. Fixed in the code and in the PR text. Dependency folders are now linked only when they are over 2,000 files or 32 MB; smaller ones are copied. The copy lists what it linked. The scratch.ts header now says exactly what the copy does not cover: linked folders and existing symlinks write through to the real tree.
  5. The lint couldn't see nested shells. Fixed. checkMutates now reads bash/sh/zsh/dash/ksh -c payloads and eval arguments as commands of their own (up to 4 levels), and outer mktemp variables carry in. The message says where the write was, for example it runs `git checkout`… (inside `bash -c`). python -c is still out of scope. The copy covers it.
  6. xargs rm < files.txt said "deletes <". Fixed. Input redirects (<, <<, <<<) and their targets are no longer read as operands, so the message is now "it deletes its input".

Second review (bake-off)

  • Linked dependency folders can change the work. Covered by item 4 above: small ones are copied and the wording is accurate. A test writes into a copied node_modules and checks the real one is unchanged.
  • The copy ran synchronously on the event loop. Fixed. copyTree and copyTreeOrWhy now use fs/promises throughout, and so does the size count. A test checks that timers keep firing while a 300-file tree is copied.
  • Not changed, open for a decision: "always-on L15 is unmeasured; gate it behind MAAT_CHECK_LINT until replayed". I left the lint always on. Now that the copy exists, gating it is a one-line change. I'd rather decide it with a replay than flip it here.

Typecheck, the unit suite, e2e and self-check are all green.

@solvyxtech

Copy link
Copy Markdown
Owner Author

Answers to the two questions on this PR. Both say what is and isn't guaranteed.

1. ps / /proc/*/cmdline exposure while a check runs
Not guaranteed hidden by this PR. A hidden check runs as bash -c <command> under Maat's uid. While it runs, any process with the same uid can read the command from ps or /proc/<pid>/cmdline, and that includes an ACP agent's leftover background process. This PR closes the on-disk leaks: receipts, journal, out/ copies, captures and the event stream. It does not close the process table. Options:

2. Reuse across jobs in one directory
There is no guarantee that a check is single-use. Withheld commands are released when each run() ends (releaseWithheld in the finally), into the journal's checks-released note and receipts/full/. After that they are readable by anything that can read .maat/. What holds:

  • Drafted checks (--criteria auto) are drafted again for every job, from the task text and the pre-work snapshot. A retry in the same directory can still draft identical commands, and a later worker could find the earlier job's released copies.
  • Caller-supplied checks (--criteria files, mission assertions) and anything reused across turns of one session are exposed from the next job on.
    If single-use matters for a bench, the harness should start each attempt in a fresh directory, or move .maat/receipts/full and the journal out of the worker's reach between attempts. A mechanical guard is possible: refuse to seal a hidden check whose sha256 is already in a checks-released note in this directory. I haven't added it here. It needs a decision on what a refused re-seal should do.

…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.
@solvyxtech

Copy link
Copy Markdown
Owner Author

Pushed 59cf962. On #28, CI failed once in a mutating task check leaves the real tree byte-identical: the check passed, but the real tree changed. Under load that also failed in 2 of 15 local runs. The likeliest cause is a copy that failed partway, after which the check ran in place. A file that vanishes between readdir and copy (git's detached maintenance, or anything else running in the tree) failed the whole copy. Such a path is now skipped, because it is simply no longer in the tree. The fixture repo also turns off gc.auto and maintenance.auto. The test now asserts the check got a copy, and if the tree still changes it lists exactly what changed, so a recurrence explains itself. The cause is not confirmed. 30 local runs under load pass, and npm run check is green: 2012 tests, e2e and self-check.

@solvyxtech
solvyxtech changed the base branch from claude/sync-local-work to claude/acp-deadline October 7, 2026 23:28
@solvyxtech

Copy link
Copy Markdown
Owner Author

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 claude/hide-checks).

#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 --worker-strict) can recover hidden check commands three ways.

Blocking — high confidence

1. The command is in the check process's argv the whole time it runs (src/bar.ts:2193, src/criteria.ts:343). Every hidden check — the trial runs during drafting (preflightCriteria), the seal-time screen, tryLateBeforeWork, and every bar run — is spawned as bash -c "<command>". The full command sits in /proc/<pid>/cmdline for the life of the subprocess. These runs are concurrent with the worker's turn (the drafter is pendingCriteria, running while the model takes steps; the bar runs on every claim). Reading /proc/*/cmdline is classed read-only by gate (src/autonomy.ts:655, read_file/grep/bash cat are all ask:false), and under --sandbox the project boundary is dropped (boundary:"machine", src/autonomy.ts:629/645), so the worker scrapes /proc for bash -c lines with no prompt at all. This is lead #2, and it leaks the commands before and independent of withholding — withholding never touches a live argv.

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 (src/bar.ts:2193). A "runs+value" check (exactly the class that earns verified) executes worker-authored code, e.g. python3 solution.py. That child reads /proc/<ppid>/cmdline (its own parent) and gets the whole hidden command. Inside a #29 PID namespace the check is pid 1 and the deliverable is its child, so /proc/1/cmdline is still readable — the namespace hides other processes, not a child's own parent. The doc answer on this PR ("#29 … is the only change that actually removes it") is not correct for this vector. Repro: a deliverable that reads its parent cmdline and prints it base64/reversed; the hidden command comes out in full.

3. Masking of the one return channel is exact-substring only, so (2) exfiltrates freely (src/bar.ts:2254, src/withhold.ts:45). A failing hidden check's output is sent back to the model each attempt (formatBarFailure → pushBarFailure), masked with maskText(output, [check.run]), which only replaces verbatim copies of the command. A deliverable that base64s or reverses what it read passes straight through. Confirmed: with the check made to fail, maskText leaves the base64 of its own command intact (clipEnds keeps 4000 bytes, a one-line blob survives). So a worker can, in one bar run, learn the command and tune the work to it — the exact "model makes the work equal the check" failure #27 exists to prevent.

Why it matters

Together these defeat the hidden-check guarantee for the checks that decide verified. Vector 1 is closed by #29's pidns (worker can't see Maat's/check's /proc); vectors 2+3 are not. Suggested directions (for whoever owns the fix): pass the command on stdin or via a pre-opened fd / 0600 script unlinked before exec so it is never in argv; run the deliverable in a grandchild whose parent argv is a wrapper, not the command; and make the returned-output guard not rely on exact-substring masking (e.g. run value checks so their stdout/stderr is never echoed back, only a pass/fail + a fixed diff shape). Happy to re-check once a direction is picked. Nothing merged or pushed.

@solvyxtech

Copy link
Copy Markdown
Owner Author

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.

@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