# 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 └── / 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//` 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//`; 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/`. 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= --regid= --init-groups --reset-env -- ``` - `--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 ` 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 … -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/` 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 `). `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.** `claude-manager.ts` drives turns through `query()` from `@anthropic-ai/claude-agent-sdk`, which spawns `claude` itself and takes `env`/`cwd` but has nowhere to put a uid. Dropping privileges has to happen *outside* the SDK, which makes a member's turn its own process — a change of shape rather than a flag. The credential is not the problem: `officer-anthropic-proxy` already holds it, so a member's `claude` needs only `ANTHROPIC_BASE_URL` pointed at the proxy and no key of its own. - **`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 `…//attachments/private.txt` | Permission denied | | `ls …//` (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 ` 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.