Skip to content

Run the worker's tools as another user: --worker-user (builds on #27) - #29

Open
solvyxtech wants to merge 9 commits into
claude/hide-checksfrom
claude/priv-sep
Open

solvyxtech wants to merge 9 commits into
claude/hide-checksfrom
claude/priv-sep

Conversation

@solvyxtech

@solvyxtech solvyxtech commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Builds on #27 (which builds on #24–#26). Merge #27 first.

Problem

In unattended runs with hidden checks, the worker's tools ran as Maat's own uid, so the worker could read anything Maat could. A Grok worker was seen grepping /, /opt, ~/.grok, Maat's dist and .maat/ for its checks. #27 masks what Maat writes. The remaining leaks all come from sharing one user: the ref-try-* dir in /tmp, .maat/mission/contract.yml, the judge's session store under the same HOME, ps, Maat's memory, and earlier bench logs in /work.

What this adds: --worker-user <name> / MAAT_WORKER_USER

It is opt-in and Linux-first. Maat runs as root, or with passwordless sudo to the worker user. With no flag, behaviour is unchanged, including the macOS desktop app.

  • Worker commands run as the worker uid/gid: bash, background jobs, and the ACP worker agent (Grok Build / OpenCode) together with every builtin it runs itself. Each gets a scrubbed environment, the worker's HOME, and /tmp as TMPDIR.
  • File tools (read/write/edit/list/grep/inspect) are done by dist/fs-helper.js, which runs as the worker. Access is decided by the kernel, symlinks included, and files the worker writes are owned by the worker.
  • State dir: MAAT_STATE_DIR, default /var/lib/maat/<session> (root) or ~/.local/state/maat/<session>, mode 700. It holds the journal, receipts and full twins, integrity, out/, the mission contract and state, and Maat's TMPDIR (reference tries, check copies, drafts, judge scratch). Records from earlier jobs are seeded into it, and the mission contract is moved out of the task folder. done.yml, bg/ and the mission library stay in the project. When the job ends, everything is copied into .maat/, owned by the project owner.
  • Judge and ask stay on Maat's uid, with a HOME the worker cannot read: Maat tightens it to 700 or refuses to start.
  • Hidden checks run as Maat in the copy-on-run tree. Anything a bar run leaves in place is handed back to the worker. Git uses safe.directory for Maat only.
  • ps: with root and CAP_SYS_ADMIN, the worker's processes share one PID namespace with a private /proc, entered with --no-new-privs and an empty capability bounding set. Otherwise Maat prints a warning on stderr. Details in docs/privilege-separation.md.

Bench

