TERMINAL is confined now, and the shell is genuinely theirs. The pty sidecar spawns it through sudo setpriv as their own account, in their own home, with the platform's environment cleared. Verified end to end against the sidecar's own socket: id -u 1001, not 1000 file the shell wrote owned by ptyprobe ps -o user=,args= ptyprobe /bin/zsh -i env | grep -c POSTGRES 0 osUser and home are resolved in upgradeWs from the authenticated account, and whatever the browser sent under those names is DELETED first. The bridge forwards the query string to the sidecar untouched and the sidecar starts a shell from what it finds there, so trusting the client for either would let a member ask for the owner's uid in a query parameter. node-pty does support uid/gid, unlike Bun.spawn, and they are deliberately unused: they set the ids without applying the account's groups or resetting the environment, so the shell would keep the owner's groups and everything Bun loaded from .env. Also closes the pty identity blindness in TODO.md. Sessions record whose they are, list and kill scope to the caller, and re-attaching to a session belonging to another account is refused — otherwise a member resumes someone else's shell by guessing an id that travels in a query string. Measured: member killing the owner's session -> ok:false, owner killing it -> ok:true. CHAT is confined so the owner can grant it and the route resolves, and both execution doors refuse a non-owner: the router wholesale, and the socket in server.tsx. The agent has not moved — the SDK spawns claude itself with nowhere to put a uid, and every transcript path resolves through the owner's home, so a member would read the owner's session list and run an agent as the owner. Reads are refused too, because listClaudePwds returns the names of the owner's projects. A deliberate, temporary gap at the owner's request: permission and route now, function when a turn can be spawned under runAs with the member's own HOME. Both guards say so, and the registry test names them so a future edit cannot move one without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 lines
21 KiB
TypeScript
475 lines
21 KiB
TypeScript
// The capability registry: the single enumeration of what this platform can do, and the unit the owner
|
|
// grants to a role.
|
|
//
|
|
// ── Why capabilities and not routes ──
|
|
//
|
|
// The obvious model is "list the routes a role may call". It does not survive contact with this codebase.
|
|
// A sweep of all 100 mutating platform routes on 2026-08-06 found reads permanently stuck on POST for two
|
|
// reasons that are not going away: bodies GET cannot carry (`/stt` multipart audio, `/tts`, `/ocr`,
|
|
// `/transcribe`), and credentials that must not sit in a query string where access logs, shell history and
|
|
// Referer headers capture them (`/tts/voices` apiKey, the four `/test` endpoints, `/local-providers/probe`).
|
|
// Five genuinely free conversions were done in e54d71d; the rest are staying. So the METHOD alone cannot
|
|
// carry the read/write distinction — hence `readOnlyWrites` below, declared per capability.
|
|
//
|
|
// The deeper reason is that a route list is not what the owner is deciding. The owner decides "this person
|
|
// gets Gitea". A capability is that decision; the prefixes, sockets and screens it expands to are an
|
|
// implementation detail that belongs next to the decision rather than in the granting UI.
|
|
//
|
|
// ── The four kinds, and why `execution` can never be granted ──
|
|
//
|
|
// 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
|
|
// the owner's physical screen. Granting any of them is not a feature flag, it is co-ownership of the
|
|
// machine. There is no level of "read" that makes a shell safe, which is why these have no level at all.
|
|
// Revisit only if per-user home confinement is ever solved — and that is a project, not a checkbox.
|
|
//
|
|
// ── Read by default ──
|
|
//
|
|
// A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in
|
|
// the capability, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing
|
|
// else. Music is the worked example: `/favorites`, `/now-playing` and `/playlists` are already per-caller
|
|
// 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' | 'confined' | 'execution' | 'admin';
|
|
|
|
export type Capability = {
|
|
/** Stable identifier. Stored in the database as the grant's subject — renaming one is a data change. */
|
|
key: string;
|
|
label: string;
|
|
description: string;
|
|
kind: CapabilityKind;
|
|
/**
|
|
* Path prefixes under `/api`, written exactly as they are mounted on protectedRouter in hono.ts —
|
|
* leading slash, no `/api`. The totality check pairs these against the real mount table, so a prefix
|
|
* here that nothing mounts is as much an error as a mount nothing claims.
|
|
*/
|
|
api: string[];
|
|
/** WebSocket providers, named as in server.tsx's `handlers` map. */
|
|
ws?: string[];
|
|
/** Frontend route prefixes. Filters the dock and the app registry; never a security boundary. */
|
|
routes?: string[];
|
|
/**
|
|
* Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only
|
|
* the caller's own data. Matched as a prefix after the capability's own: `/favorites` on the `music`
|
|
* capability permits `POST /api/music/favorites/123`.
|
|
*/
|
|
personal?: string[];
|
|
/**
|
|
* Reads that must stay POST — see the note at the top. A read grant permits these paths at any method.
|
|
* Written relative to the capability's `api` prefix, like `personal`.
|
|
*/
|
|
readOnlyWrites?: string[];
|
|
/**
|
|
* Routes any authenticated account may call even holding NO grant on this capability, because they act
|
|
* on the caller themselves. `METHOD /exact/path`, relative to the capability's prefix — exact, not a
|
|
* prefix, so this cannot widen by accident.
|
|
*
|
|
* One entry exists and it should stay that way. `PUT /api/users` is self-profile update (useAuth.ts
|
|
* calls it to change your own name and avatar) and has always lived on the same router as the owner-only
|
|
* account administration beside it. Moving it to `/api/user` would be tidier and would break every
|
|
* shipped mobile client, so the honest fix is to say out loud that this one route is not what the
|
|
* capability around it is.
|
|
*/
|
|
selfService?: string[];
|
|
};
|
|
|
|
export const CAPABILITIES: Capability[] = [
|
|
// ── core ────────────────────────────────────────────────────────────────────────────────────────
|
|
{
|
|
key: 'account',
|
|
label: 'Account',
|
|
description: 'Sign in, your own profile, password, preferences and API keys',
|
|
kind: 'core',
|
|
// `/api-keys` is core rather than app or admin because a key is not new authority — it is a second way
|
|
// to present the authority the account already has, so denying it would only force the holder to keep
|
|
// using a password in places a password should not go. What a key can then DO is decided by the same
|
|
// capability checks as any other request from that user; nothing here widens them.
|
|
api: ['/user', '/dock', '/api-keys'],
|
|
routes: ['/settings/profile'],
|
|
},
|
|
{
|
|
key: 'bug-report',
|
|
label: 'Report a problem',
|
|
description: 'Send the server owner a bug report',
|
|
kind: 'core',
|
|
api: ['/bug-report'],
|
|
},
|
|
|
|
// ── app: the grantable surface ──────────────────────────────────────────────────────────────────
|
|
{
|
|
key: 'gitea',
|
|
label: 'Gitea',
|
|
description: 'Repositories, issues, pull requests and notifications from your own Gitea account',
|
|
kind: 'app',
|
|
api: ['/gitea'],
|
|
routes: ['/gitea'],
|
|
// Every Gitea call is already scoped to the caller's own personal access token — the sidecar resolves
|
|
// the token from the caller's row and the instance from the owner's, so a member holding `read` still
|
|
// acts only as themselves upstream. Gitea's own permissions are the second gate and the real one:
|
|
// a token cannot reach a repository its account cannot reach, whatever this platform thinks.
|
|
//
|
|
// Which is why the whole capability is `personal` rather than a list of sub-paths. Nothing under
|
|
// /api/gitea can affect another Officer user, so withholding write here would only stop someone
|
|
// commenting on their own issues — security theatre with a real cost and no benefit.
|
|
personal: ['/'],
|
|
},
|
|
{
|
|
key: 'music',
|
|
label: 'Music',
|
|
description: 'The music library, playback, and your own favourites and playlists',
|
|
kind: 'app',
|
|
api: ['/music'],
|
|
ws: ['cliamp', 'cliamp-audio'],
|
|
routes: ['/music'],
|
|
// Already per-caller in the sidecar contract (X-Officer-User), which is what makes them safe to write
|
|
// at read level. The library itself — scanning, tags, file moves — is not, and is not listed.
|
|
personal: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
|
},
|
|
{
|
|
key: 'photos',
|
|
label: 'Photos',
|
|
description: 'Browse the photo library',
|
|
kind: 'app',
|
|
api: ['/photos'],
|
|
routes: ['/photos'],
|
|
},
|
|
{
|
|
key: 'jellyfin',
|
|
label: 'Video',
|
|
description: 'Browse and play the Jellyfin library',
|
|
kind: 'app',
|
|
api: ['/jellyfin'],
|
|
routes: ['/jellyfin'],
|
|
},
|
|
{
|
|
key: 'memos',
|
|
label: 'Memos',
|
|
description: 'Notes',
|
|
kind: 'app',
|
|
api: ['/memos'],
|
|
routes: ['/memos'],
|
|
},
|
|
{
|
|
key: 'calendar',
|
|
label: 'Calendar and contacts',
|
|
description: 'Calendars, contacts, and the app passwords that sync them to a phone',
|
|
kind: 'app',
|
|
api: ['/caldav', '/dav'],
|
|
routes: ['/calendar', '/contacts'],
|
|
// App passwords are minted for and revoked by their own owner; `/dav` holds nothing shared.
|
|
personal: ['/'],
|
|
},
|
|
{
|
|
key: 'email',
|
|
label: 'Email',
|
|
description: 'Mail accounts and messages',
|
|
kind: 'app',
|
|
api: ['/email'],
|
|
routes: ['/email'],
|
|
},
|
|
{
|
|
key: 'notify',
|
|
label: 'Notifications',
|
|
description: 'Push notifications to your devices',
|
|
kind: 'app',
|
|
api: ['/notify'],
|
|
// Device registration is the caller's own — a phone subscribing to its own push channel.
|
|
personal: ['/devices', '/subscriptions'],
|
|
},
|
|
{
|
|
key: 'transmission',
|
|
label: 'Transmission',
|
|
description: 'Torrent downloads',
|
|
kind: 'app',
|
|
api: ['/transmission'],
|
|
routes: ['/transmission'],
|
|
},
|
|
{
|
|
key: 'soulseek',
|
|
label: 'Soulseek',
|
|
description: 'Search and download from the Soulseek network',
|
|
kind: 'app',
|
|
api: ['/slskd'],
|
|
routes: ['/soulseek'],
|
|
},
|
|
{
|
|
key: 'invoices',
|
|
label: 'Invoices',
|
|
description: 'InvoiceShelf books',
|
|
kind: 'app',
|
|
api: ['/invoiceshelf'],
|
|
routes: ['/invoices'],
|
|
},
|
|
{
|
|
key: 'vpn',
|
|
label: 'VPN',
|
|
description: 'Enrol your own devices on the tailnet',
|
|
kind: 'app',
|
|
api: ['/vpn'],
|
|
// Minting a pre-auth key for your own device is the entire point of the capability, and the key is
|
|
// bound to the caller. Administering the tailnet is `headscale`, which is admin-only.
|
|
personal: ['/'],
|
|
},
|
|
// Core, not app — and this was a real defect, not a preference. `/api/dashboards` is not a feature, it is
|
|
// the per-user key-value store where EVERY workspace screen keeps its layout (`screens/files`,
|
|
// `ws-layout-*`, panel config). `WorkspaceView` renders nothing until that store has loaded, so gating it
|
|
// meant a member with `files` granted got a completely blank Files screen and no request to
|
|
// /api/file-browser at all — the panel never mounted. Same for Terminal, Chat and every other screen.
|
|
//
|
|
// It is entirely `personal` and always was: every row is keyed to the caller. There is nothing here to
|
|
// withhold, and withholding it does not restrict an account, it breaks it — which is exactly the
|
|
// definition of `core` at the top of this file.
|
|
{
|
|
key: 'dashboards',
|
|
label: 'Screen layouts and dashboards',
|
|
description: 'Where your own screen layouts and dashboards are saved',
|
|
kind: 'core',
|
|
api: ['/dashboards'],
|
|
routes: ['/dashboards'],
|
|
personal: ['/'],
|
|
},
|
|
|
|
// ── execution: never grantable ──────────────────────────────────────────────────────────────────
|
|
// Confined since 2026-08-11. A member's shell is spawned by the pty sidecar through `sudo setpriv` as their
|
|
// own Linux account, in their own home, with the platform's environment cleared — so it is their shell, and
|
|
// the kernel decides what it can reach. The sidecar also records whose each session is, so `list` and `kill`
|
|
// scope to the caller instead of every shell on the box.
|
|
//
|
|
// What this is NOT is a jail. A member with a shell can `cd /` and read whatever the system leaves
|
|
// world-readable, like any account on any machine. It isolates members from each other and from the owner's
|
|
// files, which is the promise `confined` makes.
|
|
{
|
|
key: 'terminal',
|
|
label: 'Terminal',
|
|
description: 'A shell on this machine, as your own user',
|
|
kind: 'confined',
|
|
api: ['/terminal'],
|
|
ws: ['terminal'],
|
|
routes: ['/terminal'],
|
|
},
|
|
// Confined so the owner can grant it and the route resolves — but the agent underneath still runs as the
|
|
// OWNER, so `api/chat/chat.ts` refuses a non-owner outright and the chat socket is refused in server.tsx.
|
|
// A deliberate, temporary gap: the permission exists, the functionality follows when a turn can be spawned
|
|
// under `runAs` with the member's own HOME. Until then this grant buys a route and a refusal, and the
|
|
// comments at both guards say so.
|
|
{
|
|
key: 'chat',
|
|
label: 'Chat',
|
|
description: 'The agent',
|
|
kind: 'confined',
|
|
api: ['/chat'],
|
|
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: 'Your own home directory on this machine, and the code editor over it',
|
|
kind: 'confined',
|
|
api: ['/file-browser', '/upload'],
|
|
routes: ['/files', '/code-editor'],
|
|
},
|
|
{
|
|
key: 'tasks',
|
|
label: 'Tasks and jobs',
|
|
description: 'Running capabilities, pipelines and background jobs',
|
|
kind: 'execution',
|
|
api: ['/tasks', '/jobs', '/pipeline-jobs', '/task-logs', '/queue'],
|
|
ws: ['task-runner', 'pipeline'],
|
|
routes: ['/jobs', '/task-logs'],
|
|
},
|
|
{
|
|
key: 'items',
|
|
label: 'Capability authoring',
|
|
description: 'Skills, tools, agents and processes on disk',
|
|
kind: 'execution',
|
|
api: ['/skills', '/tools', '/agents', '/processes', '/rescan'],
|
|
},
|
|
{
|
|
key: 'desktop',
|
|
label: 'Desktop',
|
|
description: "The server owner's physical screen",
|
|
kind: 'execution',
|
|
api: ['/desktop'],
|
|
ws: ['desktop'],
|
|
routes: ['/desktop'],
|
|
},
|
|
{
|
|
key: 'browser',
|
|
label: 'Browser',
|
|
description: 'Drives a real browser on the host',
|
|
kind: 'execution',
|
|
api: ['/browser', '/scrape'],
|
|
routes: ['/browser'],
|
|
},
|
|
|
|
// ── admin: the platform administering itself ────────────────────────────────────────────────────
|
|
{
|
|
key: 'app-store',
|
|
label: 'App store',
|
|
description: 'Install, enable and remove the sidecars this server runs',
|
|
// Admin, not app. Installing a sidecar starts a process on the machine and provisioning one starts
|
|
// containers — that is process control, not a feature a member can be granted a read of. The router
|
|
// gates on the owner in its own right as well; this entry is what makes the boot check pass and what
|
|
// keeps the surface visible in one enumeration.
|
|
kind: 'admin',
|
|
api: ['/app-store'],
|
|
routes: ['/app-store'],
|
|
},
|
|
{
|
|
key: 'server-admin',
|
|
label: 'Server settings',
|
|
description: 'Server configuration, integrations and the activity log',
|
|
kind: 'admin',
|
|
api: ['/server-settings', '/integrations', '/activity', '/system-monitor'],
|
|
routes: ['/settings/server', '/settings/system', '/activity', '/system-monitor'],
|
|
},
|
|
{
|
|
key: 'user-admin',
|
|
label: 'User management',
|
|
description: 'Accounts, roles and what each role may reach',
|
|
kind: 'admin',
|
|
api: ['/users'],
|
|
routes: ['/settings/user-management'],
|
|
// Changing your own name, username and avatar. Owner-only account administration is every other route
|
|
// on this router and stays owner-only — see ownerGate in users-router.ts, which is the second lock.
|
|
selfService: ['PUT /'],
|
|
},
|
|
{
|
|
key: 'headscale',
|
|
label: 'Headscale',
|
|
description: 'The tailnet: machines, routes and ACLs',
|
|
kind: 'admin',
|
|
api: ['/headscale'],
|
|
routes: ['/headscale'],
|
|
},
|
|
{
|
|
key: 'wallet',
|
|
label: 'Wallet',
|
|
description: "The server owner's bitcoin",
|
|
kind: 'admin',
|
|
api: ['/wallet'],
|
|
routes: ['/wallet'],
|
|
},
|
|
];
|
|
|
|
// ── Derived lookups ───────────────────────────────────────────────────────────────────────────────
|
|
|
|
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; `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');
|
|
|
|
export type CapabilityLevel = 'read' | 'write';
|
|
|
|
const isPrefixOf = (prefix: string, path: string): boolean =>
|
|
prefix === '/' || path === prefix || path.startsWith(`${prefix}/`);
|
|
|
|
/**
|
|
* Which capability owns this path? `path` is the full request path (`/api/gitea/...`).
|
|
*
|
|
* Longest prefix wins, so a capability may claim `/dav` while another claims `/dav/something` without the
|
|
* order of the array mattering. Returns null for a path no capability claims — which the totality check
|
|
* below is there to make impossible for anything mounted on protectedRouter.
|
|
*/
|
|
export function capabilityForApiPath(path: string): Capability | null {
|
|
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
|
|
let best: Capability | null = null;
|
|
let bestLength = -1;
|
|
for (const capability of CAPABILITIES) {
|
|
for (const prefix of capability.api) {
|
|
if (isPrefixOf(prefix, rest) && prefix.length > bestLength) {
|
|
best = capability;
|
|
bestLength = prefix.length;
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export function capabilityForWsProvider(provider: string): Capability | null {
|
|
return CAPABILITIES.find((c) => c.ws?.includes(provider)) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Is this an exact self-service route — one any authenticated account may call without holding the
|
|
* capability at all? Matched exactly on method AND path, never as a prefix.
|
|
*/
|
|
export function isSelfServiceRoute(capability: Capability, method: string, path: string): boolean {
|
|
if (!capability.selfService?.length) return false;
|
|
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
|
|
const upper = method.toUpperCase();
|
|
return capability.api.some((prefix) => {
|
|
if (!isPrefixOf(prefix, rest)) return false;
|
|
const sub = rest.slice(prefix.length) || '/';
|
|
return capability.selfService!.includes(`${upper} ${sub}`);
|
|
});
|
|
}
|
|
|
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
|
|
/**
|
|
* May a caller holding `level` on `capability` make this request?
|
|
*
|
|
* `write` is unconditional within the capability. `read` permits safe methods, anything the capability
|
|
* declares as a `readOnlyWrites` read-in-POST-clothing, and mutations confined to `personal` sub-paths.
|
|
*/
|
|
export function isRequestAllowedAtLevel(
|
|
capability: Capability,
|
|
level: CapabilityLevel,
|
|
method: string,
|
|
path: string,
|
|
): boolean {
|
|
if (level === 'write') return true;
|
|
if (SAFE_METHODS.has(method.toUpperCase())) return true;
|
|
|
|
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
|
|
// Strip whichever of the capability's own prefixes matched, so `personal` entries are written relative
|
|
// to the capability rather than repeated per prefix.
|
|
const withinCapability = capability.api
|
|
.filter((prefix) => isPrefixOf(prefix, rest))
|
|
.map((prefix) => rest.slice(prefix.length) || '/');
|
|
|
|
const allowed = [...(capability.personal ?? []), ...(capability.readOnlyWrites ?? [])];
|
|
return withinCapability.some((sub) => allowed.some((entry) => isPrefixOf(entry, sub)));
|
|
}
|