Trust & Capabilities¶
The host security model for plugins answers two independent questions. Trust decides whether, and whose, code is allowed to load at all — by checking manifest signatures, signing-key roots, and content hash-pins. Capabilities decide what a plugin, once loaded, is permitted to do — the grant set that gates each extension surface and host import, plus the resource quotas that bound its execution.
These are deliberately separate axes. A plugin can be fully trusted (signed by a key in your root) and still be granted nothing but a single scalar-function surface; conversely, an unsigned plugin loaded under a permissive policy is still subject to the capability set you grant it. This page covers both, and is precise about what is enforced today versus deferred — see the Scope note.
For the broader model, see Concepts; for exact type signatures, see Reference; for the per-loader details, see Loaders.
Trust vs capabilities¶
| Concern | Question | Mechanism | Configured on |
|---|---|---|---|
| Trust | Whether/whom to load | Signature policy, trust root, artifact hash-pin | The host builder (plugin_trust) |
| Capabilities | What a loaded plugin may do | Grant set (effective = declared ∩ granted), quotas |
The load call (grants=[...]) |
Trust is a per-instance, host-level decision: one signature policy and one trust
root govern the whole Uni instance. Capabilities are a per-load decision: every
plugin you load is granted its own set, and two loads of the same artifact may be
granted differently.
Signature policy¶
SignaturePolicy (in uni-plugin, crates/uni-plugin/src/verify.rs) has three
levels, dialled up over time without changing call sites:
Disabled— the default. Signature checks are skipped entirely; signed and unsigned manifests both pass without inspection. This is the back-compatible v1 behavior.WarnIfUnsigned— runs the verifier. A manifest with a signature is verified against the trust root; a manifest without one is accepted but emits atracing::warn!. Use this to observe your plugin surface before enforcing.RequireSigned— runs the verifier and rejects anything that is not validly signed: an unsigned manifest, a signature whosekey_idis not in the trust root, or a bad signature all produce aPluginError.
The host sets the policy on the builder via plugin_trust. Because the trust
config is a builder-level runtime object (see below), this is Rust-side
configuration; loaders called from Python inherit whatever the instance was built
with.
use std::sync::Arc;
use uni_db::Uni;
use uni_db::api::plugin_trust::PluginTrustConfig;
use uni_plugin::verify::{SignaturePolicy, TrustRoot};
let mut root = TrustRoot::new();
root.allow_with_key("release-2026", release_pubkey_bytes); // [u8; 32]
let db = Uni::open("./db")
.plugin_trust(PluginTrustConfig::new(
SignaturePolicy::RequireSigned,
root,
))
.build()
.await?;
The default — equivalent to omitting plugin_trust entirely — is
SignaturePolicy::Disabled with an empty trust root, which accepts every
plugin. PluginTrustConfig::default() constructs exactly that.
Trust root & keys¶
A TrustRoot is the set of signing keys the host accepts. You build it
explicitly:
let mut root = TrustRoot::new();
root.allow_with_key("ops@example.com", pubkey_bytes); // 32-byte Ed25519 key
allow_with_key(key_id, public_key) binds a key id to its 32-byte Ed25519 public
key; the related allow(key_id) adds a key id without key material, which is
useful for shape-only verification and tests.
Cryptographic verification of a signed manifest (verify_signed_manifest /
verify_ed25519) is always compiled — there is no Cargo feature gating it
and no degraded shape-only fallback. ed25519-dalek is an unconditional
dependency of uni-plugin, whose only feature is otel. The verifier is also
fail-closed: a key_id present in the trust root but carrying no public-key
bytes (the allow() shape-only path) is rejected, not waved through.
Artifact hash-pinning¶
PluginTrustConfig::pinned_artifacts is a set of Blake3 hex digests. Left empty
(the default) pinning is off. When populated, every loader entry point that
receives payload bytes — WASM component, Extism, Rhai, PyO3 — rejects a payload
whose digest is not in the set, before it is instantiated and before any
capability is granted.
let mut trust = PluginTrustConfig::default();
trust.pinned_artifacts.insert(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
);
The pin is deliberately external to the artifact. A digest carried inside a
plugin's own manifest is self-certifying — anyone able to rewrite the payload can
rewrite the digest beside it — so an embedded hash field is only meaningful
when an Ed25519 signature covers the whole manifest (see
canonical_payload, which is exactly why the signature spans every field rather
than the hash alone).
Why it is not in UniConfig¶
TrustRoot holds raw public-key material and is intentionally neither Clone
nor Serialize. The serializable UniConfig is cloned into every session and
fork, so a non-Clone, non-Serialize object cannot live there. If you go
looking for a trust_root field in Configuration,
you will not find one — by design.
Instead the trust policy is a builder-level runtime object: you pass
PluginTrustConfig to UniBuilder::plugin_trust, it is stored on the instance's
internal state (UniInner), wrapped in an Arc so it can be shared across
at_snapshot / at_fork clones, and consulted at every plugin-load site. There
is intentionally no global, serializable plugin-config struct.
Grants & capabilities¶
A loaded plugin's authority is its effective capability set, which is the intersection of what its manifest declares and what the host grants:
A capability the plugin never declared cannot be granted into existence, and a
capability the host did not grant is stripped even if declared. On the load APIs,
the grants argument is a list of capability name strings. Common grants:
| Grant | Surface |
|---|---|
ScalarFn |
Register Cypher scalar functions |
AggregateFn |
Register Cypher aggregate functions |
Procedure |
Register Cypher procedures (read-only) |
Algorithm |
Register graph algorithms |
GraphCompute |
Drive the GraphCompute coarse kernels (guest-authored graph algorithms) |
Filesystem |
Filesystem read/write host import |
Network |
HTTP/TCP egress host import |
HostQuery |
Query back into the host session (also: project a graph for GraphCompute) |
Kms |
KMS sign/verify host import |
Secret |
Acquire named secret handles |
The full grantable set — 21 extension registration-gates plus 7 allow-list host
surfaces — is the single source of truth in uni_plugin::Capability; see the
reference for the complete tables. A
guest-authored graph algorithm needs the triple Algorithm + GraphCompute +
HostQuery.
What is not grantable from a string¶
Two classes of capability are deliberately unreachable through the grants list:
- Resource quotas (
MemoryBytes,FuelPerCall,GraphComputeWork, …) carry a numeric value and are declared in the plugin manifest'scapabilities:list, never as a bare grant string. - Internal / first-party capabilities (
Auth,Authz,Cdc,Catalog,PluginDeclare) are never granted to a guest; only host code constructs them.
Grant names are case- and separator-insensitive: HostQuery and host-query
resolve identically. The strict load paths (Rhai, and the source-string Python
plugin loader) raise on an unknown, quota, or internal name; the lenient
decorator / WASM paths drop it. Adding a new Capability variant is caught by
the every_variant_classified_exactly_once test in uni-plugin, which forces it
to be classified as grantable, quota, or internal before it can ship — the
mechanism that keeps this surface from drifting.
How withheld capabilities take effect¶
- Registration surfaces — a registrar method (e.g.
scalar_fn) is rejected withPluginError::CapabilityRequiredwhen the corresponding capability is not in the effective set. This applies to every loader. - Extism — the host-function set is filtered at load: only the host functions for granted capabilities are linked into the plugin.
- WASM Component Model — the loader computes the effective set and reports it
as
effective_capabilities/denied_capabilities. The capability-gatedhost-netimport (HTTP GET/POST) is added to the linker only whenNetworkis granted — a plugin importing it without the grant fails at link time. The always-availablehost-logandhost-trace-contextimports are linked unconditionally. (host-fsis not yet exposed on the Component Model; on Extism filesystem/query/KMS/secret host functions are wired — see below.)
from uni_db import Uni
db = Uni.open("./db")
with open("geo_component.wasm", "rb") as f:
outcome = db.load_wasm_component(f.read(), grants=["ScalarFn"])
# Extism plugins use the same grants list:
with open("geo_extism.wasm", "rb") as f:
outcome = db.load_wasm_extism(f.read(), grants=["ScalarFn"])
Passing grants=["ScalarFn"] grants exactly the scalar-function surface and
nothing else — no network, filesystem, or host-query host imports are linked.
The Python bindings currently expose the coarse capability variants; the
narrowed forms (specific path globs for Filesystem, URL globs for
Network) are configured Rust-side.
Quotas¶
Per-call resource limits are applied at load:
| Limit | Bounds |
|---|---|
FuelPerCall |
Maximum fuel / operations consumed per call |
MemoryBytes |
Maximum linear memory per instance |
The Rhai engine enforces these from the granted quota capabilities; the WASM
Component Model and Extism loaders take the equivalent fuel/memory limits from
their manifest fields. There is intentionally no global PluginConfig
struct — per-load grants and limits are the only knobs.
Scope note¶
Signature enforcement is wired today only on the add_plugin path
The signature policy and trust root described above are enforced today only
on the compile-time add_plugin path — the path the wiring tests exercise
(Disabled accepts unsigned, RequireSigned rejects unsigned,
WarnIfUnsigned accepts and warns).
The sandboxed loader manifest formats — ComponentManifest for WASM Component
Model and ExtismPluginManifest for Extism — do not yet carry signature
fields. Until they do, signature enforcement on WASM, Extism, Rhai, and
Python loads is deferred (Phase D), and sandboxed loads carry no
artifact-integrity check.
Capability grants, by contrast, take effect on the sandboxed load path today: registrar surfaces reject ungranted registrations, and the Extism loader links only the host functions for granted capabilities (see Grants & capabilities).