Problem
PluginCapability (in packages/core/src/plugins/types.ts) has grown organically and is now inconsistent in three ways that hurt readability and create real audit/security hazards.
Current set
network:fetch
network:fetch:any
read:content
write:content
read:media
write:media
read:users
email:send
email:provide
email:intercept
page:inject
Issues
- Order is inverted from RBAC permissions. RBAC uses
<resource>:<verb> (e.g. content:read); capabilities use <verb>:<resource> (e.g. read:content) for some entries and <resource>:<verb> (e.g. network:fetch, email:send) for others. Same product, two grammars; capabilities are inconsistent even with themselves.
:any is overloaded. In RBAC, _any is an ownership qualifier ("not just own"). In capabilities, network:fetch:any means "no host allowlist" — a scope qualifier. Same suffix, different semantic. network:fetch and network:fetch:any differ by three characters; easy to misgrant or overlook in review.
- Hook-registration permissions are mis-categorized.
email:provide, email:intercept, and page:inject don't gate context APIs — they gate which hooks the plugin is allowed to register. They share the naming slot with data-access capabilities, which obscures escalation in audits. email:provide reads like "the plugin sends email"; it actually means "the plugin can replace the entire mail transport."
- No closed verb vocabulary.
fetch, read, write, send, provide, intercept, inject — new capabilities can pick any word.
Proposed formula
Match the RBAC shape (which we're keeping as-is for backwards compatibility):
<resource>[.<sub-resource>]:<verb>[:<qualifier>]
- Resource first, verb second. Matches RBAC. One mental model for the whole product.
. separates resource compartments.
: is the only slot delimiter.
_ is not a separator. One delimiter, one job.
Verb vocabulary (closed set)
| Verb |
Meaning |
read |
observe / list |
write |
create + update + delete (intentionally coarse — plugins are a trust boundary, not an authz edge) |
send |
one-shot emit (email, etc.) |
request |
outbound network call |
register |
permission to register a hook of a given family |
Qualifier vocabulary (closed set)
| Qualifier |
Meaning |
| (none) |
the constrained / default form |
unrestricted |
bypasses host allowlist or scope restrictions |
unrestricted is intentionally verbose. Granting it should look conspicuous in a manifest. Today's :any is too easy to skim past.
Hook-registration capabilities
Hook-registration permissions take a sub-resource that names the hook family:
hooks.email-transport:register
hooks.email-events:register
hooks.page-fragments:register
Now it's obvious in a manifest review: "this plugin is requesting permission to register hooks." Different audit category from data access.
Concrete rename table
| Old |
New |
read:content |
content:read |
write:content |
content:write |
read:media |
media:read |
write:media |
media:write |
read:users |
users:read |
email:send |
email:send |
network:fetch |
network:request |
network:fetch:any |
network:request:unrestricted |
email:provide |
hooks.email-transport:register |
email:intercept |
hooks.email-events:register |
page:inject |
hooks.page-fragments:register |
Proposed implementation
Single change-set across these files, with deprecation aliases so existing plugins keep working.
Where to warn (and where not to)
A previous instinct was to warn at runtime load — wrong, because the user loading a plugin from npm or marketplace can't edit manifest.json. Pressure has to land where the plugin author can fix it.
| Stage |
Behavior on deprecated capability |
emdash plugin bundle |
warn, continue |
emdash plugin validate |
warn, continue |
emdash plugin publish |
error, refuse to publish |
| Runtime load (trusted + sandboxed) |
silent normalize old → new |
diffCapabilities (marketplace handler) |
normalize both sides before comparing, so users don't see spurious "capability changed" prompts on upgrade |
Hard-fail at publish is correct because the fix is mechanical and entirely in the author's hands. Better to refuse 5 publishes than to ship 500 deprecated manifests.
Files
-
packages/core/src/plugins/types.ts
Add new capability strings to the PluginCapability union. Keep old strings in the union for one minor with @deprecated JSDoc tags. Add a CAPABILITY_RENAMES: Record<DeprecatedCapability, PluginCapability> map.
-
packages/core/src/plugins/manifest-schema.ts
Update PLUGIN_CAPABILITIES enum. Accept both old and new during the deprecation window.
-
packages/core/src/plugins/define-plugin.ts
Normalize old capability names to new at definition time. No console output. Keep capability-implication logic (write:content implies read:content) updated for new names.
-
packages/core/src/plugins/adapt-sandbox-entry.ts
Same silent normalization as define-plugin.ts. Used for sandboxed plugins.
-
packages/core/src/plugins/context.ts
Update capability checks at lines 897-944 to use new names. The runtime should never see old names because of step 3/4 normalization.
-
packages/core/src/plugins/hooks.ts
Update HOOK_REQUIRED_CAPABILITY map (around line 244) to map hook names to new capability strings. Critically: the email and page-fragments hooks now require hooks.<family>:register capabilities, which makes the gate match its actual semantic.
-
packages/core/src/cli/commands/bundle.ts
Warn for each deprecated capability used. Append to the existing capability warnings block at line 525. Existing network:fetch:any warning is updated to recognize the new network:request:unrestricted.
-
packages/core/src/cli/commands/publish.ts
Add a deprecation check after manifest read (around line 432) that errors and exits if any deprecated capability is present, before authentication.
-
packages/core/src/cli/commands/plugin-validate.ts
Same deprecation check, warn-only for the validate command.
-
packages/core/src/api/handlers/marketplace.ts
diffCapabilities (around line 94) normalizes both sides before diffing.
-
packages/core/src/cli/commands/plugin-init.ts
Update generated template to use new names.
-
Documentation in docs/
Update plugin authoring docs and any examples in demos/ and templates/.
Tests
- Existing capability tests pass with old names (alias layer).
- New tests for: normalization in
define-plugin and adapt-sandbox-entry, publish hard-fail on deprecated, bundle warning emission, diffCapabilities cross-version equivalence (read:content v1 → content:read v2 reports no change).
Deprecation timeline
- Next minor: introduce new names, accept old names with normalization at runtime, warn at bundle/validate, error at publish. Mark old names
@deprecated.
- Following minor: drop old names entirely. Plugins that haven't been re-bundled fail to load with a clear error pointing to the rename table.
Trade-offs
hooks.<family>:register is more verbose than email:provide. Yes — the verbosity is doing work. It forces reviewers to notice they're granting hook registration, not data access.
write stays coarse rather than splitting into create/update/delete. Inconsistent with how I'd organize RBAC, but plugins are a different consumer: trust boundary, not per-action authz. The granularity buys nothing for the plugin sandbox.
network:request:unrestricted is long. Should be. Asking for it is asking for the keys to outbound traffic.
- Asymmetry between RBAC and capabilities. RBAC keeps its current names; capabilities adopt the new shape. The two systems will look similar but not identical. Acceptable — RBAC is internal API surface, capabilities are author-facing config.
Open questions
- Hard-fail at publish or soft prompt? My read: hard-fail. Marketplace stays clean and the fix is one re-bundle. Open to discussion.
- Drop
read:users entirely? It's the only read:* with no write:* peer; we may want to leave it as a 1:1 rename to avoid scope creep, even though it's slightly odd in the new shape.
- Should
email:send become email:write to fit the closed verb set? I lean no — send reads better and the verb is genuinely different from CRUD-on-stored-resources. Closed set with one exception is fine; closed set with arbitrary additions is not.
Discussion-first?
Per CONTRIBUTING.md, feature changes need an approved Discussion before a PR. Filing this as an issue for tracking and discoverability — happy to convert to a Discussion in the Ideas category if that's the preferred route.
Problem
PluginCapability(inpackages/core/src/plugins/types.ts) has grown organically and is now inconsistent in three ways that hurt readability and create real audit/security hazards.Current set
Issues
<resource>:<verb>(e.g.content:read); capabilities use<verb>:<resource>(e.g.read:content) for some entries and<resource>:<verb>(e.g.network:fetch,email:send) for others. Same product, two grammars; capabilities are inconsistent even with themselves.:anyis overloaded. In RBAC,_anyis an ownership qualifier ("not just own"). In capabilities,network:fetch:anymeans "no host allowlist" — a scope qualifier. Same suffix, different semantic.network:fetchandnetwork:fetch:anydiffer by three characters; easy to misgrant or overlook in review.email:provide,email:intercept, andpage:injectdon't gate context APIs — they gate which hooks the plugin is allowed to register. They share the naming slot with data-access capabilities, which obscures escalation in audits.email:providereads like "the plugin sends email"; it actually means "the plugin can replace the entire mail transport."fetch,read,write,send,provide,intercept,inject— new capabilities can pick any word.Proposed formula
Match the RBAC shape (which we're keeping as-is for backwards compatibility):
.separates resource compartments.:is the only slot delimiter._is not a separator. One delimiter, one job.Verb vocabulary (closed set)
readwritesendrequestregisterQualifier vocabulary (closed set)
unrestrictedunrestrictedis intentionally verbose. Granting it should look conspicuous in a manifest. Today's:anyis too easy to skim past.Hook-registration capabilities
Hook-registration permissions take a sub-resource that names the hook family:
Now it's obvious in a manifest review: "this plugin is requesting permission to register hooks." Different audit category from data access.
Concrete rename table
read:contentcontent:readwrite:contentcontent:writeread:mediamedia:readwrite:mediamedia:writeread:usersusers:reademail:sendemail:sendnetwork:fetchnetwork:requestnetwork:fetch:anynetwork:request:unrestrictedemail:providehooks.email-transport:registeremail:intercepthooks.email-events:registerpage:injecthooks.page-fragments:registerProposed implementation
Single change-set across these files, with deprecation aliases so existing plugins keep working.
Where to warn (and where not to)
A previous instinct was to warn at runtime load — wrong, because the user loading a plugin from npm or marketplace can't edit
manifest.json. Pressure has to land where the plugin author can fix it.emdash plugin bundleemdash plugin validateemdash plugin publishdiffCapabilities(marketplace handler)Hard-fail at publish is correct because the fix is mechanical and entirely in the author's hands. Better to refuse 5 publishes than to ship 500 deprecated manifests.
Files
packages/core/src/plugins/types.tsAdd new capability strings to the
PluginCapabilityunion. Keep old strings in the union for one minor with@deprecatedJSDoc tags. Add aCAPABILITY_RENAMES: Record<DeprecatedCapability, PluginCapability>map.packages/core/src/plugins/manifest-schema.tsUpdate
PLUGIN_CAPABILITIESenum. Accept both old and new during the deprecation window.packages/core/src/plugins/define-plugin.tsNormalize old capability names to new at definition time. No console output. Keep capability-implication logic (
write:contentimpliesread:content) updated for new names.packages/core/src/plugins/adapt-sandbox-entry.tsSame silent normalization as
define-plugin.ts. Used for sandboxed plugins.packages/core/src/plugins/context.tsUpdate capability checks at lines 897-944 to use new names. The runtime should never see old names because of step 3/4 normalization.
packages/core/src/plugins/hooks.tsUpdate
HOOK_REQUIRED_CAPABILITYmap (around line 244) to map hook names to new capability strings. Critically: the email and page-fragments hooks now requirehooks.<family>:registercapabilities, which makes the gate match its actual semantic.packages/core/src/cli/commands/bundle.tsWarn for each deprecated capability used. Append to the existing capability warnings block at line 525. Existing
network:fetch:anywarning is updated to recognize the newnetwork:request:unrestricted.packages/core/src/cli/commands/publish.tsAdd a deprecation check after manifest read (around line 432) that errors and exits if any deprecated capability is present, before authentication.
packages/core/src/cli/commands/plugin-validate.tsSame deprecation check, warn-only for the
validatecommand.packages/core/src/api/handlers/marketplace.tsdiffCapabilities(around line 94) normalizes both sides before diffing.packages/core/src/cli/commands/plugin-init.tsUpdate generated template to use new names.
Documentation in
docs/Update plugin authoring docs and any examples in
demos/andtemplates/.Tests
define-pluginandadapt-sandbox-entry, publish hard-fail on deprecated, bundle warning emission,diffCapabilitiescross-version equivalence (read:contentv1 →content:readv2 reports no change).Deprecation timeline
@deprecated.Trade-offs
hooks.<family>:registeris more verbose thanemail:provide. Yes — the verbosity is doing work. It forces reviewers to notice they're granting hook registration, not data access.writestays coarse rather than splitting into create/update/delete. Inconsistent with how I'd organize RBAC, but plugins are a different consumer: trust boundary, not per-action authz. The granularity buys nothing for the plugin sandbox.network:request:unrestrictedis long. Should be. Asking for it is asking for the keys to outbound traffic.Open questions
read:usersentirely? It's the onlyread:*with nowrite:*peer; we may want to leave it as a 1:1 rename to avoid scope creep, even though it's slightly odd in the new shape.email:sendbecomeemail:writeto fit the closed verb set? I lean no —sendreads better and the verb is genuinely different from CRUD-on-stored-resources. Closed set with one exception is fine; closed set with arbitrary additions is not.Discussion-first?
Per CONTRIBUTING.md, feature changes need an approved Discussion before a PR. Filing this as an issue for tracking and discoverability — happy to convert to a Discussion in the Ideas category if that's the preferred route.