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, 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) happens under the
default-on ed25519 Cargo feature; with that feature disabled, the verifier
falls back to checking signature shape and trust-root membership only.
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. The recognized
names are:
| Grant | Surface |
|---|---|
ScalarFn |
Register Cypher scalar functions |
AggregateFn |
Register Cypher aggregate functions |
Procedure |
Register Cypher procedures (read-only) |
Filesystem |
Filesystem read/write host import |
Network |
HTTP/TCP egress host import |
HostQuery |
Query back into the host session |
Kms |
KMS sign/verify host import |
Secret |
Acquire named secret handles |
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).