run.py and container/* now run Maat as root with --worker-user agent and --cap-add=SYS_ADMIN. BENCH_PRIVSEP=0 and BENCH_PIDNS=0 turn these off. Judge credentials go to /root. Tasks run in a container-local /var/lib/bench-work (711; finished task folders and logs are root-only) and are exported to /work at the end, because the host bind mount ignores chown and modes. Graders get safe.directory. The graders stay root-only, as before.

Verification

  • npm run check passes (1985 tests, e2e, self-check), run with ~/.local/bin removed from PATH. Bench pytest: 25 passed.
  • test/privsep.test.ts passes as root in node:22-bookworm / maat-bench, both with and without CAP_SYS_ADMIN. As the worker it checks that the state dir, the reference-try dir, the mission contract and the judge HOME are all denied; that bash and the file tools run as the worker uid, cwd and git included; that the worker's /proc does not show Maat; that receipts are in the project after the job and Integrity.verify passes; and that the default (no flag) is unchanged. A new privsep-linux CI job runs it as root.
  • Real container, fix-git, nvidia/nemotron-3-ultra-550b-a55b:free: passed. While the job ran, docker exec … runuser -u agent -- cat /var/lib/maat/<session>/log/<file> and ls <state>/tmp returned Permission denied, as did ls /root, ls /var/lib/bench-work and reading an earlier repetition's folder and log. The worker's /proc listed only the namespace init.

Still open

  • In sudo mode (Maat not root) there is no PID namespace.
  • The worker's network access is not restricted.

@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: requesting changes (posted as a comment because GitHub won't let this account request changes on its own PR; please treat it as a request for changes).

Pairing rules look fine. The PR is claude/priv-sep into claude/sync-local-work, not main. It adds no subscription backends and no competitor names in product copy (Grok, OpenCode, OrbStack and Docker Desktop are only mentioned as runtime context), and it doesn't touch fine-tune data. The design is sound too: separate uid, a 700 state dir, the fs-helper running as the worker, the judge HOME tightened, and a PID namespace entered with --no-new-privs and --bounding-set=-all. I'm reviewing only the three commits on top of #27 (59613b3, bb3592a, 113455e).

Blocking: root still follows paths the worker controls

The whole point here is that the worker is hostile. It owns the project (bench hand_over, and the test fixture chowns it), so anything Maat does as root under <project>/ has to be safe against symlinks and swaps.

  1. src/background.ts:103–105: root writes to any file through a planted symlink. mkdirSync(<cwd>/.maat/bg) and openSync(<cwd>/.maat/bg/<id>.log, "w") run as Maat (root) before giveToWorker. The worker can create .maat/bg/<next id>.log -> /etc/sudoers.d/x (or /root/.bashrc, or a file in /var/lib/maat/<session>/). Ids are just nextId++. Root then truncates that target and the worker's own background command writes its output into it. That's a full escape to root. Fix: create the log as the worker (an open op in fs-helper, or let the worker's shell redirect >> log), or at least O_CREAT|O_EXCL|O_NOFOLLOW plus an lstat check that .maat and .maat/bg are real directories and not symlinks.

  2. src/privsep.ts:448/457 (handBack) and :512 (publish chown): a race between lstat and readdir. handBack runs after every bar run while the worker's background jobs can still be running. The walk does lstatSync(p) and then readdirSync(p) on the path. If the worker swaps build/ for a symlink to /etc, /root/.grok or the state dir in that gap, every root-owned entry in the target gets lchowned to the worker. publish has the same pattern, chowning to the project owner, and in the bench that owner is agent. Fix: don't walk by path as root. Something like chown -R -P --from=<maatUid> <uid>:<gid> -- <dir> works, since coreutils/fts traverse with openat and O_NOFOLLOW. Otherwise re-check realpath and dev/ino after opening each directory and refuse anything outside this.project.

  3. src/privsep.ts:289 (seed): symlinks get copied into the private state dir. cpSync without dereference copies a symlink as a symlink (and makes relative targets absolute). Say an earlier job or mission feature leaves .maat/receipts -> /somewhere; publish's cpSync hits "dir to non-dir" and fails there, so the link survives. The next seed then puts that link into /var/lib/maat/<session>/receipts, and Maat writes its receipts as root straight through it. Fix: lstat every entry and skip or refuse symlinks (or anything not owned by Maat or the project owner) in both seed and publish. A test for this would help.

Should fix

  1. The PR conflicts with its base (mergeable_state: dirty). src/acp.ts and src/opencode.ts conflict with 4d94f37 (OpenCode Zen-only, opencode://zen). After the rebase, also update the context comment in run-in-container.sh that still says MAAT_JUDGE_URL=opencode://subscription.
  2. No CI ran on this PR, because checks only trigger for PRs into main, so the new privsep-linux job hasn't actually run yet. Please confirm it passes once the stack lands, or temporarily trigger on claude/** bases.
  3. An OpenCode worker may lose its credential. workerEnv drops every name matching KEY|TOKEN|SECRET|AUTH…, spec.env included. That's fine for Grok, which reads ~/.grok/auth.json, but an OpenCode worker whose model key arrives through env will start with no key. Either pass through an explicit allowlist for the worker agent's own credential, or document that the worker's login must live in the worker's HOME.

Nits

  • Without a PID namespace, hidden-check command lines stay visible in ps, and that's only a stderr warning. For unattended runs that have hidden checks, consider defaulting MAAT_WORKER_PIDNS to required when Maat is root.
  • SECRETISH will also strip harmless names like AUTHOR_NAME. Fine, but worth a comment.

Everything else reads well: the tests that deny the state dir, the ref-try dir, the contract and the judge HOME, plus the bench lock/write_private/export flow. Happy to re-review once 1 to 3 are fixed, ideally each with a root-only test that plants the symlink or does the swap.

@solvyxtech

Copy link
Copy Markdown
Owner Author

Progress on both reviews (pushed 11f7cdd; npm run check 2001/2001, e2e + self-check green; privsep container tests 13/13 with SYS_ADMIN):

Fixed:

  • Background logs are created by the worker; root no longer creates or opens files under .maat/bg.
  • handBack walks open directory handles; nothing outside the project is chowned (the old code chowned 38/40 root-owned files outside the project in the new test).
  • seed/publish go through the worker-side file helper; symlinks never enter the state dir. The path-following recursive delete in seed() is gone.
  • safe.directory=* removed. Maat's own git calls in the project run as the worker. Bench graders grade a sanitized root-owned copy (cut-down repo config, hooks and attributes removed, no system/global config).
  • OpenCode worker gets only its own key (per-backend allowlist).
  • CI runs on PRs into claude/**.

Still open (next commits on this PR):

  • --check-user: hidden/task/mission checks run as a separate unprivileged account with a scrubbed environment (no API keys), in their own PID namespace + private /tmp when available.
  • --worker-strict, plus an isolation line in every receipt; CI job with SYS_ADMIN so the namespace test is required.
  • Known-limitations section in docs/privilege-separation.md (shared network; root reading worker files by path in snapshot/mutation code).
  • bench/local/crosscheck.py still sets safe.directory=* (came in with Sync local work: separate judge, OpenCode, bench container runner #23), to be fixed with the rest.

@solvyxtech

Copy link
Copy Markdown
Owner Author

This round: check account, strict mode, isolation line, CI namespace, crosscheck

Commits: e83a192, bff1f9b, 0d4aa24, 20a7207.

--check-user <name> (MAAT_CHECK_USER), with --worker-user. Task checks run as this account, not as Maat. That covers hidden checks, drafted and --criterion task checks, the reference check's try, mission assertions (in the bar and at milestone seals), and pre-work criterion tries.

  • The environment is an allowlist: PATH, a fresh empty HOME per run (removed afterwards), LANG and TERM. No provider keys and no MAAT_* variables.
  • As root with CAP_SYS_ADMIN, each check run is the init of its own PID namespace (unshare --pid --fork --mount-proc). It gets an empty tmpfs on /tmp, /var/tmp and /dev/shm, then drops to the account with setpriv --clear-groups --no-new-privs --bounding-set=-all.
  • Copies and reference-check files live in <state dir>.check: owner Maat, group the check account, mode 710. Copies are handed to the check account. Reference files are 750/640 to its group. The worker can't enter the folder.
  • Checks that must run in the project itself (project checks, task checks too large to copy) and mutation runs run as the worker.
  • The check account is refused if it is root, Maat's user, the worker, or has a group that contains the worker.

--worker-strict. Maat refuses to start (exit 2, with the reason) unless the worker account, the check account and the PID namespace (the worker's, and each check run's with a private /tmp) can all be set up. Without it, Maat carries on. Every receipt and the journal (one note entry, kind: "isolation") then get one line giving the isolation actually in effect, for example isolation: worker uid 1001, check uid 1002, pid namespace on.

CI. The privsep-linux container runs with --cap-add=SYS_ADMIN. AppArmor is set to unconfined, because Docker's default profile denies mount. MAAT_TEST_REQUIRE_PIDNS=1 makes the namespace assertions fail there instead of skipping. The test step uses HOME=/root, because the job container's /github/home is a bind mount Maat refuses as the judge's HOME. That refusal cancelled every root test in this job at 11f7cdd too.

Bench. The image adds checker (uid 1002, its own group) beside agent, and the tags move to :agentcu. run.py passes --check-user checker when privsep is on. crosscheck.py no longer sets safe.directory=*. It grades through run.grade_safely and runs each check on a run.sanitized_copy.

Docs. docs/privilege-separation.md gets sections on the check account and strict mode, plus "Known limitations": the network is shared between accounts; snapshot and mutation code still read worker files by path; the agent user's HOME and /tmp are shared between bench tasks in one container.

Tests

  • test/privsep.test.ts, root in node:22-bookworm. The new tests assert that:
    • a check runs with the check account's uid, a fresh empty HOME and no variables beyond the allowlist (the planted provider keys are absent);
    • a project check runs as the worker, and a mission assertion runs as the check account;
    • the worker cannot read, list or enter the reference-check folder, and the check account can read it;
    • the isolation line is in every receipt and appears once in the journal;
    • --worker-strict refuses with no PID namespace, both in the library and through the CLI's exit 2;
    • a check account equal to the worker is refused.
    • With CAP_SYS_ADMIN, the check sees fewer than 10 processes and not the host's /tmp.
  • Local containers: with --cap-add=SYS_ADMIN 22/22 pass. Without it, 21 pass and 1 is skipped (the namespace test, which is required in CI).
  • GitHub privsep-linux: 22/22 pass, 0 skipped.
  • bench/local/tests: 29 pass. That includes a new crosscheck test with a hostile repository: fsmonitor, filters, hooks, pager, alias and include fire nothing. A control run with sanitizing disabled fails it.
  • npm run check on the Mac, with ~/.local/bin removed from PATH: green, 2002 tests plus e2e and self-check.
  • GitHub macOS typecheck-and-test: 6 tests in test/local-ask.test.ts are cancelled ("Promise resolution is still pending"). They were cancelled the same way at 11f7cdd, before this round, and they pass locally. This change doesn't touch them.

Tyler Skelton added 9 commits October 7, 2026 15:43
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.
@solvyxtech
solvyxtech changed the base branch from claude/sync-local-work to claude/hide-checks October 7, 2026 22:49
@solvyxtech

Copy link
Copy Markdown
Owner Author

rebased onto claude/hide-checks@59cf962, CI green

@solvyxtech

Copy link
Copy Markdown
Owner Author

Defensive review — privilege separation (--worker-user / --check-user), at e7486c4

Scope: every place privileged Maat opens/creates/copies/chowns/renames/deletes a path the worker can write; symlink/TOCTOU/hardlink/FIFO/inherited-fd; environment scrubbing; whether the worker can read Maat's env or memory. Read + the root test matrix in test/privsep.test.ts (which only runs under the CI Linux-root job, not on my box — so the dynamic claims below are reasoned from the code and backed by those tests, not re-executed by me).

The three earlier findings are fixed (confidence: high)

  1. Background job logs no longer opened by root at a worker path. src/background.ts:102-120: under privsep the log is created by the worker's own shell — ps.execSpec("/bin/sh", ["-c", 'mkdir -p -- "$1" && exec >"$2" 2>&1 … ', …]) — so the folder and the .maat/bg/<id>.log are opened with the worker's permissions. Root never opens a path in the worker's tree, so a symlink planted at that path can't redirect a root write.
  2. chown-by-path replaced with fchown on O_NOFOLLOW descriptors. src/privsep.ts:890-985 (handBack): the project is re-opened and verified by dev/ino, every dir is opened openat-style via /proc/self/fd/<fd>/<name> with O_RDONLY|O_DIRECTORY|O_NOFOLLOW, ownership is changed with fchown on the descriptor, files are re-checked isFile && uid==maat && nlink===1 && dev==project before chown. A folder swapped for a symlink mid-walk fails to open and is skipped; hardlinks (nlink!==1) and cross-device entries are left alone. The check-then-use window is closed.
  3. Symlinks in an old .maat/ are no longer copied into the private state dir. src/fs-helper.ts:89-104 (pack, run as the worker) emits only regular files (opened O_NOFOLLOW, re-fstat'd isFile) and dirs; everything else goes to skipped. On Maat's side src/privsep.ts:638-650 (seed) runs each entry through safeRel (rejects .., absolute, empty, NUL) and privateParent (every ancestor is a real Maat-owned dir, target not a symlink) before writing mode 0600/0700. Nothing in the state dir can point back into the worker's reach.

Steering items from the confidentiality reviewer — does --worker-user/--worker-strict close them?

(2) API keys via /proc/<ppid>/environ: CLOSED by --worker-user (confidence: high).

  • The worker runs as a different uid from Maat (src/privsep.ts:305-308 refuses a worker == Maat's uid or root), and /proc/<pid>/environ is readable only by the same uid (+CAP_SYS_PTRACE). With a PID namespace the worker can't even see Maat's pid (test/privsep.test.ts:233 asserts !pids.includes(process.pid)). So a worker shell command cannot read Maat's environment or memory. This holds in both root and sudo modes.
  • The worker's own environment is also scrubbed far more broadly than scrubbedEnv — workerEnv drops everything matching /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|COOKIE|AUTH)/i plus MAAT_*/MOLT_*/GIT_CONFIG_* (src/privsep.ts:119,774-789), so the narrow 6-name SECRET_ENV allowlist at src/engine.ts:972-979 is not what protects the worker under privsep.
  • Residual (non-blocking, medium): the SECRET_ENV list at src/engine.ts:972 does still govern the degraded/fallback path — when --worker-user is given without --worker-strict and the worker account can't be set up, Maat "carries on WITHOUT privilege separation" (src/cli.tsx:2290-2296) and the worker runs as Maat (root) with only those 6 names scrubbed. There MAAT_JUDGE_KEY, MAAT_API_KEY, XAI_*/GROK_*, and generic *_API_KEY/*_TOKEN stay in the environment the worker inherits. Recommend (a) widen SECRET_ENV to the same regex workerEnv uses, and (b) document that untrusted runs must use --worker-strict (which refuses rather than degrading).

