files, for a member, in their own home

Introduces a fifth capability kind. `files` was `execution` — never grantable,
because it meant the OWNER'S filesystem. It is now `confined`: execution-shaped, but
the kernel enforces the boundary because the account has its own Linux user, its own
home, and no permission above it.

The rule that makes `confined` mean something lives in authorize.ts, once: a confined
grant is DROPPED for an account with no osUser. So "granted but unconfined" resolves
to no access rather than to the owner's home — which is what it would otherwise
resolve to, since getOwnerHomeDir ignores the email it is handed whenever HOME_DIR is
set. One rule covers the HTTP routes, the websocket doors and the dock, instead of
each router remembering.

resolveHomeDir(userId) is the new seam and it reads the row rather than the token, for
the same reason authorize.ts re-reads role: provisioning a Linux account for an
existing member has to take effect on the next request, not in thirty days.

The file browser resolves it in middleware and puts it on ctx user, because
getRootDir is called from fifteen places in that router. Making it async would have
meant editing fifteen call sites, and the cost of missing one is serving the owner's
home to a member. Now a handler cannot run without the answer.

Two things a real run caught:

- /ls seeds Downloads/Documents into the home as the service user, which is EPERM
  against a 700 home owned by the member — it took the whole listing down. Seeding is
  now best-effort there and happens at provision time instead, as the member.
- .unique() on os_user made db:push ask whether to TRUNCATE users, which is
  unanswerable non-interactively. uniqueIndex instead, per databases/CLAUDE.md.

Verified: a member without a Linux account is refused by name; with one, resolves to
their own home and NOT to HOME_DIR; the owner still resolves to HOME_DIR; and every
.. escape is refused while an absolute path is rebased under the root.

Terminal is still execution — that is the next stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 17:34:31 +00:00
co-authored by Claude Opus 5
parent 0fb9a29e64
commit 4d513c0e13
10 changed files with 226 additions and 19 deletions
+33 -5
View File
@@ -20,9 +20,26 @@
// core every authenticated account, always. Not grantable because not deniable — signing in
// without them means a broken app, not a restricted one.
// app the grantable surface. This is what the owner hands out per role.
// confined execution-shaped, but the KERNEL enforces the boundary per account. Grantable, and only
// to an account that has a Linux user — see below.
// execution NEVER grantable. Owner only, structurally.
// admin owner only: the platform administering itself, and the owner's own money and network.
//
// ── `confined`, and why it is not just `app` ──
//
// Added 2026-08-11 with per-user Linux accounts (docs/per-user-linux-accounts.md). A confined capability
// touches the filesystem or runs a process, so calling it an `app` would be a lie — but it is no longer
// the OWNER'S filesystem, because the account has its own Linux user, its own home, and the kernel refusing
// everything above it.
//
// The distinction earns its keep in one place: a grant on a confined capability means NOTHING unless the
// account actually has that Linux user. `authorize.ts` drops confined grants for an account with no
// `osUser`, so "granted but unconfined" resolves to no access rather than to the owner's home. That rule
// lives there, once, instead of in each router that would otherwise have to remember it.
//
// Moving a capability from `execution` to `confined` is therefore a claim with a test attached: every path
// it reaches must resolve its directory from the CALLER, not from HOME_DIR.
//
// `execution` is the important one. Everything under it runs as the OWNER'S OS user in the owner's home
// directory: the terminal is a real shell, chat spawns `claude` with --dangerously-skip-permissions, tasks
// run arbitrary scripts, the file browser and code editor read and write the owner's disk, the desktop is
@@ -38,7 +55,7 @@
// in the sidecar contract because every sidecar request carries `X-Officer-User`. So "may a member write
// here" is a property of the endpoint, not a policy knob someone has to remember to set.
export type CapabilityKind = 'core' | 'app' | 'execution' | 'admin';
export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
export type Capability = {
/** Stable identifier. Stored in the database as the grant's subject — renaming one is a data change. */
@@ -255,11 +272,16 @@ export const CAPABILITIES: Capability[] = [
ws: ['chat'],
routes: ['/chat'],
},
// Confined rather than execution since 2026-08-11. Every path under `/file-browser` resolves its root
// through `resolveHomeDir(userId)` in a middleware that refuses the request outright when the account has
// no Linux user — so a member sees their own home and `resolveUserPath`'s containment check stops them
// walking out of it. `/upload` was already per-caller: it writes only under
// `DATA_PATH/<email>/attachments`, never into a home.
{
key: 'files',
label: 'Files',
description: "The server owner's filesystem, and the code editor over it",
kind: 'execution',
description: 'Your own home directory on this machine, and the code editor over it',
kind: 'confined',
api: ['/file-browser', '/upload'],
routes: ['/files', '/code-editor'],
},
@@ -351,8 +373,14 @@ export const CAPABILITIES: Capability[] = [
export const CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c]));
/** The keys an owner may actually hand to a role. `core` is automatic, the other two are owner-only. */
export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app');
/**
* The keys an owner may actually hand to a role. `core` is automatic; `execution` and `admin` are owner-only.
*
* `confined` is offered here, but a grant on one is inert for an account without a Linux user — that is
* enforced in `authorize.ts`, not by withholding it from this list. Withholding it would mean the owner
* could not pre-grant a role before provisioning the people in it, which is the normal order of operations.
*/
export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined');
/** Available to every signed-in account without a grant. */
export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core');