Skip to main content

ADR 003: Plugin Permissions Model

  • Status: Accepted
  • Date: 2026-06-11
  • Deciders: Project owner

Context

GospeLib M26 introduces a community plugin ecosystem where third-party developers can extend the application with custom panels, toolbar actions, hover card tabs, and data integrations. Community plugins execute inside a QuickJS WASM sandbox (ADR-004), but the sandbox alone is insufficient: we need a declarative permissions model so that users understand what each plugin can access and can grant or revoke individual capabilities at install time and afterward.

Without a formal permissions model:

  • Plugins could silently read all user annotations and notes.
  • Plugins could make arbitrary network requests, leaking user data.
  • There is no auditable record of what a plugin was allowed to do.
  • Users have no mechanism to restrict a plugin they have already installed.

The permissions model must integrate with the existing plugin manifest format (M26-002), the QuickJS sandbox bridge (M26-001), and the subscription gating layer (M26-003).

IssueTitle
M26-001Plugin Sandbox (QuickJS WASM Runtime for Community Plugin Isolation)
M26-002Plugin Manifest & Distribution (npm Registry, Manifest Format)
M26-003Subscription Gating (Feature Flags, Entitlement Checks, Paywall UX)
M26-004Plugin Scoping (Session-Specific Plugin Enable/Disable per Session)
M16-013Plugin Event Bus

Decision

1. Four Permission Groups

All plugin permissions are organized into four groups. Each permission is a dot-separated string literal. The canonical list lives in packages/plugin-sdk/src/permissions.ts as a TypeScript string literal union type.

Content-read

Read-only access to user and corpus data.

PermissionDescription
scripture.readRead scripture passages
annotations.readRead user highlights and annotations
notes.readRead user notes
studyMap.readRead study map graph nodes and edges
pinboard.readRead pinboard items and pins

Content-write

Mutating access to user data. Each write permission implies its corresponding read permission (e.g. annotations.write implies annotations.read).

No scripture.write permission

Scripture text is corpus-level reference data, not user-owned content. Allowing plugins to modify scripture passages would undermine data integrity and trust in the canonical text. Plugins that need to present alternative renderings or transliterations should use surface contributions (e.g. contribute.paneType) to display their output alongside the unmodified scripture.

PermissionDescription
annotations.writeCreate, update, and delete annotations
notes.writeCreate, update, and delete notes
studyMap.writeAdd, remove, and rearrange map nodes
pinboard.writeModify pinboard items

Surface

Contribute UI elements to the GospeLib shell. These permissions control where a plugin can render itself.

PermissionDescription
contribute.hoverCardTabAdd a tab to the hover card
contribute.sidebarWidgetAdd a widget to the sidebar
contribute.paneTypeRegister a custom pane type
contribute.commandPaletteActionAdd an action to the command palette
contribute.toolbarActionAdd a button to the toolbar

Integration

Access to external services and cross-plugin communication.

PermissionDescription
ai.querySend queries to the AI study assistant service
search.queryExecute search queries via Typesense
network.fetchMake HTTP requests (restricted to declared allowlist)
bus.publishPublish events on the plugin event bus
bus.subscribeSubscribe to events on the plugin event bus

2. Enforcement-Boundary Table

Each permission is enforced at a specific layer. A plugin cannot bypass enforcement because all layers sit between the plugin and the resource.