(1) Hidden-check command via /proc/<pid>/cmdline: NOT fully closed, even with --worker-strict (confidence: high that the leak path exists; medium on exploitability).

  • Hidden task checks still run as bash -c "<cmd>" (src/run.ts draftedShell → src/bar.ts:2191-2201; src/criteria.ts for drafted tries). Under --check-user the command is the last argv word of the /bin/sh -c "<cmd>" the check init execs (src/privsep.ts:257-262 CHECK_INIT, :525-570 checkSpec).
  • A runs+value check executes the worker's own deliverable as a child in the same check PID namespace, as the check account. That child is model-written code and can cat /proc/<ppid>/cmdline of its ancestors to recover the hidden command. The per-check PID namespace isolates the worker process from the check, but it does not isolate the deliverable (which the check itself launches) from the check's own command line.
  • Why --worker-strict doesn't help: strict only guarantees the check account + PID namespace exist; it changes nothing about how the command is passed (argv).
  • Exfil is bounded by the documented "network is shared" limitation (docs/privilege-separation.md:274-279) — the deliverable can't write where the worker reads (check copy//tmp are the check account's and discarded), so it has to reach the worker over the network. Benches that don't run --network none leave that open.
  • Concrete mitigation to consider: pass the check command to the shell on a file descriptor / stdin (sh -s, or bash -c 'eval "$(cat <&3)"' 3<…) instead of as an argv word, so it never appears in /proc/<pid>/cmdline. That closes the leak for both check-account and worker-run checks without a namespace. (Leaving this as a note rather than a grok/ PR since hidden-check confidentiality is another reviewer's area — flagging so it isn't lost.)

