per-user linux accounts, stage 1: the account and the privilege drop
A member gets a real Linux account whose home is the directory the platform already
provisions for them. Nothing uses it yet — this is the mechanism plus the account,
deliberately with no behaviour change, so the file browser and terminal can be moved
onto something already proven.
Bun.spawn silently ignores uid/gid. Verified on 1.3.10: from uid 1000,
Bun.spawn(['id','-u'], {uid: 65534}) exits 0 and prints 1000. No throw, no warning.
Bun's types don't declare the option so typed code can't reach it by accident, but the
runtime accepts it, and a silently absent isolation boundary is the worst outcome this
feature could have. So privilege drops go through sudo -n setpriv, and a test pins Bun's
behaviour — if it's ever implemented, that test tells us we may simplify.
sudo is required for the drop and not because of the uid: --init-groups fails with
"Operation not permitted" for an unprivileged caller even when reuid'ing to its own
account, because setgroups(2) is root-only. --reset-env is what stops the platform's
environment crossing; verified POSTGRES_URL is unset on the far side and HOME arrives
from the target's passwd entry.
Three bugs that only a real run with a real useradd could find:
- chmod after chown fails forever, because chmod needs ownership. Both orderings fail
unprivileged. Both operations now go through sudo, which is what makes it re-runnable.
- a member could read ANOTHER member's home: provisionUserDirs created at the default
umask (755) and only the account being created got confined. An unlistable parent is
no protection when the child is world-readable and emails are guessable. The skeleton
is now created closed, 711 on the account dir and 700 inside.
- platform/.env was 664 and a member's shell printed JWT_SECRET, which is enough to mint
an owner token and bypass every capability check. Now a boot check that refuses to
start with OFFICER_OS_USERS on while any .env in the project root is group- or
world-readable.
Design, the measured results and the staging plan: docs/per-user-linux-accounts.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
# 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
|
||||
|
||||
`officer_<shellname>`, where `<shellname>` is `toShellUsername(username, email)` — the existing
|
||||
sanitiser, which already lowercases, strips `@…`, replaces illegal characters and truncates to 32. The
|
||||
combined name is truncated to 32 again.
|
||||
|
||||
The prefix earns its ugliness three times: it cannot collide with a system account, it makes every
|
||||
account this feature created greppable in `/etc/passwd`, and it means a member cannot pick a username
|
||||
that shadows something real.
|
||||
|
||||
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`.
|
||||
|
||||
## 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 `…/<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".
|
||||
|
||||
## 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. Nothing does today.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user