450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
/**
|
||
* 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<string, readonly PluginCapability[]> = {
|
||
// 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<PluginUiSlotType, PluginCapability> = {
|
||
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<string, PluginCapability> = {
|
||
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<PluginCapability> {
|
||
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];
|
||
},
|
||
};
|
||
}
|