capabilities: a registry, and a server that refuses to boot without one
permissions are capabilities, not routes. a capability is a feature — the
unit the owner grants, the dock filters on and the acl enforces — declaring
the api prefixes, websocket providers and screens it expands to.
four kinds. core is every account and is not grantable because it is not
deniable. app is the grantable surface. admin is the platform administering
itself. execution is never grantable at any level: terminal, chat, tasks,
files, desktop and browser all run as the owner's os user in the owner's
home, so granting one is co-ownership of the machine rather than a feature.
the part that matters is assertCapabilityTotality. the websocket hole fixed
in 2873948 was not a wrong rule — it was a door added without telling the
rule, because bun's route table matches /api/terminal/ws before the /api/*
catch-all that reaches hono's middleware. so the server now refuses to start
unless every mounted prefix and every user-facing socket maps to exactly one
capability. hono.ts mounts from a table and exports it, so the check reads
the real surface instead of a copy that can drift from it.
verified: passes against the live surface, and refuses all four ways — an
ungated router, an ungated socket, a claim on a deleted router, a claim on a
deleted socket.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
// 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.
|
||||
// execution NEVER grantable. Owner only, structurally.
|
||||
// admin owner only: the platform administering itself, and the owner's own money and network.
|
||||
//
|
||||
// `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' | '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[];
|
||||
};
|
||||
|
||||
export const CAPABILITIES: Capability[] = [
|
||||
// ── core ────────────────────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account',
|
||||
description: 'Sign in, your own profile, password and preferences',
|
||||
kind: 'core',
|
||||
api: ['/user', '/dock'],
|
||||
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: ['/'],
|
||||
},
|
||||
{
|
||||
key: 'dashboards',
|
||||
label: 'Dashboards',
|
||||
description: 'Your own dashboards and saved layouts',
|
||||
kind: 'app',
|
||||
api: ['/dashboards'],
|
||||
routes: ['/dashboards'],
|
||||
personal: ['/'],
|
||||
},
|
||||
{
|
||||
key: 'plans',
|
||||
label: 'Plans',
|
||||
description: 'Plan documents',
|
||||
kind: 'app',
|
||||
api: ['/plans'],
|
||||
routes: ['/plans'],
|
||||
},
|
||||
|
||||
// ── execution: never grantable ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'terminal',
|
||||
label: 'Terminal',
|
||||
description: 'A real shell as the server owner',
|
||||
kind: 'execution',
|
||||
api: ['/terminal'],
|
||||
ws: ['terminal'],
|
||||
routes: ['/terminal'],
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
label: 'Chat',
|
||||
description: 'The agent, running unsandboxed as the server owner',
|
||||
kind: 'execution',
|
||||
api: ['/chat'],
|
||||
ws: ['chat'],
|
||||
routes: ['/chat'],
|
||||
},
|
||||
{
|
||||
key: 'files',
|
||||
label: 'Files',
|
||||
description: "The server owner's filesystem, and the code editor over it",
|
||||
kind: 'execution',
|
||||
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: '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'],
|
||||
},
|
||||
{
|
||||
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, the other two are owner-only. */
|
||||
export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app');
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
Reference in New Issue
Block a user