Two changes, and the second is what makes the first safe.
The officer_ prefix is gone: a member's account is the username the owner typed, so
whoami says who they are and a commit from their checkout is attributed to something
recognisable. Measured first — useradd on this host accepts everything
validateUsername permits, including dots, hyphens, underscores and uppercase.
The prefix was also load-bearing, though, and not for looks. ensureOsUser REUSES an
existing account, which is what makes it re-runnable, and that was safe by
construction while only we created officer_* names. Unprefixed, adoption becomes 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 adoption now requires
the existing account's passwd home to be exactly the home we are about to confine —
that is what makes it ours — and any uid below 1000 is refused outright.
Verified: root and daemon refused as system accounts, and the owner's own username
refused by name with its real home quoted back.
Also, the ancestor trap from the first real install. A member's home is under
DATA_PATH, which is under the OWNER'S home, and /home/<owner> is 750 on Debian and
Ubuntu — so every mode bit on the account tree was right, the directory existed, and
the member still could not reach it for want of x four levels up. It surfaced as
"ssh-keygen: Could not stat …/.ssh: Permission denied", which points at the wrong
thing entirely. firstUntraversableAncestor now walks the chain as the member before
anything uses the home, and the error names the directory and the chmod.
The dev machine was already 751, and the probe used /tmp, so it never crossed the
ancestor that mattered. Worth remembering as a shape of mistake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>