Files
stack/agents/darkwing/work/queue-92/build.md
T
jason.woltjeandClaude Opus 5.5 4194f05673 darkwing: row 92 (#1586) session confinement packet, round 1, manifest bc132570
Build, patch and manifest for the bwrap sandbox, the credential check and
the unit's EnvironmentFile= line. Gate 14 suites rc=0; 26 mutants killed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 16:34:44 -05:00

17 KiB

Row 92, role sessions confined to their own files, build (Darkwing)

Issue #1586. Brief: docs/plans/2026-10-12_session-confinement.md (blob 21ee4458, queue rev 741, decision 118), with Sage's direction on decision 119 (Q126, b66bfe71, queue rev 754): any provider credential that ENV_ALLOW carries passes the launch check, the credential file's path appears once in the unit template and once in the README paragraph, and tests use stand-ins. Base af01465c. No commit from the base to the packet's commit touches the 13 paths.

build.patch is git diff --cached --binary af01465c of the 13 paths. Apply it with git apply --index to a detached worktree of the base, and sha256sum -c candidate-manifest.sha256 then gives 13/13 OK.

File What changed
packages/seat/src/sandbox.mjs new: the bind table, the bwrap command line, the probe
packages/seat/src/session.mjs the runner spawns in the sandbox; SESSION_CREDENTIALS; sessionExit
packages/seat/tests/sandbox.test.mjs new: real confined sessions against stand-ins, binds broken on purpose
packages/seat/tests/session.test.mjs the session's HOME; the exit mapping; the credential sets
packages/seat/README.md "The sandbox (#1586)" in place of the PID namespace limits
packages/cli/src/launcher.mjs two new refusal steps; the paths a session needs; recovery matches bwrap
packages/cli/tests/launcher.test.mjs both refusals; the real Claude Code coder; credential stand-ins
packages/cli/tests/fixtures/launch-host.mjs passes the fake adapter as a read-only file
packages/cli/tests/host.test.mjs the unit has one EnvironmentFile=, the README names it
packages/cli/systemd/mosaic-bus.service.in the EnvironmentFile= line and its comment
packages/cli/README.md the two refusals, the credential, the unit paragraph
packages/harness/src/gate.mjs header comment only
packages/harness/README.md "Limits" rewritten for the sandbox

The sandbox

spawnSession now runs bwrap <namespaces> <binds> --remount-ro / --setenv HOME <run>/home --setenv TMPDIR /tmp --chdir <run> -- setpriv --pdeathsig KILL -- node runner.mjs <run>. The root is an empty tmpfs; each bind lands at its host path, so the paths in session.json mean the same inside and out. The namespaces are --unshare-user --unshare-pid --unshare-ipc --as-pid-1, so the runner is still pid 1 of its own PID namespace.

Bind table

In mount order, as binds() returns them (each entry carries its why in the code too):

Source Inside Mode Why
/usr same ro the system's programs and libraries: sh, node, git, coreutils
/bin, /lib, /lib64, /sbin same symlink (or ro if a real dir) the host's links into /usr
/etc same ro name resolution, CA certificates, passwd for the user's name
(none) /dev private null, zero, urandom and a pty
(none) /proc private the session's own PID namespace
node's real path, if outside /usr node's path ro node runs the runner and the gate
the 10 RUNNER_FILES same ro the repository files the runner loads
the harness adapter same ro the runner starts it each turn
<repo>/node_modules (real path), pi only <repo>/node_modules ro pi and its packages, on the session's PATH
MOSAIC_CLAUDE_BIN's real path, Claude Code only, if outside /usr MOSAIC_CLAUDE_BIN ro the pinned Claude Code binary
the broker socket same ro the runner claims and calls through it
the launch socket, the launching role only same ro the launch tool calls it
the instance's workspace same rw where the harness works
the run directory same rw turns/, session/, tools.sock, turn.done, home/, tmp/
<run>/bundle same ro stays as the host wrote it
<run>/session.json same ro stays as the host wrote it
<run>/tmp /tmp rw a private /tmp on disk in the run directory

HOME is <run>/home, made empty (0700) before the spawn. Nothing else exists: no data root beyond the run directory, no other workspace or run, no ~/.config, ~/.mosaic, ~/.ssh or ~/.claude, no checkout beyond the runner's files. A bind to a socket is read-only as a mount, and connect() on a socket in a read-only mount still works; that is what the broker and launch sockets need.

Refusals

Two steps join the launcher's checks, both before family and capacity and both through refuse(), so each is an action.refused event and a launch log line:

  • no-session-credential: a Claude Code launch when none of the SESSION_CREDENTIALS sets is fully set in the bus host's environment: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL. A pi launch doesn't check.
  • sandbox-unavailable: sandboxProblem() runs bwrap with the namespaces and the system binds and true. A missing bwrap or a failing one refuses with the first line of bwrap's stderr. There is no unconfined path left: namespace: false and UNSHARE are gone.

Choices to review

  1. setpriv's parent-death signal, not bwrap's --die-with-parent. --die-with-parent sets the death signal on bwrap itself as well as on the child. bwrap's parent is the bus host, so a host that dies hard would take every session with it, and the launcher's recover() (the next host finds the sessions and ends their runs) would have nothing to recover. That breaks the two "host that died hard" tests. So the runner gets setpriv --pdeathsig KILL: it dies when the outer bwrap does, as it did with unshare's --kill-child, and bwrap outlives the host. The race: if the outer bwrap dies between the fork and setpriv's prctl, the runner keeps running. The host ends the launch when the outer bwrap exits, and endLaunch voids every capability of the run, so the runner's next poll gets unauthenticated and it exits (runner.mjs, SESSION_GONE). A turn already running finishes first, up to the turn timeout. The window to hit is the few instructions between the fork and the prctl.
  2. The refusal names the variables, not the file. The brief said a Claude Code launch with no credential refuses "naming the missing file". Sage's one-path rule puts the path only in the unit template and the README paragraph, so the message names the variables and "the bus unit's EnvironmentFile". The README paragraph names the file.
  3. Any provider credential passes, per decision 119. The check accepts the OAuth token, an API key, or a proxy's token with its base URL, so the proxy-issued key (Vikunja #237) swaps in by editing the file. A proxy token without its URL doesn't count.
  4. Pi sessions see the credential variables too. ENV_ALLOW passes them to every session, so a pi session's bash can print them. That was so before this row for whatever the shell had; it is now so for the file's credential. The cli README says so. Narrowing ENV_ALLOW per harness would be a change to sessionEnv, outside this brief.
  5. claude from PATH finds only /usr. With MOSAIC_CLAUDE_BIN unset, the adapter runs claude from PATH, and inside the sandbox that is only a claude under /usr; a ~/.local/bin/claude isn't there. The installed unit pins MOSAIC_CLAUDE_BIN (decision 91), so only a hand-started bus meets this. The cli README says so.
  6. Edits past the brief's file list. host.test.mjs gains the unit's one-EnvironmentFile= check, and launch-host.mjs passes the fake adapter as a read-only bind (the sandbox otherwise hides it). The cli README's refusal table gains its two rows, beyond the unit paragraph the brief names.
  7. The test fixture strips the shell's credentials. The cli fixture's env started from process.env, so a credential in the shell running the tests reached test sessions. prepare() now deletes every SESSION_CREDENTIALS variable and sets a stand-in.
  8. NoNewPrivileges. The unit sets NoNewPrivileges=yes. bwrap here is not setuid and uses user namespaces, which work under no-new-privs: setpriv --no-new-privs around sandboxProblem() returns null.

Probes

Scratch probes, not part of the candidate:

  • bwrap under setpriv --no-new-privs: works (choice 8).
  • The host's claude inside the Claude Code binds with an empty environment: claude --version gives 2.1.296; DNS resolves api.anthropic.com and a request reaches it (405); HOME is <run>/home; claude -p with no credential prints "Not logged in", exit
    1. The founder's login in ~/.claude is out of reach. Afterwards HOME holds the .claude and .claude.json claude made itself.

Tests

packages/seat/tests/sandbox.test.mjs (new):

  1. RUNNER_FILES is exactly the runner's import graph plus the three files the harness starts: the test walks the imports, so a new import that isn't bound fails here, not in a live session.
  2. sandboxProblem names a missing bwrap and one that fails.
  3. Every bind has a why; bundle/ and session.json are mounted read-only after the read-write run directory; /tmp is last; no launch socket for a role that may not launch; no node_modules for Claude Code.
  4. A real confined session (the real runner, a real broker, an adapter that runs each message's body in sh) answers a table of 20 checks against stand-ins under a scratch root: the stand-in ~/.config/mosaic-dev/secrets/stand-in.env and ~/.mosaic, another workspace, another run directory, the data root's bus store, the checkout (AGENTS.md, the seat's own source), writes to /, beside the workspace, in the data root, to a runner file, the bundle and the session file: all refused. Positive: HOME is the run's empty home/, TMPDIR is /tmp, the data root shows only this run, the workspace, the run directory and /tmp take a write and a read, node runs.
  5. Breaking it on purpose: the same session with the two stand-ins bound in fails exactly their two checks; a sandbox from binds() passes all 20; without the workspace bind, exactly the workspace check fails.

packages/seat/tests/session.test.mjs: the existing real-session test now runs in the sandbox and checks the runner's HOME is <run>/home. A new test covers sessionExit (0, 23 and 128 stay codes; 137 and 143 become SIGKILL and SIGTERM, ending killed) and hasSessionCredential (each of the three sets alone passes; a proxy token or URL alone, empty strings and nothing fail; every name is in ENV_ALLOW).

packages/cli/tests/launcher.test.mjs:

  • A Claude Code PM with only a proxy token refuses no-session-credential with the exact message, one log line, no session, no run directory.
  • A bwrap that fails, then a missing one, refuse sandbox-unavailable with bwrap's line and "is missing"; a session's launch request gets the same code through refuse(); no session, no run.
  • The real Claude Code launch. The PM (fake adapter) launches a coder whose harness is Claude Code, with the real adapters/claude and the host's claude pinned by MOSAIC_CLAUDE_BIN. Its credential is a stand-in proxy key, ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL pointing at the scripted Messages API from the harness tests, so no real credential or model is involved. The script makes one Bash call that tries nine things; the coder's reply carries the tool result. Refused: the stand-in secret (FAKE_TOKEN=stand-in-value), the stand-in ~/.mosaic, the PM's run directory, the data root's sessions.json, the checkout, a write outside. Allowed: a write and read in its workspace and in /tmp, a writable HOME ending /home. The reply never contains the secret's value. The test passes in about 3 seconds; a rerun that printed the reply body gave ANSWER [[false,"0 no\n1 no\n2 no\n3 no\n4 no\n5 no\n6 yes\n7 yes\n8 yes"]]. With the stand-in secret added as a read-only bind, the test fails on exactly 'read the stand-in secret': true (real-exposed.txt).
  • The test where the PM launches a coder now also reads both sessions' bwrap command lines: the PM's binds the launch socket, the coder's doesn't. This was the one mutant survivor (below).
  • prepare() strips the shell's credential variables (choice 7).

packages/cli/tests/host.test.mjs: the rendered unit has exactly one EnvironmentFile=, of the form -%h/..., and the cli README contains that line.

The unit renders, and systemd-analyze --user verify on it prints nothing.

Mutants

mutants.sh with agents/darkwing/work/queue-59-review/r1/mut.mjs. Each mutant edits one site (exactly one match), runs the test files it names and restores the file. The run went 2026-10-12 20:43:33Z to 20:47:09Z, after Dewey's row 89 run had ended (mutants.txt).

26 mutants. 23 were killed on the first run. Three needed a second look:

  • bind-checkout and probe-always-ok were killed by a SyntaxError, not by a test. Their replacement text was single-quoted, so it carried a literal \n. I fixed the quoting and reran both. Each is now killed by an assertion (the confined-shell table and the bwrap refusal test).
  • launch-socket-always survived: a coder session given the launch socket passed every test. The coder's sandbox couldn't reach the socket in any test, because no test asked. The launcher test above now reads the command lines, and the rerun kills it.

The table after the fixes, all 26 killed:

Mutant What it undoes Killed by
no-workspace-bind the workspace bind confined shell table
runDir-read-only the run directory is read-write bind order test
bind-all-runs the run bind is the run directory, not its parent bind order test
bundle-writable bundle/ read-only over the run directory bind order test
session-json-writable session.json read-only bind order test
no-private-tmp the private /tmp bind order test
bind-checkout the checkout stays out confined shell table
node-modules-always node_modules only for pi bind order test
runner-file-missing a runner import left out of RUNNER_FILES import walk
no-remount-ro the read-only root confined shell table
host-home HOME in the run directory confined shell table
no-pid-namespace the PID namespace the PM launch test
die-with-parent bwrap's own --die-with-parent added the host-died-hard test
no-pdeathsig setpriv --pdeathsig KILL removed the host-died-hard test: the runner outlives bwrap
exit-code-not-mapped 128+n back to the signal sessionExit test
recover-matches-unshare recover() matching bwrap's command line the host-died-hard test
no-sandbox-check the sandbox-unavailable refusal the bwrap refusal test
probe-always-ok the probe the bwrap refusal test
no-credential-check the no-session-credential refusal the credential refusal test
proxy-key-alone the proxy set needs its URL credential tests
proxy-key-ignored the proxy set counts credential tests
any-variable-will-do every variable of a set credential tests
claude-bin-not-bound the pinned claude bind the real Claude Code launch
launch-socket-always the launch socket only for the launching role the PM launches a coder test
unit-no-envfile the unit's EnvironmentFile= host.test.mjs
unit-envfile-required its leading - host.test.mjs

My three-mutant rerun overlapped Filbert's row 95 run for about a minute: I chained the busy check and the run in one command, so the run didn't stop when the check found Filbert's processes.

Gate

My run on the candidate, in a worktree of af01465c holding the 13 paths, TMPDIR in my scratch (gate.sh, gate-summary.txt). Sequential, each package's own test command from its directory, then every scripts/test-*.sh, with test-task.sh under DOCKER_HOST=unix:///nonexistent. Nothing of another seat's was running: Filbert called the machine clear at 21:30:30Z and my check found no process. 2026-10-12T21:30:49Z to 21:34:10Z, all rc=0.

Suite Result
seat 33 pass
cli 118 pass
harness 59 pass
bus 102 pass
webui 84 pass
test-auth 15 passed
test-conductor 17 passed
test-config 24 passed
test-discord 66 passed
test-extension-package 18 passed
test-foundation 44 passed
test-queue 27 passed
test-release 14 passed
test-task 26 passed; four Docker groups skip (adapter seam, workspace/capability, live task, live recall pair)

No node suite skipped a test. The cli count includes the launch socket check that the third mutant rerun needed; this is its first unmutated run. One test made a compose network, wt_default, at 21:33:58Z; it was empty afterwards and I removed it. The worktree still checks 13/13 against the manifest after the run.