PermissionEnforcement LayerMechanism
scripture.readSandbox bridgeBridge method gated by permission check
annotations.readSandbox bridgeBridge method gated by permission check
annotations.writeSandbox bridge + APIBridge gate + server-side plugin-token validation
notes.readSandbox bridgeBridge method gated by permission check
notes.writeSandbox bridge + APIBridge gate + server-side plugin-token validation
studyMap.readSandbox bridgeBridge method gated by permission check
studyMap.writeSandbox bridge + APIBridge gate + server-side plugin-token validation
pinboard.readSandbox bridgeBridge method gated by permission check
pinboard.writeSandbox bridge + APIBridge gate + server-side plugin-token validation
contribute.hoverCardTabPlugin hostHost ignores ungranted surface contributions
contribute.sidebarWidgetPlugin hostHost ignores ungranted surface contributions
contribute.paneTypePlugin hostHost ignores ungranted pane type registrations
contribute.commandPaletteActionPlugin hostHost ignores ungranted command palette actions
contribute.toolbarActionPlugin hostHost ignores ungranted toolbar actions
ai.querySandbox bridge + APIBridge gate + rate-limited server-side validation
search.querySandbox bridge + APIBridge gate + server-side validation
network.fetchSandbox bridge + proxyBridge gate + URL allowlist check in fetch proxy
bus.publishPlugin event busEvent bus rejects unauthorized publishers
bus.subscribePlugin event busEvent bus rejects unauthorized subscribers

3. Permission-Prompt UX Flow

When a user installs a plugin, the system presents a permission prompt:

  1. Install trigger: User clicks "Install" in the plugin gallery or runs gospelib plugin install <name>.
  2. Modal prompt: A modal dialog appears listing all requested permissions, grouped by category (Content-read, Content-write, Surface, Integration). Each permission has a one-line human-readable description. Sensitive permissions (network.fetch, *.write) are highlighted with a warning icon.
  3. Granular toggle: The user can toggle individual permissions off. The plugin may declare some permissions as required -- disabling those prevents installation and shows a "required for core functionality" note.
  4. Accept / Cancel: "Allow & Install" grants the toggled-on permissions and installs the plugin. "Cancel" aborts.
  5. Toast on upgrade: When an installed plugin requests new permissions in an update, a toast notification appears: "Plugin X requests new permissions." Tapping the toast opens the modal.
  6. Persistence: Granted permissions are stored in Dexie (gl_plugin_permissions table) keyed by pluginId. The record includes the permission set, grant timestamp, and granting user action (install vs. upgrade).

4. Default-Deny Semantics

All permissions follow default-deny:

  • If a permission is not listed in the plugin's manifest permissions array, the plugin cannot request it at runtime.
  • If a permission is listed in the manifest but the user has not granted it, the sandbox bridge returns a PermissionDeniedError when the plugin attempts to use the corresponding API.
  • If a permission is listed and granted but later revoked, the same PermissionDeniedError is raised on next use. The plugin is expected to handle this error gracefully.
  • Official plugins (core.* namespace) are pre-granted all permissions they declare. They are not subject to the user prompt flow.

5. Revocation Path

Users can revoke any previously granted permission at any time:

  1. Navigate to Settings > Plugins > [Plugin Name] > Permissions.
  2. Toggle off any granted permission.
  3. The change takes effect immediately -- the sandbox bridge stops exposing the revoked API.
  4. The plugin receives a permissionRevoked event on the event bus (if it has bus.subscribe), allowing it to degrade gracefully (e.g., hide a UI panel that depended on contribute.sidebarWidget).
  5. If a required permission is revoked, the plugin is disabled entirely with a user-facing notice: "Plugin X requires [permission] to function. Re-enable the permission or uninstall the plugin."

6. Audit-Log Hook

Every permission grant and revocation is logged:

  • Dexie table gl_plugin_audit_log: { id, pluginId, permission, action: 'grant' | 'revoke', timestamp, source: 'install' | 'upgrade' | 'settings' }
  • Telemetry event (if user has opted into analytics): plugin.permission.grant and plugin.permission.revoke with pluginId and permission fields.
  • Audit log entries are immutable (append-only). The log can be viewed in Settings > Plugins > Audit Log.
  • Retention: local Dexie entries are retained indefinitely. Telemetry events follow the standard 90-day retention policy.

7. Migration and Versioning

