Files
PaperClipAI/server/src/services/plugin-capability-validator.ts
T
Devin FoleyandPaperclip b70641f23f feat(plugins): support image catalogs and persistent application overlays (#13646)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Plugins extend the application without adding each integration to
Core.
> - A downstream image needs a way to supply prebuilt plugins.
> - Some plugin UI must stay mounted as users move between pages.
> - This change adds an image catalog and a persistent application slot.
> - Operators can upgrade or remove these plugins through their image
and configuration.

## Linked Issues or Issue Description

**Subsystem affected**

Plugin packaging, activation and application UI.

**Problem or motivation**

The built-in plugin catalog is fixed in Core source. Downstream images
cannot add entries through an explicit catalog. Existing page slots also
cannot preserve a small application overlay across route changes.

**Proposed solution**

Read a bounded catalog of prebuilt plugins from the image. Verify its
files before importing manifests. Use the existing managed selection and
plugin lifecycle. Add an `appShellOverlay` slot with account and company
cleanup.

**Alternatives considered**

A downstream fork adds merge work. Script injection provides no plugin
lifecycle. A separate runtime download system adds a second distribution
channel.

**Roadmap alignment**

This extends the existing plugin system. Related PR #9006 covers runtime
install replication; this change covers immutable image contents. PR
#12555 covers CLI scaffolding. Neither provides this catalog or
application slot. The maintainer requested this work directly.

## What Changed

- Validate catalog identities, confined paths, package versions and
bundle hashes before importing code.
- Apply image selection to persisted plugin installs, including removal
and rollback. Adopt the verified image path from existing npm/local
installs and bind runtime worker/UI entrypoints to verified package
declarations.
- Mount application overlays in both UI shells. Preserve route state and
clear it on account, company and onboarding changes.
- Restrict service-worker offline storage/fallback to hashed public
assets in a separate cache namespace; exclude application HTML and
extension/API data, including after worker restart.
- Document the packaging contract, trust model and rollback
requirements.

## Verification

- Passed `pnpm -r typecheck`, `pnpm build`, and `pnpm
check:token-gates`. Affected server/UI typechecks and builds, plus token
gates, passed again after rebasing onto current master; the 124 focused
tests also passed after rebase.
- Latest focused verification: 124 tests in nine files passed for
catalog/reconciliation/loader, overlay lifecycle, Layout and
service-worker policy. The broader UI/shared/SDK run passed 7,204 tests
in 690 files with canonical `TMPDIR`.
- Real disposable Core/PostgreSQL: catalog install, selection removal,
0.1.0→0.1.1→0.1.0, same-version npm/legacy-path adoption, and
preservation of disabled status passed. Added permissions entered
`upgrade_pending`, withheld UI across restart, and activated only after
explicit operator enable.
- Real Chromium: desktop/mobile layout, route draft retention and
Escape/focus passed with mocked extension responses. A persistent
browser restart retained public hashed-asset offline fallback while
refusing seeded legacy/current private entries and legacy HTML.
- Full `pnpm test:run`: 12,539 passed; 17 failed across six existing
files, stopping later phases. macOS read-only directory renames fail in
runtime-skill-cache and company-skills-service; email tests require an
absent local AgentMail fixture. Native runner/comment-redaction passed
in isolation after temporary Rust setup; agent-conversations also passed
in isolation. No unrelated source was changed to hide failures.
- After rebase, two unchanged chat timing tests failed in CI and passed
locally in isolation. Their CI shard passed on its single retry. All
other current-head CI jobs passed on the initial run; review is 5/5 with
no unresolved threads.
- No live deployment or external plugin service was used.

## Risks

- Plugins are trusted code. The catalog detects packaging errors; it
does not authenticate an untrusted image builder.
- Invalid catalogs fail startup. Images must contain the catalog and
bundles together, with stable directories.
- A host older than this contract lacks the activation guard. Disable
added plugins and remove their configuration keys before reverting to
it.
- Offline navigation now returns 503 instead of replaying cached
application HTML. Only public build assets have offline fallback.
- Rolling back an unapproved permission change retains the approval
gate; review the current manifest and explicitly enable it. A reduced
permission set cannot establish prior approval or prior enabled status.
- Plugin data migrations need their own rollback policy. This change
retains installed records and does not reverse migrations.

## Model Used

- OpenAI GPT-6 (Codex), model ID `gpt-6`, with repository inspection,
code execution and browser verification. The runtime does not expose an
exact context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (relevant suites; broad
macOS server-run exceptions are documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (fresh run on 488b3754ae; chat
shard passed its single retry)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(fresh review on 488b3754ae)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-19 09:18:10 -07:00

527 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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"],
"projects.managed.get": ["projects.managed"],
"projects.managed.reconcile": ["projects.managed"],
"projects.managed.reset": ["projects.managed"],
"routines.managed.get": ["routines.managed"],
"routines.managed.reconcile": ["routines.managed"],
"routines.managed.reset": ["routines.managed"],
"project.workspaces.list": ["project.workspaces.read"],
"project.workspaces.get": ["project.workspaces.read"],
"execution.workspaces.get": ["execution.workspaces.read"],
"issues.list": ["issues.read"],
"issues.get": ["issues.read"],
"issues.relations.get": ["issue.relations.read"],
"issue.comments.list": ["issue.comments.read"],
"issue.comments.get": ["issue.comments.read"],
"issue.interactions.list": ["issue.interactions.read"],
"issue.interactions.read": ["issue.interactions.read"],
"issue.attachments.list": ["issue.attachments.read"],
"issue.attachments.get": ["issue.attachments.read"],
"approvals.list": ["approvals.read"],
"approvals.get": ["approvals.read"],
"agents.list": ["agents.read"],
"agents.get": ["agents.read"],
"agents.managed.get": ["agents.managed"],
"agents.managed.reconcile": ["agents.managed"],
"agents.managed.reset": ["agents.managed"],
"goals.list": ["goals.read"],
"goals.get": ["goals.read"],
"activity.list": ["activity.read"],
"activity.get": ["activity.read"],
"costs.list": ["costs.read"],
"costs.get": ["costs.read"],
"issues.summaries.getOrchestration": ["issues.orchestration.read"],
"db.namespace": ["database.namespace.read"],
"db.query": ["database.namespace.read"],
"localFolders.declarations": [],
"localFolders.configure": ["local.folders"],
"localFolders.status": ["local.folders"],
"localFolders.list": ["local.folders"],
"localFolders.readText": ["local.folders"],
"localFolders.writeTextAtomic": ["local.folders"],
// Data write operations
"issues.create": ["issues.create"],
"issues.update": ["issues.update"],
"issues.relations.setBlockedBy": ["issue.relations.write"],
"issues.relations.addBlockers": ["issue.relations.write"],
"issues.relations.removeBlockers": ["issue.relations.write"],
"issues.assertCheckoutOwner": ["issues.checkout"],
"issues.getSubtree": ["issue.subtree.read"],
"issues.requestWakeup": ["issues.wakeup"],
"issues.requestWakeups": ["issues.wakeup"],
"issue.comments.create": ["issue.comments.create"],
"issue.comments.create_human_attributed": ["issue.comments.create_human_attributed"],
"issue.interactions.create": ["issue.interactions.create"],
"issue.interactions.respond": ["issue.interactions.respond"],
"approvals.respond": ["approvals.respond"],
"approvals.decide": ["approvals.respond"],
"activity.log": ["activity.log.write"],
"metrics.write": ["metrics.write"],
"telemetry.track": ["telemetry.track"],
"db.migrate": ["database.namespace.migrate"],
"db.execute": ["database.namespace.write"],
"external.objects.detect": ["external.objects.detect"],
"external.objects.read": ["external.objects.read"],
"external.objects.write": ["external.objects.write"],
"external.objects.refresh": ["external.objects.refresh"],
// 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"],
// Environment runtime drivers
"environment.validateConfig": ["environment.drivers.register"],
"environment.probe": ["environment.drivers.register"],
"environment.acquireLease": ["environment.drivers.register"],
"environment.resumeLease": ["environment.drivers.register"],
"environment.releaseLease": ["environment.drivers.register"],
"environment.destroyLease": ["environment.drivers.register"],
"environment.realizeWorkspace": ["environment.drivers.register"],
"environment.execute": ["environment.drivers.register"],
"environment.startInteractiveSetup": ["environment.drivers.register"],
"environment.getInteractiveSetup": ["environment.drivers.register"],
"environment.captureTemplate": ["environment.drivers.register"],
"environment.cancelInteractiveSetup": ["environment.drivers.register"],
"environment.deleteTemplate": ["environment.drivers.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",
appShellOverlay: "ui.action.register",
toolbarButton: "ui.action.register",
contextMenuItem: "ui.action.register",
commentAnnotation: "ui.commentAnnotation.register",
commentContextMenuItem: "ui.action.register",
settingsPage: "instance.settings.register",
companySettingsPage: "instance.settings.register",
routeSidebar: "ui.sidebar.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",
database: "database.namespace.migrate",
environmentDrivers: "environment.drivers.register",
agents: "agents.managed",
projects: "projects.managed",
routines: "routines.managed",
objectReferences: "external.objects.detect",
};
// ---------------------------------------------------------------------------
// 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);
}
}
}
if ((manifest.objectReferences?.length ?? 0) > 0) {
for (const requiredCap of ["external.objects.detect", "external.objects.read"] as const) {
if (!declared.has(requiredCap) && !allMissing.includes(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];
},
};
}