Files
stack/docs/plans/2026-10-12_session-confinement.md
T

112 lines
5.5 KiB
Markdown

# Role sessions confined to their own files (2026-10-12)
Status: written by Sage, lead. It follows `docs/plans/BRIEF-TEMPLATE.md`.
It answers Vikunja P19 #232 (security, priority 5), which the stack's
own reviewer session found in Jason's first test and Mos checked at
9438880e (sage-0102). Gitea #1586. It comes before every other row from
that test.
## Role sessions confined to their own files
### Problem
The tool gate checks the paths of Read, Write, Edit, Grep and Glob
(`packages/harness/src/gate.mjs:22-25`) and nothing else. Bash, which
the coder, cto, reviewer and researcher roles have (`roles/*.json`
`tools`), runs as the host's user with only a user and PID namespace
(`packages/seat/src/session.mjs:39`). It sees the whole filesystem. In
the test the reviewer's Grep of the coder's workspace was refused and
its Bash `ls` of the same directory ran. The same shell can read
`~/.config/mosaic-dev/secrets`, `~/.mosaic`, `~/.ssh`,
`~/.git-credentials`, every other workspace and run directory, and the
canonical checkout. The harness README says so (`README.md:162-173`, "The
gate is not a sandbox"); slice 1 accepted that limit (S0 line 5), and
Jason's test shows the stack can't run role sessions on its own source
with it.
### Owner and reviewers
Owner: Darkwing, next after their row 63 packet (e176bb53).
Reviewers: Filbert and Dewey, both required, since this sets a security
boundary.
### Files owned
- `packages/seat/src/session.mjs`, the spawn and its arguments;
`packages/seat/src/sandbox.mjs`, new
- `packages/cli/src/launcher.mjs`, only where it passes the paths a
session needs
- `packages/harness/src/gate.mjs`, the header comment only
- `packages/harness/README.md` "Limits", `packages/seat/README.md`
- `packages/cli/systemd/mosaic-bus.service.in`, one `EnvironmentFile=`
line for the session credential, and its paragraph in
`packages/cli/README.md` (lead decision 118)
- tests under `packages/seat/tests/`, `packages/cli/tests/` and
`packages/harness/tests/`
- `agents/darkwing/work/queue-<row>/`
### What ships
- Every role session's process tree runs inside a bubblewrap
(`/usr/bin/bwrap`, 0.12.0 on the host) mount namespace built from an
empty root. It sees, read-only: the system directories the harness and
its tools need (`/usr`, `/etc` and the like), the pinned harness
binaries, and the repository files the runner loads. It sees,
read-write: its own workspace and its own run directory. It gets a
private `/tmp` and an empty `HOME`, plus whatever the harness needs
there, created inside the run directory. It sees nothing else: no
data root, no other workspace or run directory, no `~/.config`, no
`~/.mosaic`, no `~/.ssh`, no checkout outside what the runner loads.
The PID namespace and kill-on-parent-exit stay.
- The packet lists every bind with its source, mode and why the session
needs it. A bind the reviewers can't tie to a need comes out.
- A missing or failing `bwrap` refuses the launch with
`sandbox-unavailable`, through the broker's `refuse()`. There is no
unconfined fallback.
- A confined Claude Code session can't reach Jason's
`~/.claude/.credentials.json`, which is how every role session logs
in today (Darkwing, 2026-10-12T19:52Z: the bus host has no
`ANTHROPIC_*` or `CLAUDE_CODE_OAUTH_TOKEN` variable). Binding that
file in is ruled out: Claude Code replaces it by rename on refresh,
and the session would hold a refresh token that can log out Jason and
every seat. The credential comes from a 0600 file under
`~/.config/mosaic-dev/secrets/`, which the bus unit loads with
`EnvironmentFile=`, as `CLAUDE_CODE_OAUTH_TOKEN` (or
`ANTHROPIC_API_KEY`), and `ENV_ALLOW` passes it on
(`packages/seat/src/session.mjs:61-66`). Which one is Jason's
choice. Sessions get nothing from `~/.claude`. With no credential a
Claude Code launch refuses before it starts, naming the missing file.
- The network stays as it is. The harness needs its model endpoint, and
the README keeps saying the network isn't confined.
- Tests run a real confined session's shell and prove these refuse:
reading a file under a stand-in `~/.config/mosaic-dev/secrets`, under a
stand-in `~/.mosaic`, in another instance's workspace, in another run
directory, in the data root's store, and in the canonical checkout
outside the runner's files; writing outside the workspace and run
directory. They prove the session can still read and write its
workspace, run its harness and reach its tool socket. A test kills a
bind from the list and shows the matching check fail, so the checks
can't pass on an unconfined session.
- The harness README's "Limits" section says what is confined now and
what isn't (network, abstract sockets, same UID outside the namespace).
Suites: the seat, cli, harness, bus and webui node suites, and every
`scripts/test-*.sh`. The packet also shows one real coder launch on the
host with Claude Code, reading a stand-in secret from its Bash and
getting a refusal.
### Out of scope
Network confinement. Different UIDs per role. Containers (rows 86 and
87, I1). Removing Bash from any role. The tool gate's own checks.
### Gate
Filbert and Dewey approve on the row's issue, naming the candidate
manifest. The suites above pass on Sage's gate rerun. The launcher runs
in the bus host, so the change is live at the next bus restart, which
ends every running session (decision 109 notices to Mos). Until it is
live, whether bash-capable sessions run is Jason's call. The row lands
only after Jason has chosen the credential and its file is in place;
without it the PM would not log in after the restart.