Adding new permissions to the model must not break installed plugins:

  • New permissions are additive: A new permission (e.g., bookmarks.read) is added to the PluginPermission union type. Existing plugins that do not declare it are unaffected.
  • Manifest version field: The gospelib.manifestVersion field in package.json tracks the permission schema version. Currently 1.
  • Forward compatibility: The plugin host ignores permissions it does not recognize (e.g., a plugin built against manifest v2 running on a host that only knows v1). Unrecognized permissions are logged as warnings but do not prevent installation.
  • Deprecation flow: A permission can be deprecated by adding it to a deprecatedPermissions registry. The host emits a console warning when a plugin uses a deprecated permission. After two minor versions, the permission is removed and plugins using it fail to validate at install time.
  • Breaking changes: Removing or renaming a permission is a breaking change that bumps manifestVersion. The host refuses to install plugins with a manifestVersion higher than it supports.

8. network.fetch Allowlist Syntax

The network.fetch permission is unique: it is scoped to a declared set of URL patterns. The plugin manifest must include a networkAllowlist array when requesting network.fetch.

Allowlist format

{
"name": "@community/greek-lexicon",
"gospelib": {
"manifestVersion": 1,
"permissions": ["scripture.read", "network.fetch", "contribute.sidebarWidget"],
"networkAllowlist": ["https://api.example.com/*", "https://cdn.example.com/assets/*"],
},
}

Pattern rules

  • Patterns use a simplified glob syntax: * matches any path segment(s).
  • Scheme must be https:// (no http://, no data:, no blob:).
  • Wildcards in the host portion are not allowed (no https://*.example.com).
  • Each pattern must specify at least the origin (https://api.example.com).
  • At install time, the user sees the full list of allowed domains in the permission prompt.
  • At runtime, the sandbox fetch proxy checks each outgoing URL against the allowlist. Non-matching URLs are rejected with NetworkNotAllowedError.
  • An empty networkAllowlist with network.fetch requested is a manifest validation error -- the plugin cannot be installed.

Consequences

Positive

  • Users have clear visibility into what each plugin can access before installation.
  • The permission model is enforceable at multiple layers (sandbox bridge, plugin host, API server), providing defense in depth.
  • Granular revocation lets users restrict plugins without uninstalling them.
  • The audit log provides accountability for security-sensitive operations.
  • The network.fetch allowlist prevents data exfiltration to arbitrary endpoints.
  • The model is extensible -- new permissions can be added without breaking existing plugins.

Negative

  • The permission prompt adds friction to the install flow. Users may find the modal annoying for plugins that request many permissions.
  • Maintaining enforcement at multiple layers (bridge + host + API) increases implementation complexity.
  • The network.fetch allowlist is restrictive -- plugins that need to call many APIs will have verbose manifests.

Neutral

  • Official core.* plugins bypass the prompt but still declare permissions in their manifests for documentation purposes.
  • The audit log table grows unboundedly in Dexie; a future cleanup job may be needed for users who install and uninstall many plugins.
  • The permission enum will grow as new plugin capabilities are added in future milestones.

Alternatives Considered

VS Code Extension Permissions

VS Code uses a coarse permissions model where extensions declare broad capabilities (e.g., vscode.workspace, vscode.window) but users cannot selectively disable individual permissions. We rejected this because GospeLib plugins handle sensitive user study data (annotations, notes, reading history) that warrants granular control.

Chrome permissions[] Model

Chrome extensions use a permissions array in manifest.json with host permissions for network access. This model is closest to our design and heavily influenced it. We adopted Chrome's allowlist approach for network.fetch but chose a flatter permission namespace (dot-separated strings) instead of Chrome's mixed bag of API names and URL patterns. We also added the revocation path and audit log, which Chrome does not expose to users in a structured way.

Capability-Based Security (Object Capabilities)

An alternative to declarative permissions is to pass capability objects into the sandbox at initialization. This provides stronger theoretical guarantees but is harder for users to understand and for plugin developers to declare in a manifest. We opted for the declarative model for its simplicity and familiarity.