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>
15 KiB
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 —
claudeandopencoderun 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:
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, sobunx tsgorejects 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/--regidset the real ids, not just effective — there is nothing to switch back to.--init-groupsapplies 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-envclears the inherited environment and then setsHOME,SHELL,USER,LOGNAMEandPATHfrom the target's passwd entry. Both halves matter: the parent's env contains the owner'sHOME, 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:
- Run the pty sidecar as root and pass
uid/gidper session. - 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
sudois 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.tsdrives turns throughquery()from@anthropic-ai/claude-agent-sdk, which spawnsclaudeitself and takesenv/cwdbut 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-proxyalready holds it, so a member'sclaudeneeds onlyANTHROPIC_BASE_URLpointed at the proxy and no key of its own. pty,vaultandopencodereceive no identity at all (TODO.md→ Multi-user). pty keys purely on asessionIdfrom the query string, and its/_officer/sessionsendpoints 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:
chmodafterchownfails forever.chmodrequires 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.- A member could read another member's home.
provisionUserDirscreated directories at the default umask (755), and the confinement pass only ever ran for the account being created.DATA_PATHbeing unlistable is not protection when the child is world-readable and the attacker knows an email address. The skeleton is now created closed —711on the account directory,700inside — so unconfined is also unreachable. platform/.envwas readable, and printingJWT_SECRETfrom a member's shell was confirmed. This is the prerequisite above, demonstrated. It is now a boot check (assertSecretsClosed) that refuses to start withOFFICER_OS_USERSon while any.envin 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 -rffails with EPERM, which is how it was noticed.deleteUserHandlerdoes not touch disk today so nothing is broken, but account deletion will needuserdeland a sudormto stop leaving an orphaned, unremovable directory behind. confineUserTreesetsDATA_PATHitself to 711. If a container ever bind-mounts a path underDATA_PATHand runs as another uid, it will traverse but not list. Nothing does today.
Stages
- The account and the mechanism.
users.os_user;ensureOsUser(useradd + chown + the mode bits above);runAs; the.envpermission gate; tests including the Bun-ignores-uid pin. No behaviour change — accounts are created and nothing uses them yet. getOwnerHomeDirhonours its email argument. It takes an email and throws it away wheneverHOME_DIRis 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.- 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. - The terminal, via
setpriv, plus pty identity. Oneexecutioncapability reopened. - Agents. Separately, later, with the SDK problem solved first.