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]>
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 theSESSION_CREDENTIALSsets is fully set in the bus host's environment:CLAUDE_CODE_OAUTH_TOKEN,ANTHROPIC_API_KEY, orANTHROPIC_AUTH_TOKENwithANTHROPIC_BASE_URL. A pi launch doesn't check.sandbox-unavailable:sandboxProblem()runs bwrap with the namespaces and the system binds andtrue. A missing bwrap or a failing one refuses with the first line of bwrap's stderr. There is no unconfined path left:namespace: falseandUNSHAREare gone.
Choices to review
- setpriv's parent-death signal, not bwrap's
--die-with-parent.--die-with-parentsets 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'srecover()(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 getssetpriv --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'sprctl, the runner keeps running. The host ends the launch when the outer bwrap exits, andendLaunchvoids every capability of the run, so the runner's next poll getsunauthenticatedand 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 theprctl. - 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.
- 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.
- Pi sessions see the credential variables too.
ENV_ALLOWpasses 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. NarrowingENV_ALLOWper harness would be a change tosessionEnv, outside this brief. claudefromPATHfinds only/usr. WithMOSAIC_CLAUDE_BINunset, the adapter runsclaudefromPATH, and inside the sandbox that is only aclaudeunder/usr; a~/.local/bin/claudeisn't there. The installed unit pinsMOSAIC_CLAUDE_BIN(decision 91), so only a hand-started bus meets this. The cli README says so.- Edits past the brief's file list.
host.test.mjsgains the unit's one-EnvironmentFile=check, andlaunch-host.mjspasses 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. - 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 everySESSION_CREDENTIALSvariable and sets a stand-in. - NoNewPrivileges. The unit sets
NoNewPrivileges=yes. bwrap here is not setuid and uses user namespaces, which work under no-new-privs:setpriv --no-new-privsaroundsandboxProblem()returns null.
Probes
Scratch probes, not part of the candidate:
- bwrap under
setpriv --no-new-privs: works (choice 8). - The host's
claudeinside the Claude Code binds with an empty environment:claude --versiongives 2.1.296; DNS resolvesapi.anthropic.comand a request reaches it (405); HOME is<run>/home;claude -pwith no credential prints "Not logged in", exit- The founder's login in
~/.claudeis out of reach. Afterwards HOME holds the.claudeand.claude.jsonclaude made itself.
- The founder's login in
Tests
packages/seat/tests/sandbox.test.mjs (new):
RUNNER_FILESis 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.sandboxProblemnames a missing bwrap and one that fails.- Every bind has a
why;bundle/andsession.jsonare mounted read-only after the read-write run directory;/tmpis last; no launch socket for a role that may not launch; nonode_modulesfor Claude Code. - 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.envand~/.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 emptyhome/, TMPDIR is/tmp, the data root shows only this run, the workspace, the run directory and/tmptake a write and a read, node runs. - 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-credentialwith the exact message, one log line, no session, no run directory. - A bwrap that fails, then a missing one, refuse
sandbox-unavailablewith bwrap's line and "is missing"; a session's launch request gets the same code throughrefuse(); 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/claudeand the host'sclaudepinned byMOSAIC_CLAUDE_BIN. Its credential is a stand-in proxy key,ANTHROPIC_AUTH_TOKENwithANTHROPIC_BASE_URLpointing at the scripted Messages API from the harness tests, so no real credential or model is involved. The script makes oneBashcall 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'ssessions.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 gaveANSWER [[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-checkoutandprobe-always-okwere 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-alwayssurvived: 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.