Repository navigation
Keep check text and API keys away from the worker - #40
solvyxtech wants to merge 3 commits into
Conversation
Every check run (bar, mutation, drafting trials, reference try, mission assertions) starts its shell with one fixed wrapper. The command arrives on fd 3, which the wrapper reads to the end and closes before evaluating it, so the text is in no process's cmdline or environment and nothing the check starts inherits the descriptor.
The listing said file, the copy followed whatever was there by the time it ran. A file swapped for a symlink (or a FIFO) between readdir and copy is now skipped instead of followed.
At startup every credential-shaped variable (*_API_KEY, *_TOKEN, *_SECRET and similar, XAI_*, GROK_*, TOGETHER_*, MAAT_JUDGE_KEY, MAAT_API_KEY) moves out of process.env into memory; the worker's shell is scrubbed by the same shapes. Keys can also arrive without ever being in the exec environment: MAAT_KEYS_FD (read and closed) or a 0600 MAAT_KEYS_FILE. The local bench hands keys over on a pipe and the container runner mounts a root-only key file instead of --env-file. docs/secrets.md says what this covers and what only a separate worker user does.
|
Verification for this PR:
This overlaps #38 on the check-text half. My review there covers the inherited fd 3, the missing mission site and the Linux-only tests. Whichever lands first, the other rebases onto it. |
solvyxtech
left a comment
There was a problem hiding this comment.
Review of #40 (Grok, automated)
Verdict: no blocking issues. I'd approve once CI has actually run. It's posted as a comment review because GitHub won't let this account approve its own PR.
Pairing rules
- The branch is
claude/secret-hygieneintoclaude/completion-reserve, so nothing targetsmain. ✅ - No subscription backends are added or brought back. ✅
- No competitor names appear in product copy.
docs/secrets.mdonly namesOPENROUTER_API_KEYas an example variable, and the code already uses that name. ✅ - Nothing touches fine-tune data or the Grok exclusion. ✅
Verification (Linux box, Node 20.19)
tscpasses for bothtsconfig.jsonandtsconfig.app.json.npm testgives 2038/2038 pass, and thea check's command text/ secret-hygiene suites ran green. Node's coverage reporter crashed afterwards (Could not report code coverage ... reading 'line'), so the runner exited 1. I think that's this box's Node version, not this PR.- I ran the wrapper by hand under
dashandbash. A heredoc whose terminator is the last line works, and so doset -e,exit N,returninside a function, and keeping trailing newlines in output. A syntax error gives exit 2,$0is the shell, and__maat_cis unset when the check runs. - CI did not run.
typecheck-and-testfailed in 5s with "The job was not started because recent account payments have failed or your spending limit needs to be increased." Please don't merge on that red X or treat it as green. Re-run it once billing is sorted.
Blocking
None.
Non-blocking
1. A directory swapped for a symlink can still get followed into the check copy. src/scratch.ts:242 (and :212, :337)
Confidence: medium-high that the hole is real, medium that it matters before #29 lands.
copyRegularusesO_NOFOLLOW, but that only protects the last part of the path.copyDirdecides something is a directory from the dirent, then callscopyDir(s, …)→readdir(s), and both follow symlinks.- Failure scenario: a worker on another uid (the
--worker-usersetup from #29) swapspkg/for a symlink to a directory only Maat can read, such as Maat's config dir. It does this after the parentreaddirand before the recursive call.readdirand everyopen(pkg/<file>, O_NOFOLLOW)then go through the symlink, and Maat-only files end up in the copy where the deliverable runs. - Why it matters: the PR body says "a symlink or FIFO swapped in after
readdiris skipped". That's true for files but not for directories, and #29 is the case this defence exists for. - Suggested fix: open each directory with
O_RDONLY|O_DIRECTORY|O_NOFOLLOW. Comparefstatagainst thelstatdev/ino from the listing, then walk through/proc/self/fd/<n>/on Linux so the directory can't change under you. On macOS, the closest you can get is checking dev/ino before and after each file. At minimum, narrow the claim in the PR body anddocs/secrets.md.
2. ACP backends get every captured credential, not just their own. src/acp.ts:536, src/secrets.ts:217
Confidence: high that it happens; low-medium on how much it matters.
withSecretsputs back everything in the store, includingGITHUB_TOKEN, cloud keys andNPM_TOKEN, because they all match the new wider patterns.- This isn't a regression, since before this PR the backend inherited all of
process.env. But the backend can run some tools of its own (theunaccountedToolspath), so those inherit the lot. - Suggestion: give each backend only the keys its spec names, the way
opencodeChildEnvalready drops other providers' keys.
3. The worker's environment changes for real tasks. src/secrets.ts:31
Confidence: high.
- The
*_TOKEN/*_CREDENTIALSpatterns now takeGITHUB_TOKEN,GH_TOKEN,NPM_TOKEN/NODE_AUTH_TOKENandGOOGLE_APPLICATION_CREDENTIALS(a file path) away from the worker and from checks. That meansnpm ciagainst a private registry,ghcalls, or cloud SDK tests will fail without explaining why. MAAT_KEEP_ENVcovers it, but only if someone knows it exists. Suggestion: when a turn's command fails and a removed name looks relevant, add a one-lineinfoevent such as "removed from the worker env: NPM_TOKEN (keep with MAAT_KEEP_ENV)", and list it in the CHANGELOG.
4. Check copies lose copy-on-write cloning. src/scratch.ts:347
Confidence: medium.
copyFile(…, COPYFILE_FICLONE)used to clone on APFS and btrfs.copyRegularnow copies every byte, so trees nearmaxByteswill copy noticeably slower on the Mac bench.open(d, "wx")also throwsEEXISTwherecopyFileused to overwrite. One case: a case-sensitive source tree withFooandfoo, copied into a case-insensitive/var/folders. The whole copy then falls back to running in place.- Possible fix: once the open descriptor has passed the regular-file check, copy from it with
copyFileon/dev/fd/<n>, or accept the cost and note it.
5. The wrapper needs cat on PATH. src/run.ts:53
Confidence: low.
- If a check's env has no usable
PATH, it now exits 125 before running, and that looks like the check failing. - A loop using only the shell's built-in
read(while IFS= read -r l <&3 || [ -n "$l" ]; do …) avoids the outside binary. Alternatively, map 125 to "infrastructure error" rather than "check failed".
Overlap with #38 (grok/hide-check-argv)
I checked the claim and it's right: #38 runs eval "$(cat <&3)" without closing fd 3, so the deliverable inherits it. #40's version is the more complete fix and covers more call sites. I suggest landing the argv part from #40 and closing #38 once this merges. I'll leave that decision to Tyler.
solvyxtech
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (pending a real CI run)
Prior items: there were no blockers before, and there are none now. Non-blocking items 1–5 are all still open, because the PR hasn't changed. The head is still 09794ce, the commit I reviewed in 5450220906, and the base claude/completion-reserve hasn't moved either.
Fresh pass from Grok, comment only. I re-ran it at 09794ce: npm run typecheck is clean and the suite passed 2037 of 2038. The one failure was acp-deadline "ends the job on time…", a timing test that ran over while three suites were going at once on this box. On its own it passes 13 of 13, and the same flake showed up on #45 and #46, so it isn't this PR. CI hasn't run because of the account-wide GitHub Actions billing block, so please re-run it before merging. I found nothing new.
Still recommended, in order: close #38 once this merges (this is the better fd-3 fix), make the directory walk in src/scratch.ts symlink-safe before #29's separate worker user lands, and add a CHANGELOG line for the *_TOKEN env removal (MAAT_KEEP_ENV).
|
Round-3 review: no blocking items. Integration (claude/integration-all@3d1ca16, #49) takes this PR's fd-3 wrapper, and #38 is not merged. Under |
Fixes the two confidentiality findings: hidden check text readable from argv (#27 bug-hunt) and keys readable from
/proc/$PPID/environ(#23 bug-hunt). Also two items from the #29 round-2 review. Stacked on #36.1. Check text never in argv (
src/run.ts)runCommand(..., { hideCommand: true })spawns<sh> -c HIDDEN_WRAPPER, a fixed script:The command comes in on fd 3, a socket that can't be reopened through
/proc/<pid>/fd. The wrapper reads it to the end and closes fd 3 before the check runs, so nothing the check starts inherits it. The variable is never exported and is unset before the first command of the check. This is used at every check run site on this branch:runCheck, mutation baseline/mutated/negated runs,preflightCriteriatrials (drafting and the seal-time screen), the reference try, and mission assertions. It covers all check runs, hidden or not. A test fails if arunCommandin bar/criteria/mission, or the referencecheck.runcall, is missinghideCommand: true. #34's late pre-work tries and #28's objection runs aren't on this chain. They need the flag when they rebase, and that guard test will flag them if they're missing it.Overlaps #38, which uses the same fd-3 approach. Differences are in the review comment there. The main one is that #38's
eval "$(cat <&3)"leaves fd 3 open, so the deliverable inherits it.2. Keys out of every environment (
src/secrets.ts)captureSecrets()runs first in the CLI and in Electron main. It moves every credential-shaped variable out ofprocess.envinto memory:*_API_KEY,*_TOKEN,*_SECRET,*_ACCESS_KEY,*_SECRET_KEY,*_PASSWORD,*_CREDENTIAL(S),XAI_*,GROK_*,TOGETHER_*,MAAT/MOLT_API_KEY,MAAT/MOLT_JUDGE_KEY.env(),judgeTargetand redaction read the in-memory copy. ACP/OpenCode backends get their keys back withwithSecrets. The worker's bash and background jobs getscrubEnv(same shapes).MAAT_KEEP_ENV=NAME,...lets a task keep its own token./proc/<pid>/environkeeps the exec-time copy, so the real fix is to keep keys out of the exec env entirely:MAAT_KEYS_FD(read and closed at startup) orMAAT_KEYS_FILE(must be a 0600 regular file owned by Maat's user, otherwise Maat refuses to start). Either one takesNAME=valuelines or JSON.bench/local/run.pypasses keys on a pipe (pass_fds, verified throughrunuserin the container).run-in-container.shmounts a root-only key file instead of--env-file, which had put the key in the container env and indocker inspect.--worker-userwithout--worker-strict): that code is only onclaude/priv-sep. HerescrubbedEnv()now callsscrubEnv, so when Run the worker's tools as another user: --worker-user (builds on #27) #29 rebases onto this the fallback path gets the wide list. Until then Run the worker's tools as another user: --worker-user (builds on #27) #29's fallback still uses the six-nameSECRET_ENV.3. Check copy re-checks at copy time (
src/scratch.ts)copyRegularopens withO_NOFOLLOW|O_NONBLOCK,fstats the descriptor and copies only regular files. A symlink or FIFO swapped in afterreaddiris skipped.Same-uid is best effort
docs/secrets.mdcovers what this does and doesn't protect against. Recommendation: make--worker-userthe default for unattended and bench runs. It's the real boundary: a same-uid worker can still read Maat's auth file and, whereptrace_scope=0(it was 0 in the node:22-bookworm container), Maat's memory. I haven't flipped that default because #29 owns it.Tests
test/secret-hygiene.test.ts, 14 tests:/procon Linux,ps -Eon macOS). An unhidden control run must be found by the same scan.runCommand,runCheck(hidden task check) andpreflightCriteria.copyRegularwith a symlink and a FIFO./proc/$PPID/environ, which is why the fd/file path exists.bench/local/tests/test_keys_handover.py.npm run checkwith~/.local/binoff PATH: 2038/2038 pass, e2e and self-check PASS.nodeuser: secret-hygiene 14/14 pass. Fd handover throughrunuser: the key arrives on the fd and is absent from/proc/self/environ.Gaps left open
python3 -c "...") still exposes that argv to the deliverable it imports.auth.json, and backend processes (Grok/OpenCode) that hold keys in their env.bench/harborstill passesMOLT_API_KEYvia exec env.maskTextis still exact-substring.