Skip to content

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 a tracing::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 whose key_id is not in the trust root, or a bad signature all produce a PluginError.

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:

effective = declared ∩ granted

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 with PluginError::CapabilityRequired when 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-gated host-net import (HTTP GET/POST) is added to the linker only when Network is granted — a plugin importing it without the grant fails at link time. The always-available host-log and host-trace-context imports are linked unconditionally. (host-fs is 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).