Skip to content

Trust & scopes

A plugin is trusted through two layers. For the jail: an admin consents to each capability at install (POST /api/plugins/install/confirm, admin-gated), and the Deno sandbox enforces exactly the granted set — see Sandboxed packages. For the declarative seams: a closed scope taxonomy plus a per-installation scoped token gate every HTTP call a seam makes. There is no marketplace; the host is the trust boundary — a plugin runs under one operator’s host, scoped to its projects. (Web install is admin-gated; a manifest-only plugin can also be sideloaded by an operator with host access.)

This page covers the scope/scoped-token layer; capability consent is documented with the jail. The two coexist — a package that ships a main.ts and declares seams is gated by both.

Each plugin installation — a (pluginId, projectPath) pair — gets its own token (the GitHub-Apps model). It is the capability gate over every seam.

Lifecycle — minted on enable, exposed only while enabled. There is no separate “install” step: dropping a directory into a plugins root only makes the plugin discovered (disabled by default). The per-install token is minted the first time the plugin is enabled for a project, and GET /api/plugins?projectPath=<abs> returns the token only for that user’s enabled plugins (the field is omitted for disabled ones). When a panel plugin is launched, the web client passes the returned token into the mounted iframe as the lowkey_plugin_token query parameter (see surfaces); the plugin’s service then presents it on every call back to Lowkey.

Disable = revoke. The scoped-token guard re-checks enablement on every request: a call made with the token of a now-disabled installation is denied (plugin_disabled, 403). Disabling a plugin therefore cuts off its access without rotating any secret.

  • Format: lkp_ + random. The service authenticates back to Lowkey by sending it as Authorization: Bearer lkp_… or X-Lowkey-Plugin-Token: lkp_… (header). The iframe receives it as the lowkey_plugin_token query param; the service sends it as a header.
  • Scope of authority: the token authorizes calls only for its plugin, only in its project, and only against the scopes its manifest declares.
  • Isolation: a plugin cannot read another plugin’s token or a provider’s secrets. Provider-credential access is refused outright in v1.
  • Per request: every scoped route checks the token, the projectPath match, that the installation is registered and enabled, the manifest’s declared scope, and (where relevant) credential access — in that order.

The token is bound to the installation, not to a user session.

The closed set (also in the manifest reference):

Scope Grants Trust tier
cards Emit closed cards into the feed. Third-party OK.
mcp Register an MCP server, injected per project. Third-party OK.
webhook Inbound trigger + turn-completion subscription. Third-party OK.
focus Mount a surface in the main pane. Third-party OK.
side-panel Mount a surface in the side panel. Third-party OK.
session-read Read a session’s transcript + lineage. Consentable third-party.
agent Contribute an AgentDef (prompt, mode, translator, runtime). Consent-worthy privilege.

Not in v1 (deferred):

  • media — camera/mic delegated to a surface’s iframe via Permissions-Policy. When it lands it is first-party / trusted only; the highest-stakes scope.
  • storage — a filesystem/storage-provider seam. Deferred until a storage/transport plugin is built; gated on the credential broker.

Scopes are declared in the manifest and consented to when the plugin is enabled for a project (the point at which its scoped token is minted). Two trust distinctions matter when you design a plugin:

  • agent is a privilege, not just an API scope. A plugin that only contributes an AgentDef calls no API, yet it runs on the operator’s provider credential, speaks to users, and (via platformPromptMode: minimal) can lower the safety floor. It is consent-worthy on its own.
  • session-read is consentable for third parties — it reads the session’s own transcript + lineage, not platform internals — but it is still a deliberate grant a user makes knowingly.

Some seams are reserved for first-party / trusted plugins and are not granted to third parties in v1:

  • media delegation (deferred; first-party-only when it lands).
  • platformPromptMode: none — not exposed at all in v1; a first-party-only trapdoor if/when it returns, behind a no-tools-attached guarantee.

Everything else in the closed scope set is available to third parties, subject to consent.

Scoped routes return a JSON body { "error": "unauthorized" | "forbidden", "reason": "<reason>" }:

Reason Status Cause
missing_token 401 No token on the request.
invalid_token 401 Token not recognized.
missing_project 403 No projectPath supplied.
project_mismatch 403 projectPath ≠ the token’s installation project.
plugin_not_registered 403 No manifest resolves for the installation.
plugin_disabled 403 The installation exists but is disabled for the project (revoked access).
plugin_mismatch 403 Resolved manifest id ≠ the token’s plugin id.
undeclared_scope 403 The route’s scope is not in the manifest’s scopes.
credential_forbidden 403 The route asked for credential access the token may not have.

The manifest carries signing (publicKey, signature). Designed-for but unenforced in v1 — integrity verification lands with the catalog. Include the fields; do not rely on enforcement yet.