Accepted
ADR 0036 — Plugin manifest and capability model
Every plugin declares exactly what it can do; the host hands it nothing more
Plugins run inside Dynamic Worker Loader sub-isolates. The manifest is the negotiation surface: it lists the capabilities the plugin needs, and the host hands the sub-isolate bindings for exactly those capabilities and nothing else. A plugin that does not declare a capability cannot exercise it, because the binding for it is never constructed.
The decision
A plugin manifest is validated against a JSON Schema at install time. Capabilities are a fixed, enumerable vocabulary. The host translates the declared capabilities into the loader's bindings and outbound-egress configuration at the moment the isolate loads; that translation is the single policy-enforcement point.
Capability families
The vocabulary is grouped into five families. Each grant is narrow and explicit.
- Storage — kv.read / kv.write, d1.read / d1.write, r2.read / r2.write. Every storage grant is scoped to the plugin's own prefix or tables (see the Plugin storage scoping record). Write implies read.
- Communication — email.send sends through the host's email service with the from address fixed to the deployment's verified domain; the plugin cannot forge it. http.fetch:{hostname} grants outbound fetch to one enumerated hostname over HTTPS.
- Content — content.read / content.write reach documents through a host-supplied API, never raw D1; writes go through the versioned-write helper. media.read / media.write and setting.read:{key} work the same way, filtered by the host.
- Hooks — hook:{event} subscribes to a host lifecycle event. Every hook the plugin registers must be declared, or the host rejects it at load time.
- Admin — admin.page:{slug} contributes a React page under /admin/plugins/{pluginId}/{slug}; admin.api:{path} contributes an endpoint under /api/admin/plugins/{pluginId}/{path}.
A plugin's own configuration namespace is always granted without a declaration: it reads and writes settings under plugin.{pluginId}.config.*. Unknown capability strings and the reserved core and theme prefixes fail validation cleanly at install.
Manifest shape
The manifest is a structured object, not a flat string array. Requested bindings, hook subscriptions, contributed blocks and field types, and admin pages each have their own section, validated independently.
{
"requestedBindings": {
"kv": true,
"r2": true,
"d1": { "tables": ["widgets"] },
"email": { "from": "sender@example.com" },
"httpFetch": { "hostnames": ["api.example.com", "*.example.com", "user-config:webhookUrl"] }
},
"subscribesTo": [{ "hookName": "document.published", "priority": 100 }],
"contributesBlocks": [],
"contributesFieldTypes": [],
"adminPages": []
}The validator lives at src/worker/install/manifest-plugin.ts and collects every error in one pass, so a plugin author gets one round-trip per pack iteration rather than one per error. A regression test round-trips every bundled first-party plugin manifest through validation, so structural drift fails before merge.
Hostname-scoped HTTP egress
Outbound fetch is the largest exfiltration channel, so it is hostname-enumerated. A bare wildcard is forbidden; every hostname must be named, though a single leading wildcard subdomain such as *.example.com is allowed. HTTPS only — the egress shim rejects plain http URLs.
A plugin that legitimately needs a user-supplied hostname declares user-config:{settingKey}. The egress shim resolves the configured value and runs the private-host SSRF guard on every fetch, against the URL actually requested — load-time resolution would go stale the moment the operator changed the setting.
Install-time consent (planned)
The decided design surfaces capabilities at install in editor language with a plain-English expansion per item — "Send email" rather than email.send — and requires the operator to affirm trust before install is enabled. Consent is per install and per version, not remembered per author, because the capability set belongs to the version; an upgrade that broadens the set re-prompts showing only the additions, while a strictly narrower or unchanged set upgrades without a prompt. This consent surface is not yet implemented: the install dialog today posts the plugin reference (id, version, source URL), and binding-layer enforcement of the declared capabilities plus administrator review are the active trust boundary. The consent prompt and the upgrade re-prompt are planned for a later release.
Why
- The capability surface is enumerable, greppable, and schema-validated. A reviewer reads the manifest and knows exactly what the plugin can do.
- The host is the single enforcement point. Bindings are scoped at construction, so the plugin never reaches raw DB, BUCKET, KV, or EMAIL.
- Coarse permission tiers were rejected — a "writer" tier that grants storage, mail, and arbitrary fetch at once is no better than an unsandboxed plugin. Reading capabilities from the plugin's code by static analysis was rejected too: an eval-based dodge makes false negatives catastrophic, so the declarative manifest is the source of truth.
Consequences
- The capability vocabulary is load-bearing. Adding a new capability is a manifest-schema change and goes through its own decision record.
- Plugin configuration and capability history are versioned through the versioned-write helper, so site-wide time travel covers what a plugin was allowed to do at any past moment.
- An operator can still choose to install a plugin that asks for a lot. The planned consent prompt will not stop a determined trust decision; it ensures the operator actively makes one. Until it ships, administrator review of the manifest is that active decision point, and binding-layer enforcement bounds what the plugin can do regardless.