Files
platform/docs/per-user-linux-accounts.md
pastilhasandClaude Opus 5 ed52faae02 correct two claims about agents that the SDK disproves
docs/per-user-linux-accounts.md carried the reasoning that per-user agents were
a large piece of work, and both halves of that reasoning were wrong.

The SDK does have somewhere to put a uid — spawnClaudeCodeProcess, documented
for running Claude Code in VMs and containers — so a member's turn does not have
to become its own process. And the credential claim was backwards: the proxy
holds the OWNER'S credential, reading the owner's own ~/.claude/.credentials.json,
so pointing a member at it spends the owner's account on the member's turns.
The previous handoff had already retracted that one; the doc had not caught up,
which is how a retracted claim stays live.

Corrected in place rather than deleted, with what was believed and why it was
wrong, because the superseded version is the interesting part: the first claim
is what made agents look like a later stage than they are.

Adds the constraint that actually is out of scope, which the old text never
stated: no platform process ever runs as a member, because the sidecar holds
POSTGRES_URL and the JWT secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:33:19 +00:00

446 lines
26 KiB
Markdown

# Per-user Linux accounts
**Status: in progress.** Stage 1 (the account and the privilege-drop mechanism) is being built now.
Agents are explicitly out of scope for the first pass.
## What this is for
Today every `execution` capability — terminal, chat, files, tasks, items, desktop, browser — runs as the
**owner's OS user in the owner's home**. That is why `capabilities/registry.ts` declares them
`kind: 'execution'` and why `authorize.ts` strips them from a grant even if a row somehow contains one.
The registry says so out loud: *"revisit only if per-user home confinement is ever solved — and that is a
project, not a checkbox."*
This is that project. A member gets a real Linux account whose home is the directory the platform already
provisions for them, and the surfaces that execute code run **as that account**. The payoff is three
things at once:
- **Isolation** — a member cannot read another member's files, because the kernel says so rather than
because a path check happened to be right.
- **Permissions** — "may they see this" becomes a mode bit, checked by the OS on every syscall, instead
of a predicate the platform has to remember to apply on every route.
- **Separable agents** — `claude` and `opencode` run as the member, with their own `~/.claude`, their own
transcripts and their own session state, because the CLI groups by HOME and cwd.
## The target for the first test
A member signs in and:
- the **file browser** shows their home as the root and cannot navigate above it;
- the **terminal** lands in their home and has no permission to see anything above it.
Nothing else changes. Agents stay owner-only until this much is solid.
## The layout, and what each mode bit is for
```
data/ 711 service user traverse only — a member cannot enumerate the members
└── <email>/ 711 service user traverse only — a member cannot see their OWN siblings
├── home/ 700 the member their real Linux home
├── attachments/ 700 service user platform-written; unreachable even by name
├── email_accounts/ 700 service user "
├── dashboards/ 700 service user "
└── … 700 service user "
```
The important line is the second one. `data/<email>/` is traverse-only **to its own member**: they need
`x` to reach `home/`, and they must not have `r`, or they could list the platform's private tree beside
it. And because every sibling is `700 service user`, knowing a name does not help — traversal without
read gets you exactly one place, which is where they are going anyway.
This is also what resolves the two-sided ownership problem. The platform runs as the service user and
writes attachments, email databases and dashboards into `data/<email>/`; the member owns only `home/`.
Nobody needs a shared group, a setgid bit or an ACL, and neither side can write where the other lives.
**Members' homes stay under `DATA_PATH`** rather than moving to `/home/<user>`. They are the platform's
data, they belong with the rest of that account's data, and the directory is already provisioned there by
`provisionUserDirs`. A move would also break `getOwnerHomeDir`'s fallback, which is the only shape the
code has ever had for a non-owner home.
## Hard prerequisite: the secrets a shell can currently read
**This must be fixed before any member gets a shell, and it is not optional.**
On this machine, verified 2026-08-11:
| path | mode | consequence |
| --- | --- | --- |
| `/home/pastilhas` | 751 | traversable by anyone (no listing) |
| `…/officer.dev` | 775 | listable by anyone |
| `…/platform/.env` | **664** | **world-readable** |
`platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with
a real shell could read it and mint themselves an owner token, which makes the whole exercise worse than
not doing it — the capability model would be intact and completely bypassed.
So stage 1 includes: `chmod 600` on every `.env`, `chmod 751` on the project root so the tree is
traversable but not listable, and a **boot-time check that refuses to enable OS users while any `.env`
under the project root is group- or world-readable.** A prerequisite that is merely written down is a
prerequisite that gets skipped.
The same applies to `capabilities/` (775 today) and to the repo checkout itself: a member can read the
platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not.
## The mechanism, and the trap in it
### `Bun.spawn` silently ignores `uid` and `gid`
Verified on bun 1.3.10, 2026-08-11. From uid 1000:
```js
Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }) // exit 0, prints "1000"
```
It does not throw. It does not warn. It accepts the option and runs as the parent. Every agent, task and
script spawn in this codebase goes through `Bun.spawn`.
Two honest qualifications, because the danger is narrower than it first looks:
- **Bun's own types do not declare `uid`**, so `bunx tsgo` rejects it. Typed code cannot reach this by
accident — confirmed while writing the test, which needs a cast to reproduce the behaviour at all.
- What *can* reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four
sidecars are `.mjs`.
So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through
an external wrapper, and a test pins Bun's runtime behaviour. If Bun ever implements the option, that test
fails and tells us we may simplify. **A silently absent isolation boundary is the worst possible outcome of
this project**, so it is worth a test that exists only to observe something staying broken.
### `sudo -n setpriv`, and why both words are needed
`runAs` builds:
```
sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv…>
```
- `--reuid`/`--regid` set the real ids, not just effective — there is nothing to switch back to.
- `--init-groups` applies the account's supplementary groups. Without it the process keeps the *owner's*
groups, which is a quiet way to retain access we just took away.
- `--reset-env` clears the inherited environment and then sets `HOME`, `SHELL`, `USER`, `LOGNAME` and
`PATH` from the target's passwd entry. Both halves matter: the parent's env contains the owner's `HOME`,
and on a process started by PM2 in the platform directory it contains everything Bun auto-loaded from
`.env`.
**`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with
`initgroups failed: Operation not permitted` for an unprivileged caller *even when reuid'ing to its own
account* — `setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n`
makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no
user will ever see.
Verified end to end, dropping to the current account:
```
$ sudo -n setpriv --reuid=pastilhas --regid=pastilhas --init-groups --reset-env -- \
sh -c 'id -u; id -G; echo HOME=$HOME; echo SECRET=${POSTGRES_URL:-unset}'
1000
1000 4 24 27 30 46 101 988 1001 ← supplementary groups from the account, not inherited
HOME=/home/pastilhas ← from passwd, after the reset
SECRET=unset ← the platform's .env did NOT cross
```
That last line is the whole security property, demonstrated rather than asserted, and it is pinned by a
test (`os-user.test.ts` → "does not pass the platform environment through").
`sudo -u <user>` alone would also work and be shorter. It is not used because its environment handling is
sudoers *policy*`env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
shell" must not depend on a config file someone may have edited.
Root is available: `scripts/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that
wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket
rule anyway.
### The terminal is the easy one
`sidecar/pty/sessions.mjs` uses **node-pty** under **node**, and node-pty's `spawn` genuinely honours
`uid`/`gid` (it is a native binding, not Bun's spawn). Two options; we take the second:
1. Run the pty sidecar as root and pass `uid`/`gid` per session.
2. Keep the sidecar unprivileged and make the command `setpriv … <shell> -i`.
(2) means no root daemon and one mechanism shared with everything else. A root daemon accepting session
requests over a socket is a bigger promise than this feature needs to make.
## Naming
**The username the owner chose, verbatim.** `whoami` in a member's terminal says who they are, their
prompt is their name, and a commit from their edge checkout is attributed to something recognisable.
This carried an `officer_` prefix for about an hour. The prefix bought three things — no collision with a
system account, a greppable record of what the feature created, and a member unable to pick a name that
shadows something real — and cost the only thing anyone would notice. Measured before removing it:
`useradd` on this host accepts everything `validateUsername` already permits, including dots, hyphens,
underscores and uppercase.
**What replaced the prefix's safety is the adoption rule, and it had to.** `ensureOsUser` reuses an
existing Linux account, which is what makes it re-runnable. That was safe by construction while only we
created `officer_*` names. With the name being whatever was typed, adoption became the dangerous path: a
platform account named `root` would have found root in passwd, and every `runAs` for that member would
have been a root shell. So an existing account is adopted **only when its passwd home is already exactly
the home we are about to confine** — that is what makes it ours — and any uid below 1000 is refused
outright as belt and braces.
Verified:
```
username "root" -> refused: 'root' is a system account on this machine.
username "daemon" -> refused: 'daemon' is a system account on this machine.
the owner's own account -> refused: 'pastilhas' is already a user on this machine, with its
home at /home/pastilhas. Refusing to take it over.
```
The resolved name is **stored** on the user row (`users.os_user`) rather than re-derived. `useradd` can
adjust or refuse a name, and re-deriving would mean the platform's idea of who a member is could drift
from what is actually in `/etc/passwd`.
## The ancestor trap
A member's home sits under `DATA_PATH`, which on a normal install sits under the **owner's** home — and
`/home/<owner>` is `750` on Debian and Ubuntu. Every mode bit on the account tree can be correct, the
directory can exist, and the member still cannot reach it, because they have no `x` on an ancestor four
levels up.
What that surfaced as, on the first real install:
```
ssh-keygen failed: Could not stat …/data/jg@pertento.ai/home/.ssh: Permission denied
```
Which points at exactly the wrong thing. `.ssh` was there and correctly owned; the account could not
traverse `/home/pastilhas`.
`firstUntraversableAncestor` now walks the chain **as the member** before anything tries to use the home,
and the error names the directory and the fix (`chmod o+x <dir>`). `x` without `r` is the ask throughout:
traversal, not listing — nobody gains the ability to enumerate the owner's home.
The development machine happened to be `751` already, which is exactly why the probe passed there and
failed on a fresh install. Worth remembering as a shape of mistake: the probe used `/tmp`, so it never
crossed the ancestor that mattered.
## Out of scope, and honest about it
- **This is not a sandbox.** A member with a shell is on the machine. They cannot read the owner's files
or another member's, and `sudo` is not theirs — but they can run code, see process names, and reach the
network. It isolates members from each other and from accidents, not from the host.
- **Agents come later** — but by less than this said. Two claims here were wrong and are corrected on
2026-08-11; the superseded text is in the git history of this file, and the working state is
`COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md`.
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen *outside* it, making a
member's turn its own process — "a change of shape rather than a flag". It is a flag: `sdk.d.ts:951`
exposes `spawnClaudeCodeProcess`, documented for running Claude Code "in VMs, containers, or remote
environments", and `node:child_process.spawn` already satisfies the `SpawnedProcess` shape it wants. So the
existing sidecar wraps the CLI spawn in `runAsArgv` per turn and there is no second process to stand up.
It also said the credential is not the problem because `officer-anthropic-proxy` already holds it, so a
member's `claude` "needs only `ANTHROPIC_BASE_URL` pointed at the proxy". That is backwards. The proxy holds
the **owner's** credential (`sidecar/claude/proxy.ts:7` reads the owner's own
`~/.claude/.credentials.json`), so pointing a member at it spends the owner's account on the member's
turns. Per-user Claude means their own login in their own home, and `setpriv --reset-env` is what makes that
the default rather than something to remember: nothing crosses into their process unless it is written into
the argv.
What does remain out of scope: **no platform process ever runs as a member.** The agent sidecar needs
`POSTGRES_URL` and the JWT signing secret, so a member-uid process holding them could read every account and
sign a token as the owner — more than their shell can do, and already refused by `assertSecretsClosed`. The
harness stays the service user's; only `claude` itself drops privileges.
- **`pty`, `vault` and `opencode` receive no identity at all** (`TODO.md` → Multi-user). pty keys purely
on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill *every*
session on the box. Safe today only because terminal is owner-only. **The moment a member has a shell
that is a cross-user kill switch**, so it is fixed in the same stage as the terminal, not after.
- **Email change orphans a home.** The on-disk layout is keyed on email everywhere. Renaming an account
would leave its home behind under the old address. Pre-existing, unfixed, worth knowing.
## What the first real run proved, and what it corrected
Stage 1 was exercised end to end against a throwaway `DATA_PATH` with a real `useradd`. Every property
below was **observed**, not reasoned about:
| attempted, as the member | result |
| --- | --- |
| write in own home | OK |
| read `…/<email>/attachments/private.txt` | Permission denied |
| `ls …/<email>/` (their own account dir) | Permission denied |
| `ls $DATA_PATH` (enumerate the members) | Permission denied |
| `ls …/other-member@example.com/home` | Permission denied |
| `cd $HOME/..` | **succeeds** — see below |
Three bugs surfaced only by running it:
1. **`chmod` after `chown` fails forever.** `chmod` requires ownership, so once the home belongs to the
member the service user cannot set its mode. Both orderings fail unprivileged — the first on the second
run, the second immediately. Both operations now go through sudo, which is what makes the function
re-runnable.
2. **A member could read another member's home.** `provisionUserDirs` created directories at the default
umask (`755`), and the confinement pass only ever ran for the account being created. `DATA_PATH` being
unlistable is not protection when the child is world-readable and the attacker knows an email address.
The skeleton is now created closed — `711` on the account directory, `700` inside — so *unconfined* is
also *unreachable*.
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
start with `OFFICER_OS_USERS` on while any `.env` in the project root is group- or world-readable.
**`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd`
works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real
shell can reach `/etc`, `/usr` and anything else the system leaves world-readable, because that is what a
shell is. So:
- the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`,
enforced by the platform;
- the **terminal** cannot *read* anything above the home, but is not confined to it. Confining it would
mean a namespace or a chroot, which is a different and much larger feature.
Say "cannot see behind it", not "cannot leave it".
## SSH: two keys, two directions
A member is meant to behave like a real user on the machine — reachable over SSH, able to push to Gitea as
themselves, able to have an agent do the same on their behalf. That needs two keys, and they are **not**
alternatives:
| | where | who holds the private half | what it is for |
| --- | --- | --- | --- |
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | *they* SSH into this machine |
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | *the machine* authenticates to Gitea as them |
The tempting simplification is "if they pasted a key, skip generating one." It breaks the actual goal.
Agent forwarding covers a human in an interactive session; a **platform-spawned agent has no agent socket
to borrow**, so an edge checkout it is asked to commit and push needs a key that lives on the box. So the
inbound key is optional — an account without one is simply platform-only — and the outbound keypair is
generated regardless.
**No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks
*password* login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
is the resting state. The privilege drop is `sudo -n setpriv` performed by the platform, so there is nothing
to authenticate. Keeping the platform password and the machine out of each other's business is the point: a
Linux password would be a second door that changing the platform password does not close and deleting the
platform account does not lock.
**Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a
pasted value containing a newline would silently install a *second* authorized key. `validatePublicKey`
refuses anything multi-line, refuses a private key with a message saying so, and refuses an options prefix
(`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can
force a command.
**Everything is written with `sudo install`.** The home is 700 and owned by the member, so the service user
cannot create `.ssh` at all. `install` sets content, owner and mode in one step, which also closes the
window where a key file briefly exists at the process umask. File content goes via a temp path rather than
shell text, so nothing has to reason about quoting a value that came from a form.
**`StrictHostKeyChecking accept-new`, not a seeded `known_hosts`.** The Gitea SSH endpoint is not knowable
at account-creation time — the platform stores an HTTP base URL, and SSH may be a different host or port.
The failure this avoids is specific: the default setting makes a first connection *prompt*, and a prompt in
a non-interactive agent turn is a hang, not an error. `accept-new` trusts on first use and still refuses a
*changed* host key, which is the attack that matters.
**The generated public key is stored on the user row** (`users.os_ssh_public_key`) and shown after creation
and on the user's row afterwards. It is public by definition, and it has an errand attached that nothing
else will remind anyone about: it has to be added to that person's Gitea account or their pushes fail with
a permission error that says nothing about a missing key.
Verified end to end with a real `useradd`: `.ssh` 700 and `id_ed25519` 600 both owned by the member and
readable by them, `authorized_keys` byte-identical to what was pasted, the key **not** rotated on a second
run (it has been added to Gitea by then), and a multi-line paste refused with `authorized_keys` left
untouched.
## Docker: rootless, one daemon per member
Verified working on a real member account, 2026-08-11.
**Not the `docker` group.** `usermod -aG docker <user>` is the one-line version and it is root: membership
means talking to the host daemon, which runs as root, so `docker run -v /:/host -it alpine chroot /host` is
a root shell. That reads `.env`, every other member's home and the wallet seed — every boundary above,
bypassed by one documented command. The group is not "access to Docker", it is "root, by a longer route".
Rootless gives what was actually wanted: a daemon per account, containers in that account's user namespace,
images under their own home. Measured — the daemon runs as the member, `docker pull` put 403 MB in their
home, and `docker ps -a` showed nothing while the owner had four containers running.
**Host prerequisites**, all in `setup.sh` as core packages: `uidmap` (newuidmap/newgidmap — rootless cannot
start without them), `dbus-user-session`, and Docker's own rootless extras. `useradd` allocates the
`/etc/subuid` range automatically wherever `login.defs` sets `SUB_UID_COUNT`, and `userdel` reclaims it.
**`loginctl enable-linger` is required, not optional.** Officer's shells are not login sessions, so without
it a member's daemon would stop the moment their terminal closed.
**The setup tool's exit code is not the gate.** It writes `~/.config/systemd/user/docker.service` and then
fails its own `systemctl --user start` with "Unit docker.service not found", because nothing reloaded a
manager that was already running. So: run it, `daemon-reload`, start it ourselves, and verify by asking the
daemon its version.
**Two features built the same day collided.** Creating a volume copies xattrs, and the DEFAULT ACLs on a
member's home — added so the file browser could read their files — are inherited by Docker's storage, where
a mapped id inside a user namespace is not a valid id to set:
```
failed to copy xattrs: failed to set xattr "system.posix_acl_default" on …/volumes/…/_data: invalid argument
```
Every container failed to start while the image pulled perfectly. The fix strips DEFAULT ACLs from
`~/.local/share/docker` only (`setfacl -R -k`), leaving the access ACLs the file browser depends on. Losing
the platform's reach into Docker's internal storage costs nothing: it is layers and volume data, read
through `docker` or not at all.
### The port space is shared, and that is not fixed
A rootless daemon is isolated; the **host's port space is not**. RootlessKit publishes into it, so a member
mapping `5432` collides with the owner's production Postgres — observed immediately:
```
error while calling RootlessKit PortManager.AddPort(): listen tcp4 0.0.0.0:5432: bind: address already in use
```
Two consequences worth knowing:
- **Publish on `127.0.0.1` explicitly.** A bare `-p 15432:5432` binds `0.0.0.0` in rootless mode, putting a
member's dev database on the network. `127.0.0.1:15432:5432` is all they need to reach it from their own
shell.
- **Nothing allocates ports.** With one member the owner manages it by hand, which is where this stands
deliberately. With several, a per-member offset is the crude answer that works.
## Follow-ups this creates
- **Deleting a member no longer removes their home.** It belongs to their uid, so the platform cannot
remove it — `rm -rf` fails with EPERM, which is how it was noticed. `deleteUserHandler` does not touch
disk today so nothing is broken, but account deletion will need `userdel` and a sudo `rm` to stop
leaving an orphaned, unremovable directory behind.
- **`confineUserTree` sets `DATA_PATH` itself to 711.** If a container ever bind-mounts a path under
`DATA_PATH` and runs as another uid, it will traverse but not list.
**This happened, and it was worse than the note predicted.** Reported from a live server: a bind-mounted
`postgres:18-alpine` crash-looped with `mkdir: can't create directory '…/18/docker'` on a directory that
already existed. Two reasons the prediction was too mild. The image's inner uid is 70, which maps through
the member's subuid range to 231141 — neither the service user nor the member, so `other`. And by then the
home carried `default:other::---` from the ACL work, so `other` had lost even the traverse bit that the 711
reasoning assumed. Traverse-but-not-list became no-traverse-at-all.
`3bea46f`'s fix — stripping defaults from `~/.local/share/docker` — covered NAMED VOLUMES only. A bind
source lives wherever the member put it. A named volume passes with the bug present, which is exactly why
that fix looked complete.
Now: `~/.local/dockers` is provisioned at `711` with **all** ACLs removed (`setfacl -R -b`, not `-k`), and
is the documented place for compose bind mounts. `711` rather than `700` is the point — a container's inner
uid needs `x` to reach a bind source inside, and no ACL can grant what the mode denies. `-b` rather than
`-k` because `-k` left `mask::---` behind, so inherited named entries read as `rwx #effective:---`: an ACL
that says one thing and means another.
Bounded deliberately. A member bind-mounting from elsewhere in their home still hits the denial; this is
the place that works, not a guarantee about everywhere. The alternatives were worse — extending the strip
cannot work when the member chooses the path, and `d:other::--x` on the whole home loosens every directory
forever to fix one local case.
## Stages
1. **The account and the mechanism.** `users.os_user`; `ensureOsUser` (useradd + chown + the mode bits
above); `runAs`; the `.env` permission gate; tests including the Bun-ignores-uid pin. **No behaviour
change** — accounts are created and nothing uses them yet.
2. **`getOwnerHomeDir` honours its email argument.** It takes an email and throws it away whenever
`HOME_DIR` is set, which is always on a real install. Seven call sites; this one change repoints the
file browser, chat, tasks, agents and the VNC password file per account.
3. **The file browser**, rooted at the member's home. Containment already exists — `resolveUserPath` +
`isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new
security code.
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` capability reopened.
5. **Agents.** Separately, later, with the SDK problem solved first.