Skip to content

Plugin capability names are inconsistent — propose unified formula and deprecation path #767

@ascorbic

Description

@ascorbic

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

  1. 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.
  2. :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.
  3. 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."
  4. 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

  1. 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.

  2. packages/core/src/plugins/manifest-schema.ts
    Update PLUGIN_CAPABILITIES enum. Accept both old and new during the deprecation window.

  3. 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.

  4. packages/core/src/plugins/adapt-sandbox-entry.ts
    Same silent normalization as define-plugin.ts. Used for sandboxed plugins.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. packages/core/src/cli/commands/plugin-validate.ts
    Same deprecation check, warn-only for the validate command.

  10. packages/core/src/api/handlers/marketplace.ts
    diffCapabilities (around line 94) normalizes both sides before diffing.

  11. packages/core/src/cli/commands/plugin-init.ts
    Update generated template to use new names.

  12. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions