Capabilities, Grants & Trust

The plugin permission model, the three trust tiers, and the security invariants no plugin can weaken.

Capabilities are requests, grants are permissions

A manifest's capabilities array is a request: the set of host operations the plugin may ever ask for. An operator's grants are the permission: which of those requests are allowed for this installation.

manifest capabilities  ⊇  operator grants  ⊇  what actually runs

Grants can only ever restrict the manifest. There is no mechanism by which an operator, a connected app, an API key, or a plugin can widen a capability beyond what the manifest declared. Declaring a capability never grants it.

Capability names are lowercase domain:action with exact-match semantics. Wildcards are not part of the contract. The namespace is open — adding a new contribution seam does not require a @owlat/plugin-kit major just to add a capability literal — and the host remains the sole authority on which capabilities it recognizes and enforces.

The vocabulary

CapabilityGrants
send:transportContribute a send transport
send:gateContribute a restrict-only autonomy gate
agent:stepContribute an inbox agent-pipeline step
draft:strategyContribute a draft generation strategy
automation:trigger / automation:step / automation:conditionContribute to the matching automation registry
webhooks:publishPublish namespaced webhook events
imports:provideContribute an import provider
scheduler:cronRegister a fixed-interval background cron
ui:navigation / ui:settingsAdd a sidebar or settings destination
worker:enqueueReserved for Tier-3 sandboxed jobs; no host enqueue adapter is shipped
llm:invokeMetered text generation through Owlat's LLM dispatch
plugin-storage:read / plugin-storage:writeNamespaced JSON key/value storage

Connected apps additionally request API scopes, which double as capabilities on a plugin-bound key: contacts:read, contacts:write, events:write, transactional:send, topics:write (also valid on standalone keys) and the Tier-2-only campaigns:read, mail:read, knowledge:read, webhooks:manage, plugin-storage:read, plugin-storage:write.

Tier-2-only scopes fail closed at mint

A Tier-2-only scope has no standalone-key meaning. Key creation rejects it unless the key is bound to a plugin, so it can only ever exist inside the manifest-ceiling + operator-grant model. Legacy keys with no scopes column are deny-all at enforcement.

When enforcement happens

Every hosted operation revalidates the whole chain immediately before it runs — not once at startup:

  1. singleton organization scope,
  2. the plugin is a registered part of the build composition,
  3. the feature flag is on in the resolved flag map,
  4. the manifest declares the capability,
  5. the operator grant for that exact capability exists,
  6. every flag.requiredEnvVars entry is present.

Any failure raises a PluginHostError (capability_not_declared, capability_not_granted, plugin_disabled, required_environment_missing, …) and the contribution's declared safe fallback applies. Denial never invokes plugin code. Disabling a plugin or revoking a grant therefore takes effect on the very next operation — no redeploy, no cache to wait out.

Services, not framework contexts

Hosted plugin code receives a PluginContext built only from host-mediated services:

ServiceNotes
permissionshas(capability) / require(capability)
storageNamespaced JSON KV. Methods take no organization or plugin argument
llmAttributed, budgeted dispatch
loggerStructured logging
schedulerNamed-task scheduling

It never exposes a Convex ctx, a database handle, environment values, raw model objects, or arbitrary scheduler function references.

Storage isolation and limits

The host binds the storage service to the authenticated organization and the validated plugin, then rechecks the flag and the exact plugin-storage:read / plugin-storage:write grant on every operation. Entries are indexed by organization, plugin and key; list cursors are authenticated-encrypted and scope-bound.

LimitValue
Key256 bytes
Value64 KiB
Entries per (org, plugin)1 000
Total bytes per (org, plugin)10 MiB
List page size100
JSON depth / nodes / array items / object fields32 / 4 096 / 1 024 / 1 024

Quota counters update in the same Convex transaction as each write, so the ceiling cannot be raced.

LLM budgets

llm:invoke requires an explicit flag and a validated llmBudget.dailyUsd. The host authorizes before resolving tenant provider config, then rechecks registration, flag, declaration, grant, and budget in the same transaction that reserves spend immediately before dispatch.

Requests are bounded to 64 KiB of UTF-8 input across at most 32 messages of 32 KiB each, and to 2 048 output tokens. Only exact model identities in a provider-specific admission catalog are accepted, and pricing is trusted only for a provider's built-in endpoint — an explicit base URL, Azure deployment, compatible/local server, or unknown provider fails closed.

