import { PERMISSIONS, permissionForApiPath, permissionForWsProvider } from './registry'; // The part that actually matters. // // On 2026-08-06 a Member token that was 403'd on `GET /api/tasks` opened `/api/tasks/pipeline/ws` and got // a 101 in the same minute — a shell, the agent with --dangerously-skip-permissions, arbitrary script // execution and the owner's screen, all reachable. The cause was not a bad rule. It was that WebSocket // upgrades never reach Hono's middleware at all: Bun's route table in server.tsx matches // `/api/terminal/ws` before the `/api/*` catch-all, so the authorisation gate was simply not on that code // path. Nobody had written a rule that was wrong; a door had been added and the rule had not been told. // // It was fixed in 2873948 by adding the check to the socket door too. That fix is a patch, and patches of // that shape do not survive the next door. What survives is refusing to boot: every mounted API prefix and // every user-facing WebSocket provider must map to exactly one permission, or the server does not start. // Add a router and forget the registry and you find out during `pm2 restart`, not during an incident. // // The exemptions below are the honest cost of that: a short list of surfaces that genuinely are not // user-permission-gated, each of which has to say why. A list that grows silently is the failure mode, so // keep it short and keep the reasons real. /** * Mounted under `honoServer` directly rather than `protectedRouter`, and gated by something other than a * platform permission. Each entry is a claim that has to stay true. */ const EXEMPT_API_PREFIXES: Record = { // Unauthenticated by necessity — this is where a caller goes to BECOME authenticated. '/auth': 'signin, bootstrap and token refresh; runs before any account exists to check', // Public marketing surfaces, served to anonymous visitors. '/landing-page-data': 'public landing page content, no account involved', '/waitlist': 'public signup form, no account involved', // Registration socket for sidecars. Process-to-process on loopback; there is no user on this path. '/sidecar': 'sidecar registration, loopback process-to-process', // The caller is a Claude session running curl, not a browser: it has no platform JWT to present, so // userMiddleware would 401 it and a permission lookup would have no account to resolve. What it does // present is a per-panel token minted by the platform, which identifies exactly one agent panel and // authorises exactly one action — deliver a prompt to a NAMED PEER ON THAT PANEL'S OWN DASHBOARD. // It reads nothing else, writes nothing else, and cannot name a raw session key. The narrowness is // the whole justification: this is not a general API door with a second credential, it is one verb. '/agent-handoff': 'agent-to-agent handoff; a per-panel bearer token authorising one verb, not a platform JWT', // An iframe cannot send an Authorization header — it is a navigation, not a fetch — so the permission // gate has nothing to read and would 401 the preview before it loaded. The route is NOT open: it // verifies the owner's token from the URL once and issues a short-lived cookie, and it refuses anyone // who is not the owner. That check lives in the router because it is the only door here, which is // exactly the shape this list exists to make somebody justify. '/dev-proxy': 'iframes cannot carry a bearer token; the router itself verifies the owner and sets a cookie', }; /** * WebSocket providers that do not go through `upgradeWs` and therefore never carry a platform account. */ const EXEMPT_WS_PROVIDERS: Record = { sidecar: 'sidecar registration, loopback process-to-process, no user', // The HMR socket of a dev server being previewed. Upgraded by `upgradeDevProxyWs`, which requires the // same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner // without carrying an account through `upgradeWs`. Refused outright without that cookie. 'dev-proxy': 'dev server HMR relay; gated by the owner-only cookie the dev proxy issues', }; /** * Is this path served above the account gate? * * Read by the permission backstop, so that signin — which by definition has no account to check — is not * asked to prove a permission. Deliberately shares EXEMPT_API_PREFIXES with the boot check: an exemption * granted at boot and an exemption honoured at request time must be the same list, or one of them is a * hole. */ export function isExemptApiPath(path: string): boolean { const rest = path.startsWith('/api') ? path.slice('/api'.length) : path; return Object.keys(EXEMPT_API_PREFIXES).some((prefix) => rest === prefix || rest.startsWith(`${prefix}/`)); } export type PermissionSurface = { /** Every prefix mounted on protectedRouter, as written in hono.ts. */ apiPrefixes: string[]; /** Every key of the `handlers` map in server.tsx. */ wsProviders: string[]; }; /** * Refuses to return if the registry and the real surface disagree. Called from the boot path. * * Four ways to fail, and all four are real bugs rather than pedantry: * - a mounted prefix no permission claims → reachable by a rule nobody wrote * - a prefix two permissions claim → which grant applies is undefined * - a declared prefix nothing mounts → the registry is describing a router that no longer exists * - a socket provider no permission claims → exactly the 2026-08-06 hole, structurally */ export function assertPermissionTotality(surface: PermissionSurface): void { const problems: string[] = []; // 1. Every mount is claimed, and claimed once. for (const prefix of surface.apiPrefixes) { if (prefix in EXEMPT_API_PREFIXES) continue; const claimants = PERMISSIONS.filter((c) => c.api.includes(prefix)); if (claimants.length === 0) { problems.push(`/api${prefix} is mounted but no permission claims it — add it to the registry`); } else if (claimants.length > 1) { problems.push(`/api${prefix} is claimed by ${claimants.map((c) => c.key).join(', ')} — it must be exactly one`); } } // 2. Every claim corresponds to something real. Catches a router that was deleted or renamed while the // registry kept describing it — which would leave a grant that silently means nothing. const mounted = new Set(surface.apiPrefixes); for (const permission of PERMISSIONS) { for (const prefix of permission.api) { if (!mounted.has(prefix)) { problems.push(`permission '${permission.key}' claims /api${prefix}, which nothing mounts`); } } } // 3. Every socket door is claimed. This is the one the incident was about. for (const provider of surface.wsProviders) { if (provider in EXEMPT_WS_PROVIDERS) continue; if (!permissionForWsProvider(provider)) { problems.push(`websocket provider '${provider}' is served but no permission claims it`); } } // 4. And no permission claims a socket that does not exist. const providers = new Set(surface.wsProviders); for (const permission of PERMISSIONS) { for (const provider of permission.ws ?? []) { if (!providers.has(provider)) { problems.push(`permission '${permission.key}' claims websocket '${provider}', which is not served`); } } } // 5. Keys are unique — a duplicate would make grants ambiguous in the database. const seen = new Set(); for (const permission of PERMISSIONS) { if (seen.has(permission.key)) problems.push(`duplicate permission key '${permission.key}'`); seen.add(permission.key); } if (problems.length > 0) { throw new Error( [ 'Permission registry does not cover the served surface. The server will not start.', '', ...problems.map((p) => ` • ${p}`), '', 'Fix src/servers/permissions/registry.ts. If a surface genuinely is not permission-gated, add it', 'to the exemption list in totality.ts WITH a reason — an unexplained exemption is how the', 'websocket hole happened.', ].join('\n'), ); } } /** Exposed for the settings UI, so an owner can see what a permission actually covers. */ export function describePermissionSurface(): { exemptApi: typeof EXEMPT_API_PREFIXES; exemptWs: typeof EXEMPT_WS_PROVIDERS; } { return { exemptApi: EXEMPT_API_PREFIXES, exemptWs: EXEMPT_WS_PROVIDERS }; } export { permissionForApiPath };