From a live-server report: a bind-mounted postgres:18-alpine crash-looped with
`mkdir: can't create directory '…/18/docker'` on a directory that already existed.
3bea46f stripped default ACLs from ~/.local/share/docker and I concluded the ACL problem
solved. It covered NAMED VOLUMES only. A bind source lives wherever the member put it, and
there the same collision returns by another route: the image's inner uid is 70, mapped
through the member's subuid range to 231141 — neither the service user nor the member, so
`other` — and the home carries default:other::--- from the file browser's ACLs. A named
volume passes with this bug present, which is exactly why the first fix looked complete.
~/.local/dockers is now provisioned as the documented place for compose bind mounts: mode
711, all ACLs removed. Two details that are the whole fix:
711, not 700 — a container's inner uid is `other` and needs x to reach a bind source
inside. No ACL can grant what the mode denies, and 700 blocks the path before any ACL is
consulted. `r` stays off so nothing can list it, and the home above is still 700, so no
other account can traverse this far anyway.
setfacl -b, not -k — `-k` removes defaults but left mask::--- behind, so inherited named
entries read as `user:pastilhas:rwx #effective:---`. An ACL that says one thing and means
another is worse than none, and container storage wants ordinary mode bits.
Chosen over the alternatives: 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. Bounded deliberately — a bind mount from elsewhere in the home still hits the denial.
This is the place that works, not a promise about everywhere.
VERIFIED: the directory comes out `user::rwx group::--- other::--x` with no ACL and no
defaults, which is the design exactly.
NOT VERIFIED: a container actually starting from a bind mount in it. My host recycles uid
1001 across probe accounts and a stale /run/user/1001 — a systemd runtime mount that
survives rm — leaves the new account with no bus, so rootless Docker will not start here.
That is the deprovision/uid-reuse problem in the queue, hitting the test rig. The live
server is the place to confirm it: green is uid 1002 with no recycling, and the report that
prompted this came from there.
docs/per-user-linux-accounts.md line 337 predicted a milder version of this and said
"nothing does today". Corrected: something does, and the ACLs had removed the traverse bit
its 711 reasoning assumed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
430 lines
25 KiB
Markdown
430 lines
25 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.** `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".
|
|
|
|
## 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.
|