Accounting uses integer micro-USD. A reservation covers every retry the shared dispatch policy allows; success releases unused headroom but retains a maximum for each failed attempt. Missing or malformed usage, a provider failure, an action crash, an accounting failure, or a provider reporting a different model than the one admitted all retain the conservative reservation for that UTC day. That availability tradeoff never reopens spend the provider may have billed.

Untrusted text

Plugin-produced text is untrusted at every boundary. On the shipped Convex-side boundaries — automation step reasons and autonomy gate reasons — the host clamps the text to a code-point maximum and runs an injection scrub before it can reach a prompt or a stored field. There is deliberately no permissive default policy: each host adapter names the policy for its runtime, and a non-string value, a scrubber that throws, or a scrubber returning a non-string is rejected outright (untrusted_output_rejected).

Those two are the entire list. The assistant is deliberately absent: no plugin can contribute an assistant tool today — assistantTools is a reserved bucket (see Contribution reference) — and the built-in tools' output is injection-scrubbed by the assistant's own scrubForInjection, a core control unrelated to this policy.

Browser-bound plugin text is a different, narrower control: nav and settings labels are clamped to 64 UTF-16 code units (an astral character counts as two, the same unit the manifest validator bounds the name by) with control and bidi-format characters stripped when the entry is derived. That is spoofing defense — Vue's HTML escaping is what defends against markup — and the injection-scrub policy is not applied on that path.

Hosted mail projections are truncated by Unicode code points: from 512, to 2 048, subject 1 024, each decrypted body or draft 65 536, and the classification an autonomy gate sees 128.

Audit attribution

Every successful host-service operation writes a tenant audit row carrying both organizationId and pluginId. Audit metadata is an allowlisted scalar snapshot: storage keys/values/prefixes/cursors, LLM prompts/messages/results, provider errors, credentials, mail content, and caller-defined metadata never enter the audit boundary. A successful hosted mutation writes its audit row in the same transaction as the state change, so a rollback discards both.

The three trust tiers

Tier 1 — bundled plugins

Operator-installed packages composed at build time. They may contribute backend and frontend modules and may ship an isolated Convex component under an injective plugin_<id> namespace. Runtime flags disable them instantly; installing or removing one requires a rebuild.

Trust: the same as the operator's own deployed code. Capabilities, grants, flags, and host-mediated services reduce authority and make actions attributable. They are not a sandbox.

Tier 2 — connected apps

External HTTPS services reached through scoped API keys and webhooks, with a specified signed synchronous-hook protocol. They never execute code inside Convex or Nuxt. The API-key and webhook halves are live; the hook runtime adapter is not shipped. See Connected Apps.

Trust: untrusted network peer. Responses are strictly shape-validated, scrubbed, clamped, size-capped, deadline-bounded, and replay-protected.

Tier 3 — sandboxed worker jobs

The worker-side protocol for untrusted or resource-heavy compute remains behind the code-worker OS boundary, but no host enqueue adapter is shipped. See Sandboxed Jobs.

Trust: untrusted process. No ambient credentials, hard resource and time ceilings, host-controlled command, host-authoritative cancellation and retries.

The same capability vocabulary and host-service principles apply to all three tiers.

Invariants a plugin can never weaken

These hold across every tier and every contribution point. If a change would break one, it is not a plugin feature — it is a bug.

  1. Capabilities are checked at codegen and at runtime. A contribution whose capability is not declared never composes; an operation whose capability is not granted never runs.
  2. Operator grants can only restrict the manifest. Nothing widens them.
  3. A plugin may add work or caution, never remove it. It cannot bypass the security scan, remove a safety gate, force approval, or force sending. Gate and hook result types are structurally incapable of approval.
  4. Gate errors and timeouts fail closed. Missing, disabled, revoked, stale, timed-out, failed, or malformed gates route to human review.
  5. Plugin-produced text is untrusted — clamped at every boundary, and injection-scrubbed at every Convex-side boundary.
  6. Plugin LLM access goes through dispatch with plugin attribution and a hard daily budget; accounting uncertainty fails closed.
  7. Tenant and plugin storage are isolated, with the scope bound by the host and rechecked per operation.
  8. No arbitrary runtime JavaScript is loaded into Convex or the browser. Instant installation exists only for connected apps and sandboxed jobs.