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).
Related Issues
| Issue | Title |
|---|---|
| M26-001 | Plugin Sandbox (QuickJS WASM Runtime for Community Plugin Isolation) |
| M26-002 | Plugin Manifest & Distribution (npm Registry, Manifest Format) |
| M26-003 | Subscription Gating (Feature Flags, Entitlement Checks, Paywall UX) |
| M26-004 | Plugin Scoping (Session-Specific Plugin Enable/Disable per Session) |
| M16-013 | Plugin 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.
| Permission | Description |
|---|---|
scripture.read | Read scripture passages |
annotations.read | Read user highlights and annotations |
notes.read | Read user notes |
studyMap.read | Read study map graph nodes and edges |
pinboard.read | Read 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).
scripture.write permissionScripture 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.
| Permission | Description |
|---|---|
annotations.write | Create, update, and delete annotations |
notes.write | Create, update, and delete notes |
studyMap.write | Add, remove, and rearrange map nodes |
pinboard.write | Modify pinboard items |
Surface
Contribute UI elements to the GospeLib shell. These permissions control where a plugin can render itself.
| Permission | Description |
|---|---|
contribute.hoverCardTab | Add a tab to the hover card |
contribute.sidebarWidget | Add a widget to the sidebar |
contribute.paneType | Register a custom pane type |
contribute.commandPaletteAction | Add an action to the command palette |
contribute.toolbarAction | Add a button to the toolbar |
Integration
Access to external services and cross-plugin communication.
| Permission | Description |
|---|---|
ai.query | Send queries to the AI study assistant service |
search.query | Execute search queries via Typesense |
network.fetch | Make HTTP requests (restricted to declared allowlist) |
bus.publish | Publish events on the plugin event bus |
bus.subscribe | Subscribe 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.
| Permission | Enforcement Layer | Mechanism |
|---|---|---|
scripture.read | Sandbox bridge | Bridge method gated by permission check |
annotations.read | Sandbox bridge | Bridge method gated by permission check |
annotations.write | Sandbox bridge + API | Bridge gate + server-side plugin-token validation |
notes.read | Sandbox bridge | Bridge method gated by permission check |
notes.write | Sandbox bridge + API | Bridge gate + server-side plugin-token validation |
studyMap.read | Sandbox bridge | Bridge method gated by permission check |
studyMap.write | Sandbox bridge + API | Bridge gate + server-side plugin-token validation |
pinboard.read | Sandbox bridge | Bridge method gated by permission check |
pinboard.write | Sandbox bridge + API | Bridge gate + server-side plugin-token validation |
contribute.hoverCardTab | Plugin host | Host ignores ungranted surface contributions |
contribute.sidebarWidget | Plugin host | Host ignores ungranted surface contributions |
contribute.paneType | Plugin host | Host ignores ungranted pane type registrations |
contribute.commandPaletteAction | Plugin host | Host ignores ungranted command palette actions |
contribute.toolbarAction | Plugin host | Host ignores ungranted toolbar actions |
ai.query | Sandbox bridge + API | Bridge gate + rate-limited server-side validation |
search.query | Sandbox bridge + API | Bridge gate + server-side validation |
network.fetch | Sandbox bridge + proxy | Bridge gate + URL allowlist check in fetch proxy |
bus.publish | Plugin event bus | Event bus rejects unauthorized publishers |
bus.subscribe | Plugin event bus | Event bus rejects unauthorized subscribers |
3. Permission-Prompt UX Flow
When a user installs a plugin, the system presents a permission prompt:
- Install trigger: User clicks "Install" in the plugin gallery or runs
gospelib plugin install <name>. - 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. - 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. - Accept / Cancel: "Allow & Install" grants the toggled-on permissions and installs the plugin. "Cancel" aborts.
- 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.
- Persistence: Granted permissions are stored in Dexie (
gl_plugin_permissionstable) keyed bypluginId. 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
permissionsarray, 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
PermissionDeniedErrorwhen the plugin attempts to use the corresponding API. - If a permission is listed and granted but later revoked, the same
PermissionDeniedErroris 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:
- Navigate to Settings > Plugins > [Plugin Name] > Permissions.
- Toggle off any granted permission.
- The change takes effect immediately -- the sandbox bridge stops exposing the revoked API.
- The plugin receives a
permissionRevokedevent on the event bus (if it hasbus.subscribe), allowing it to degrade gracefully (e.g., hide a UI panel that depended oncontribute.sidebarWidget). - 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.grantandplugin.permission.revokewithpluginIdandpermissionfields. - 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 thePluginPermissionunion type. Existing plugins that do not declare it are unaffected. - Manifest version field: The
gospelib.manifestVersionfield inpackage.jsontracks the permission schema version. Currently1. - 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
deprecatedPermissionsregistry. 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 amanifestVersionhigher 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://(nohttp://, nodata:, noblob:). - 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
networkAllowlistwithnetwork.fetchrequested 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.fetchallowlist 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.fetchallowlist 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.
Related
- ADR-004: Plugin Sandbox — the QuickJS WASM isolation layer that enforces these permissions
- Decisions Overview — full ADR list