Other observations

  • Copy-for-check follows worker symlinks by path (non-blocking, medium; already documented). src/scratch.ts:198-217 (copyOne): copyDir decides file-vs-symlink from the readdir dirent, then copyOne does copyFile(s, d) which follows a symlink. Between the readdir and the copyFile the worker can swap a regular file for a symlink → root copies the target's contents (e.g. a root-only file) into the check copy, which the check account (worker's code) then reads. This is exactly the "snapshot/mutation code still read worker files by path" limitation in docs/privilege-separation.md:280-287, so it's known — noting it's reachable in the copy path too, and that re-checking with O_NOFOLLOW + fstat isFile (as fs-helper pack already does) would close it.
  • Env-scrub for the worker agent's own credential is correct (confidence: high). src/acp.ts:141,266,553 + src/privsep.ts:802-803,774-789: only the names in workerCredentialEnv (OpenCode: OPENCODE_API_KEY) survive the scrub, and only for that agent's process — not for the worker's shell commands. This fixes the earlier "OpenCode key may get stripped" concern without widening the hole.
  • PID-namespace leader can be orphaned on an uncaught SIGKILL of Maat (non-blocking, low). src/privsep.ts:739-771: the unshare leader is cleaned up via close() on process.on("exit") and killNamespace(), and --kill-child ties the namespace to the leader. But if Maat is SIGKILLed, no exit handler runs and the leader (while :; do sleep; done) survives as an orphan with its namespace. In the bench the backstop that kills the whole tree covers this; outside it, a SIGKILL leaks a namespace. Minor.

Status

Typecheck clean. test/privsep.test.ts covers the root paths well (worker uid, EACCES on the state dir, <10 pids in the worker's /proc, Maat's pid invisible, check-account allowlisted env with planted keys absent, private /tmp, records round-trip). Nothing merged; comment only.

@solvyxtech

Copy link
Copy Markdown
Owner Author

Follow-up on finding 1 (hidden-check command visible in /proc/<pid>/cmdline): I've opened the fix as #38 into claude/hide-checks, since that's where hidden checks and runCommand live. It adds hideCommand to runCommand and streams the command in on fd 3 (<sh> -c 'eval "$(cat <&3)"') so it never lands in argv, wired for every hidden-check spawn on that branch.

This PR's privsep path needs the same change when rebased. src/privsep.ts CHECK_INIT (~257-262) and checkSpec (~525-570) still build the check as … sh -c command, so the command remains an argv word under --worker-user/--check-user — the per-check PID namespace isolates the worker but not the deliverable the check itself launches, which can read its parent's cmdline. When #38 lands under this branch, checkSpec should pass the command on fd 3 after the setpriv drop. Until then, privsep does not close this leak.

@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