import { mutationClassAllowedByEffect, type ToolEffect } from "../manifest/effect"; import type { AppPermissionV2 } from "../manifest/permissions"; import { APP_CAPABILITY_OPERATIONS, isAppCapabilityOperation, type AppCapabilityOperation, } from "./operations"; /** * The capability gate (plan §10.3, §12.2). * * Pure, transport-agnostic evaluation of a single capability call against * the app's declared permissions, the install's granted permissions, and * the effect ceiling of the executing unit. Every transport (tier-2 * bridge, hosted-logic stubs, server guard) runs the SAME gate — the * bridge is UX, the server is truth, but the logic is one function. * * The gate deliberately does not know about workspace membership or * resource ACLs: those require IO and live in the host-side guard, which * composes this gate with its own checks. */ export type CapabilityDenialCode = | "unknown_operation" | "permission_not_declared" | "permission_not_granted" | "effect_ceiling_exceeded"; export type CapabilityGateDecision = | { allowed: true; operation: AppCapabilityOperation } | { allowed: false; operation: string; code: CapabilityDenialCode; message: string; }; export interface CapabilityGateConfig { /** Permissions the manifest declares. */ manifestPermissions: readonly AppPermissionV2[]; /** Permissions the install has actually been granted. */ grantedPermissions: readonly AppPermissionV2[]; /** * The effect ceiling of the executing unit (a tool's declared effect). * Omitted for non-tool contexts (e.g. render handlers), which are * implicitly ceilinged at `read` — rendering must not mutate. */ effectCeiling?: ToolEffect | undefined; } const RENDER_CONTEXT_CEILING: ToolEffect = "read"; export function evaluateCapabilityGate( operation: string, config: CapabilityGateConfig, ): CapabilityGateDecision { if (!isAppCapabilityOperation(operation)) { return { allowed: false, operation, code: "unknown_operation", message: `"${operation}" is not a Coline capability operation.`, }; } const descriptor = APP_CAPABILITY_OPERATIONS[operation]; if (descriptor.permission !== null) { if (!config.manifestPermissions.includes(descriptor.permission)) { return { allowed: false, operation, code: "permission_not_declared", message: `"${operation}" requires the "${descriptor.permission}" permission, which the app manifest does not declare.`, }; } if (!config.grantedPermissions.includes(descriptor.permission)) { return { allowed: false, operation, code: "permission_not_granted", message: `"${operation}" requires the "${descriptor.permission}" permission, which this install has not been granted.`, }; } } const ceiling = config.effectCeiling ?? RENDER_CONTEXT_CEILING; if (!mutationClassAllowedByEffect(descriptor.mutation, ceiling)) { return { allowed: false, operation, code: "effect_ceiling_exceeded", message: `"${operation}" is a ${descriptor.mutation}-class operation, above the "${ceiling}" effect ceiling of the executing unit.`, }; } return { allowed: true, operation }; } export class CapabilityDeniedError extends Error { readonly code: CapabilityDenialCode; readonly operation: string; constructor(decision: Extract) { super(decision.message); this.name = "CapabilityDeniedError"; this.code = decision.code; this.operation = decision.operation; } } export interface CapabilityGate { check(operation: string): CapabilityGateDecision; assert(operation: string): AppCapabilityOperation; } export function createCapabilityGate(config: CapabilityGateConfig): CapabilityGate { return { check(operation) { return evaluateCapabilityGate(operation, config); }, assert(operation) { const decision = evaluateCapabilityGate(operation, config); if (!decision.allowed) { throw new CapabilityDeniedError(decision); } return decision.operation; }, }; }