5.0 KiB
Capability Matrix
Every host function exposed to a plugin sits behind a capability. The
manifest must declare a capability before the runtime binds the
corresponding host functions; calling an undeclared host-fn throws
CapabilityDeniedError and audits as outcome=denied.
Closed enum
The runtime maintains a closed enum of capability names in
packages/plugin-runtime/src/capabilities.ts. Anything outside this
list is rejected at manifest parse time.
| Capability | Risk | Host binding availability | Unlocks / intended binding | Scope shape |
|---|---|---|---|---|
log |
none | available | ctx.log.{info,warn,error} - structured logging into host pino |
none |
audit |
none | available | ctx.audit.record({ action, target?, meta? }) |
none |
tenant.info |
low | reserved / fail-closed | Reserved tenant metadata binding; not bound today | none |
secret.read |
medium | reserved / fail-closed | Reserved per-tenant secret reads; not bound today | { keyPattern: "^slack-" } planned |
storage.read |
medium | reserved / fail-closed | Reserved tenant-scoped key/value reads; not bound today | per-prefix locking planned |
storage.write |
medium | reserved / fail-closed | Reserved tenant-scoped key/value writes; not bound today | per-prefix locking planned |
http.outbound |
high | reserved / fail-closed | Reserved outbound fetch binding; not bound today | { allowedHosts: ["hooks.slack.com"] } |
db.read |
high | reserved / fail-closed | Reserved DB binding for marketplace work; not bound today | { tablePrefix: "tx_" } planned |
db.write |
critical | reserved / fail-closed | Reserved DB binding for marketplace work; not bound today | { tablePrefix: "tx_" } planned |
PLUGIN_CAPABILITY_NAMES is intentionally broader than the production host
binding set. As of this foundation, PLUGIN_AVAILABLE_HOST_CAPABILITY_NAMES
contains only log and audit; declaring higher-risk capabilities parses, but
production install/grant/enable paths reject them until their bindings, tests,
and security review ship.
Risk legend
- none — no tenant data, no outbound — safe for any plugin.
- low — tenant identity only; no payload exposure.
- medium — touches per-tenant data inside the host; operator review recommended.
- high — issues network requests OR reads tenant business data; audit-trail required and operator must explicitly approve at install.
- critical — mutates tenant business data; install requires out-of-band operator sign-off; auto-disable on the slightest anomaly.
Common pitfalls
- Declaring
http.outboundwithoutallowedHostsis the most common authoring mistake. The runtime denies every request unless the host appears in the allowlist. The lint command warns on this shape. - Assuming
db.readis bound today — the DB capability is reserved-only in the foundation. A plugin that declares it parses fine but the host returnsno_host_implementationon call. - Using
auditas a payload queue — see the async-task example; the audit body has a cap. Pass identifiers, not blobs.
How to request a new capability
- Propose the name (lowercase, dot-separated).
- Write a one-paragraph threat model: what data can leak, what privileges are gained, what reverse-direction trust does the capability imply?
- Add to
PLUGIN_CAPABILITY_NAMESincapabilities.ts. - Add the host-fn implementation in
capability-gateway.tsvia the providedhostFnsmap; never bypass the gateway. - Update this table.
- Operator-side: external security review when the new capability ships in production (Phase 06 #23 sign-off — see audit-docs.md).
Audit trail mapping
Every successful or failed call to a host-fn produces one row in
PluginCapabilityUse:
| Column | Meaning |
|---|---|
capability |
Capability name (e.g. http.outbound) |
outcome |
success / denied / error |
payloadHash |
SHA-256 of stable call descriptor; never the body |
durationMs |
Time spent in the host-fn (excluding gateway frame) |
errorCode |
Short code on denied / error (e.g. not_declared_in_manifest) |
Tenant admins see this list under
/app/:tenant/settings/plugins/:id/audit.