/** * PluginCapabilityValidator — enforces the capability model at both * install-time and runtime. * * Every plugin declares the capabilities it requires in its manifest * (`manifest.capabilities`). This service checks those declarations * against a mapping of operations → required capabilities so that: * * 1. **Install-time validation** — `validateManifestCapabilities()` * ensures that declared features (tools, jobs, webhooks, UI slots) * have matching capability entries, giving operators clear feedback * before a plugin is activated. * * 2. **Runtime gating** — `checkOperation()` / `assertOperation()` are * called on every worker→host bridge call to enforce least-privilege * access. If a plugin attempts an operation it did not declare, the * call is rejected with a 403 error. * * @see PLUGIN_SPEC.md §15 — Capability Model * @see host-client-factory.ts — SDK-side capability gating */ import type { PluginCapability, PaperclipPluginManifestV1, PluginUiSlotType, PluginLauncherPlacementZone, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; import { logger } from "../middleware/logger.js"; // --------------------------------------------------------------------------- // Capability requirement mappings // --------------------------------------------------------------------------- /** * Maps high-level operations to the capabilities they require. * * When the bridge receives a call from a plugin worker, the host looks up * the operation in this map and checks the plugin's declared capabilities. * If any required capability is missing, the call is rejected. * * @see PLUGIN_SPEC.md §15 — Capability Model */ const OPERATION_CAPABILITIES: Record = { // Data read operations "companies.list": ["companies.read"], "companies.get": ["companies.read"], "projects.list": ["projects.read"], "projects.get": ["projects.read"], "project.workspaces.list": ["project.workspaces.read"], "project.workspaces.get": ["project.workspaces.read"], "issues.list": ["issues.read"], "issues.get": ["issues.read"], "issue.comments.list": ["issue.comments.read"], "issue.comments.get": ["issue.comments.read"], "agents.list": ["agents.read"], "agents.get": ["agents.read"], "goals.list": ["goals.read"], "goals.get": ["goals.read"], "activity.list": ["activity.read"], "activity.get": ["activity.read"], "costs.list": ["costs.read"], "costs.get": ["costs.read"], // Data write operations "issues.create": ["issues.create"], "issues.update": ["issues.update"], "issue.comments.create": ["issue.comments.create"], "activity.log": ["activity.log.write"], "metrics.write": ["metrics.write"], // Plugin state operations "plugin.state.get": ["plugin.state.read"], "plugin.state.list": ["plugin.state.read"], "plugin.state.set": ["plugin.state.write"], "plugin.state.delete": ["plugin.state.write"], // Runtime / Integration operations "events.subscribe": ["events.subscribe"], "events.emit": ["events.emit"], "jobs.schedule": ["jobs.schedule"], "jobs.cancel": ["jobs.schedule"], "webhooks.receive": ["webhooks.receive"], "http.request": ["http.outbound"], "secrets.resolve": ["secrets.read-ref"], // Agent tools "agent.tools.register": ["agent.tools.register"], "agent.tools.execute": ["agent.tools.register"], }; /** * Maps UI slot types to the capability required to register them. * * @see PLUGIN_SPEC.md §19 — UI Extension Model */ const UI_SLOT_CAPABILITIES: Record = { sidebar: "ui.sidebar.register", sidebarPanel: "ui.sidebar.register", projectSidebarItem: "ui.sidebar.register", page: "ui.page.register", detailTab: "ui.detailTab.register", taskDetailView: "ui.detailTab.register", dashboardWidget: "ui.dashboardWidget.register", globalToolbarButton: "ui.action.register", toolbarButton: "ui.action.register", contextMenuItem: "ui.action.register", commentAnnotation: "ui.commentAnnotation.register", commentContextMenuItem: "ui.action.register", settingsPage: "instance.settings.register", }; /** * Launcher placement zones align with host UI surfaces and therefore inherit * the same capability requirements as the equivalent slot type. */ const LAUNCHER_PLACEMENT_CAPABILITIES: Record< PluginLauncherPlacementZone, PluginCapability > = { page: "ui.page.register", detailTab: "ui.detailTab.register", taskDetailView: "ui.detailTab.register", dashboardWidget: "ui.dashboardWidget.register", sidebar: "ui.sidebar.register", sidebarPanel: "ui.sidebar.register", projectSidebarItem: "ui.sidebar.register", globalToolbarButton: "ui.action.register", toolbarButton: "ui.action.register", contextMenuItem: "ui.action.register", commentAnnotation: "ui.commentAnnotation.register", commentContextMenuItem: "ui.action.register", settingsPage: "instance.settings.register", }; /** * Maps feature declarations in the manifest to their required capabilities. */ const FEATURE_CAPABILITIES: Record = { tools: "agent.tools.register", jobs: "jobs.schedule", webhooks: "webhooks.receive", }; // --------------------------------------------------------------------------- // Result types // --------------------------------------------------------------------------- /** * Result of a capability check. When `allowed` is false, `missing` contains * the capabilities that the plugin does not declare but the operation requires. */ export interface CapabilityCheckResult { allowed: boolean; missing: PluginCapability[]; operation?: string; pluginId?: string; } // --------------------------------------------------------------------------- // PluginCapabilityValidator interface // --------------------------------------------------------------------------- export interface PluginCapabilityValidator { /** * Check whether a plugin has a specific capability. */ hasCapability( manifest: PaperclipPluginManifestV1, capability: PluginCapability, ): boolean; /** * Check whether a plugin has all of the specified capabilities. */ hasAllCapabilities( manifest: PaperclipPluginManifestV1, capabilities: PluginCapability[], ): CapabilityCheckResult; /** * Check whether a plugin has at least one of the specified capabilities. */ hasAnyCapability( manifest: PaperclipPluginManifestV1, capabilities: PluginCapability[], ): boolean; /** * Check whether a plugin is allowed to perform the named operation. * * Operations are mapped to required capabilities via OPERATION_CAPABILITIES. * Unknown operations are rejected by default. */ checkOperation( manifest: PaperclipPluginManifestV1, operation: string, ): CapabilityCheckResult; /** * Assert that a plugin is allowed to perform an operation. * Throws a 403 HttpError if the capability check fails. */ assertOperation( manifest: PaperclipPluginManifestV1, operation: string, ): void; /** * Assert that a plugin has a specific capability. * Throws a 403 HttpError if the capability is missing. */ assertCapability( manifest: PaperclipPluginManifestV1, capability: PluginCapability, ): void; /** * Check whether a plugin can register the given UI slot type. */ checkUiSlot( manifest: PaperclipPluginManifestV1, slotType: PluginUiSlotType, ): CapabilityCheckResult; /** * Validate that a manifest's declared capabilities are consistent with its * declared features (tools, jobs, webhooks, UI slots). * * Returns all missing capabilities rather than failing on the first one. * This is useful for install-time validation to give comprehensive feedback. */ validateManifestCapabilities( manifest: PaperclipPluginManifestV1, ): CapabilityCheckResult; /** * Get the capabilities required for a named operation. * Returns an empty array if the operation is unknown. */ getRequiredCapabilities(operation: string): readonly PluginCapability[]; /** * Get the capability required for a UI slot type. */ getUiSlotCapability(slotType: PluginUiSlotType): PluginCapability; } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- /** * Create a PluginCapabilityValidator. * * This service enforces capability gates for plugin operations. The host * uses it to verify that a plugin's declared capabilities permit the * operation it is attempting, both at install time (manifest validation) * and at runtime (bridge call gating). * * Usage: * ```ts * const validator = pluginCapabilityValidator(); * * // Runtime: gate a bridge call * validator.assertOperation(plugin.manifestJson, "issues.create"); * * // Install time: validate manifest consistency * const result = validator.validateManifestCapabilities(manifest); * if (!result.allowed) { * throw badRequest("Missing capabilities", result.missing); * } * ``` */ export function pluginCapabilityValidator(): PluginCapabilityValidator { const log = logger.child({ service: "plugin-capability-validator" }); // ----------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------- function capabilitySet(manifest: PaperclipPluginManifestV1): Set { return new Set(manifest.capabilities); } function buildForbiddenMessage( manifest: PaperclipPluginManifestV1, operation: string, missing: PluginCapability[], ): string { return ( `Plugin '${manifest.id}' is not allowed to perform '${operation}'. ` + `Missing required capabilities: ${missing.join(", ")}` ); } // ----------------------------------------------------------------------- // Public API // ----------------------------------------------------------------------- return { hasCapability(manifest, capability) { return manifest.capabilities.includes(capability); }, hasAllCapabilities(manifest, capabilities) { const declared = capabilitySet(manifest); const missing = capabilities.filter((cap) => !declared.has(cap)); return { allowed: missing.length === 0, missing, pluginId: manifest.id, }; }, hasAnyCapability(manifest, capabilities) { const declared = capabilitySet(manifest); return capabilities.some((cap) => declared.has(cap)); }, checkOperation(manifest, operation) { const required = OPERATION_CAPABILITIES[operation]; if (!required) { log.warn( { pluginId: manifest.id, operation }, "capability check for unknown operation – rejecting by default", ); return { allowed: false, missing: [], operation, pluginId: manifest.id, }; } const declared = capabilitySet(manifest); const missing = required.filter((cap) => !declared.has(cap)); if (missing.length > 0) { log.debug( { pluginId: manifest.id, operation, missing }, "capability check failed", ); } return { allowed: missing.length === 0, missing, operation, pluginId: manifest.id, }; }, assertOperation(manifest, operation) { const result = this.checkOperation(manifest, operation); if (!result.allowed) { const msg = result.missing.length > 0 ? buildForbiddenMessage(manifest, operation, result.missing) : `Plugin '${manifest.id}' attempted unknown operation '${operation}'`; throw forbidden(msg); } }, assertCapability(manifest, capability) { if (!this.hasCapability(manifest, capability)) { throw forbidden( `Plugin '${manifest.id}' lacks required capability '${capability}'`, ); } }, checkUiSlot(manifest, slotType) { const required = UI_SLOT_CAPABILITIES[slotType]; if (!required) { return { allowed: false, missing: [], operation: `ui.${slotType}.register`, pluginId: manifest.id, }; } const has = manifest.capabilities.includes(required); return { allowed: has, missing: has ? [] : [required], operation: `ui.${slotType}.register`, pluginId: manifest.id, }; }, validateManifestCapabilities(manifest) { const declared = capabilitySet(manifest); const allMissing: PluginCapability[] = []; // Check feature declarations → required capabilities for (const [feature, requiredCap] of Object.entries(FEATURE_CAPABILITIES)) { const featureValue = manifest[feature as keyof PaperclipPluginManifestV1]; if (Array.isArray(featureValue) && featureValue.length > 0) { if (!declared.has(requiredCap)) { allMissing.push(requiredCap); } } } // Check UI slots → required capabilities const uiSlots = manifest.ui?.slots ?? []; if (uiSlots.length > 0) { for (const slot of uiSlots) { const requiredCap = UI_SLOT_CAPABILITIES[slot.type]; if (requiredCap && !declared.has(requiredCap)) { if (!allMissing.includes(requiredCap)) { allMissing.push(requiredCap); } } } } // Check launcher declarations → required capabilities const launchers = [ ...(manifest.launchers ?? []), ...(manifest.ui?.launchers ?? []), ]; if (launchers.length > 0) { for (const launcher of launchers) { const requiredCap = LAUNCHER_PLACEMENT_CAPABILITIES[launcher.placementZone]; if (requiredCap && !declared.has(requiredCap) && !allMissing.includes(requiredCap)) { allMissing.push(requiredCap); } } } return { allowed: allMissing.length === 0, missing: allMissing, pluginId: manifest.id, }; }, getRequiredCapabilities(operation) { return OPERATION_CAPABILITIES[operation] ?? []; }, getUiSlotCapability(slotType) { return UI_SLOT_CAPABILITIES[slotType]; }, }; }