Skip to content

Hidden-check commands off argv: stream them in on fd 3 - #38

Open
solvyxtech wants to merge 1 commit into
claude/hide-checksfrom
grok/hide-check-argv
Open

solvyxtech wants to merge 1 commit into
claude/hide-checksfrom
grok/hide-check-argv

Conversation

@solvyxtech

Copy link
Copy Markdown
Owner

Keep hidden-check commands out of argv (/proc/<pid>/cmdline, ps)

Fixes the blocking finding confirmed by two reviewers (#27 confidentiality note, #29 privsep review): a hidden check's command is a secret the worker must not learn, but spawned through the shell the ordinary way (spawn(command, { shell }) → <sh> -c "<command>") the command becomes an argv word. /proc/<pid>/cmdline is world-readable and ps shows it to every user, so:

What changed

runCommand (src/run.ts) gains hideCommand. When set — Linux/macOS only; Windows has no /proc and no cheap fd passing, so it falls back to the ordinary spawn — the shell is invoked with a fixed wrapper and the real command is streamed in on an inherited file descriptor:

spawn(shellPath, ["-c", 'eval "$(cat <&3)"'], { stdio: ["ignore","pipe","pipe","pipe"] })
// then: child.stdio[3].end(command)

The only thing in any argv is <sh> -c eval "$(cat <&3)"; the command text never appears. eval of the whole fd-3 text runs it exactly as -c would (and $0 is the shell, as with -c).

Why fd 3 and not sh -s on stdin: stdin is left exactly as it was (ignore → /dev/null), so a check that reads stdin is unaffected. Feeding the script on stdin (sh -s) would make a check that does read/cat consume its own script text — a real behaviour change. fd 3 avoids that entirely.

Wired on every hidden-check spawn path on this branch:

  • src/bar.ts runCheck — the bar check (hideCommand: check.hidden === true);
  • src/bar.ts mutationCheck — baseline, mutated and negated runs (threaded check.hidden);
  • src/criteria.ts preflightCriteria — the drafted-check trials before work (hideCommand: !!opts.stray, the seal-time screen);
  • src/reference.ts writeAndTry — the reference value-check try (hideCommand: true).

Non-hidden project checks keep the ordinary spawn, so ps still shows a person's own check command for debuggability.

Masking note (src/withhold.ts)

Added a doc note that maskText is exact-substring only (plus a trimmed and a markdown-\|-escaped form): it catches a command echoed verbatim but not one a tool has reformatted (re-quoted, re-indented, line-rewrapped). Masking is a convenience for Maat's own records; the confidentiality boundary is keeping the command off disk during the job, out of argv (this change), and behind a uid the worker cannot cross (--worker-user). I kept the masking logic itself unchanged — a fuzzy/whitespace-normalised pass risks mangling legitimate record text, so I left it as a documented limit rather than widen it blindly.

Tests

New Linux-only suite in test/run.test.ts (skips on macOS, no /proc): a hidden-command run whose body reads its own and a spawned child's cmdline and asserts the command's unique token is absent from both, that the command still executed (RAN=42), and that the exit code propagates and stdin is left empty; plus a contrast run proving the ordinary spawn does put the token in argv (so the test can actually fail).

Results

  • tsc --noEmit: clean.
  • Full suite (npm test): 2015 pass, 0 fail (the "Could not report code coverage" line is a pre-existing harness warning, unrelated).

Left open / needs the same change when rebased

No competitor names, no subscription backends, nothing written to fine-tune data.

A hidden check's command is a secret the worker must not learn, but spawned
through the shell the ordinary way it becomes an argv word and shows up in
/proc/<pid>/cmdline (world-readable) and ps. A runs+value check that executes
the worker's own deliverable hands that deliverable a process whose command
line it can read — even inside a per-check PID namespace, where a child can
still read its parent's cmdline.

runCommand gains hideCommand: when set (Linux/macOS), the shell is spawned as
'<sh> -c eval "$(cat <&3)"' and the real command is streamed in on fd 3, so
the only thing in argv is that fixed wrapper. fd 3 rather than stdin so a
check's stdin is untouched. Wired for every hidden-check spawn: the bar's
runCheck, the mutation builtin's runs, the drafted-check trials in
preflightCriteria, and the reference value-check try.

Note in withhold.ts that masking is exact-substring only (a convenience for
Maat's own records, not the confidentiality boundary).

Test: a hidden-command run whose body reads its own and a child's cmdline and
asserts the command's token is absent, that it still executes and propagates
its exit code, and that stdin is left empty; plus a contrast run proving the
ordinary spawn does leak the token. Linux-only (skipped on macOS).
@solvyxtech

Copy link
Copy Markdown
Owner Author

Review of #38 against the goal-1 requirements (#40 makes the same change on top of #36). The approach is right, and it's the one #40 uses too. Four gaps:

  1. fd 3 is inherited by everything the check starts. eval "$(cat <&3)" closes fd 3 only in the $(...) subshell. The evaluating shell keeps it open, so the deliverable a value check runs inherits it. Repro: the hidden command sh -c "(true <&3) && echo CHILD-HAS-FD3" prints CHILD-HAS-FD3 under this wrapper. By then the socket is at EOF, so the text can't be re-read through it. But the requirement is "children don't inherit the descriptor", and a backgrounded server holding fd 3 also keeps the parent's socket open. Fix: read into a variable, exec 3<&-, then eval. Keep check text and API keys away from the worker #40 uses __maat_c=$(cat <&3) || exit 125; exec 3<&-; eval "unset __maat_c; $__maat_c", so the variable is unexported and unset before the check runs.
  2. Call sites. bar runCheck, mutation, preflightCriteria and the reference try are covered, gated on hidden / stray. Not covered: mission assertions (src/mission.ts runAssertions). Redraft checks that pass before the work; discrimination gate opt-in #34's late pre-work tries and Review objections must run: --review-executable #28's objection runs aren't on this base, so they'll need the flag on rebase. Keep check text and API keys away from the worker #40 adds a source test that fails when a runCommand in bar/criteria/mission, or the reference check.run call, lacks hideCommand: true, so a rebased caller can't slip through.
  3. Tests. All three skip off Linux, so a macOS npm test (the 2015-pass run in the description) runs none of them. They read only /proc/$$ and one child's parent, not every process's cmdline and environ, and they don't go through runCheck or preflightCriteria. Keep check text and API keys away from the worker #40's suite scans all processes (/proc on Linux, ps -E on macOS) with an unhidden control, walks every ancestor from a deliverable started by a hidden bar check and by a drafting trial, and checks fd inheritance. It passes 14/14 in node:22-bookworm as a non-root user and on macOS.
  4. Not in argv, but in memory. Under same-uid with ptrace_scope=0 (it's 0 in node:22-bookworm), Maat's memory holds the checks. Worth adding to the "left open" list next to Run the worker's tools as another user: --worker-user (builds on #27) #29's CHECK_INIT/checkSpec.

Your withhold.ts note on exact-substring masking is good and isn't in #40. Suggestion: since #40 is a superset on the goal-1 side and sits on top of the chain, land the check-text change through #40, and keep the withhold.ts doc note from here (or I can carry it into #40). Happy to go the other way if you'd rather fix (1) here and add the mission site. In that case I'll rebase #40 onto it and drop my run.ts/call-site commit.

@solvyxtech

Copy link
Copy Markdown
Owner Author

Superseded by #40 in claude/integration-all@3d1ca16 (#49). The two PRs make the same change and conflict in run.ts, bar.ts and reference.ts. Integration keeps #40's wrapper, which closes fd 3 before the check runs, and its call-site coverage, mission assertions included. Your withhold.ts note on exact-substring masking is carried over, reworded for the fd-3 mechanism. This branch is not merged into integration.

solvyxtech added a commit that referenced this pull request Oct 8, 2026
Merges #24-#48 except #38 (superseded by #40). CI green on 64c3870: macOS, Linux full suite, privsep.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants