step 4/4: the docs say permissions too, and capability means one thing again

44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
This commit is contained in:
2026-08-15 16:31:11 +00:00
parent f9fd002ff4
commit 027b10bd6e
45 changed files with 721 additions and 694 deletions
+40 -39
View File
@@ -5,11 +5,11 @@ 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
Today every `execution` permission — terminal, chat, files, tasks, items, desktop, browser — runs as the
**owner's OS user in the owner's home**. That is why `permissions/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."*
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
@@ -63,22 +63,22 @@ code has ever had for a non-owner home.
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** |
| 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.
not doing it — the permission 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
The same applies to `permissions/` (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
@@ -88,7 +88,7 @@ platform source. That is acceptable — it is not secret — but anything creden
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"
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
@@ -98,7 +98,7 @@ 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
- 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
@@ -115,7 +115,7 @@ 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*
- `--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`,
@@ -123,8 +123,8 @@ sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv
`.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`
`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.
@@ -143,7 +143,7 @@ That last line is the whole security property, demonstrated rather than asserted
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
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/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
@@ -227,7 +227,7 @@ crossed the ancestor that mattered.
2026-08-11; the superseded text is in the git history of this file, and the working state is
`COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md`.
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen *outside* it, making a
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen _outside_ it, making a
member's turn its own process — "a change of shape rather than a flag". It is a flag: `sdk.d.ts:951`
exposes `spawnClaudeCodeProcess`, documented for running Claude Code "in VMs, containers, or remote
environments", and `node:child_process.spawn` already satisfies the `SpawnedProcess` shape it wants. So the
@@ -245,8 +245,9 @@ crossed the ancestor that mattered.
`POSTGRES_URL` and the JWT signing secret, so a member-uid process holding them could read every account and
sign a token as the owner — more than their shell can do, and already refused by `assertSecretsClosed`. The
harness stays the service user's; only `claude` itself drops privileges.
- **`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*
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
@@ -257,14 +258,14 @@ crossed the ancestor that mattered.
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 |
| 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:
@@ -275,8 +276,8 @@ Three bugs surfaced only by running it:
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*.
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 while any `.env` in the project root is group- or world-readable.
@@ -293,7 +294,7 @@ 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
- 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".
@@ -304,10 +305,10 @@ A member is meant to behave like a real user on the machine — reachable over S
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 |
| | 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
@@ -316,14 +317,14 @@ inbound key is optional — an account without one is simply platform-only — a
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"
_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`
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.
@@ -335,9 +336,9 @@ 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
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.
_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
@@ -446,5 +447,5 @@ Two consequences worth knowing:
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.
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` permission reopened.
5. **Agents.** Separately, later, with the SDK problem solved first.