Distributing Plugins
You built a plugin; now it needs to leave your machine without asking the
people who install it to trust you blindly. ZeroClaw’s distribution story has
two independent layers: Ed25519 manifest signatures (who published this) and
registry install (how it gets there). This page covers both, checked against
crates/zeroclaw-plugins/src/signature.rs, src/plugin_registry.rs, and the
install path in host.rs.
Signing
What is signed
The signature covers the canonical manifest bytes. The host parses the TOML,
removes only the exact root signature and publisher_key entries, preserves
the rest of the document, and strips trailing empty lines
(canonical_manifest_bytes in signature.rs). This makes the signature
self-embeddable: sign the manifest without those root fields, then add them;
verification removes them before checking.
Two consequences worth knowing:
- The
.wasmcomponent itself is not covered by the signature. What the signature attests is the manifest: the name, version, capabilities, and permissions a publisher stands behind. Pair it with a registrysha256digest (below) when the artifact integrity matters in transit. - Nested fields with names such as
config_schema.properties.signature, and similarly prefixed root fields such assignature_algorithm, remain signed. Reformatting or reordering retained content invalidates the signature. Sign last, after the manifest is final. - Config exposure markers such as
config_schema.properties.api_token.x-secretare therefore signature-covered policy. Changing a tool or channel property between public config exposure and scopedsecrets.getaccess requires rebuilding and re-signing. - Packages signed by the former prefix-based canonicalizer need re-signing only if they relied on one of those edge cases or on TOML decoration attached to a removed field. Ordinary manifests keep the same signed content.
Keys and process
Signing uses Ed25519 via the same ring primitives the host verifies with.
The signature is base64url (no padding); the public key is hex-encoded. The
crate exposes the full toolchain (signature.rs): generate_signing_key
produces a PKCS#8 keypair and its hex public key, sign_manifest produces
the base64url signature over the canonical bytes, and public_key_hex
recovers the public key from a stored private key. There is no CLI wrapper
for signing today; publishers drive these functions from a short Rust helper
in their release pipeline.
The signed manifest then carries two extra root fields: signature (the
base64url value) and publisher_key (your hex public key). Put both before the
first table header, including [config_schema]; appending them after a table
header makes them members of that table under TOML rules and the host will see
an unsigned manifest.
name = "my-plugin"
version = "0.1.0"
signature = "<base64url-signature>"
publisher_key = "<hex-public-key>"
[config_schema]
type = "object"
properties = {}
additionalProperties = false
Operators who want to trust you add that hex key to their
plugins.security.trusted_publisher_keys list:
zeroclaw config set plugins.security.signature_mode strict
zeroclaw config set plugins.security.trusted_publisher_keys '["<your-key-hex>"]'
How verification behaves
Verification runs at both discovery and install (enforce_signature_policy
called from host.rs); discovery skips a failing plugin and logs, install
returns the error. The mode matrix, from the operator’s side:
| Mode | Unsigned | Signed, key not trusted | Signed, signature invalid | Signed and trusted |
|---|---|---|---|---|
disabled | loads | loads, not checked | loads, not checked | loads, not checked |
permissive | loads with warning | loads with warning | loads with warning | loads, verified |
strict | rejected | rejected | rejected | loads |
Note what strict means for you as a publisher: an operator in strict mode
loads your plugin only if your exact key is in their trusted set and the
manifest bytes verify. Any post-signing manifest edit, by you or by anyone in
the distribution path, bricks the install. That is the point.
Registry publication
The install path is the local plugin directory; a registry is only a JSON
index consulted at command time (zeroclaw plugin search / install).
Both commands exist only in binaries with the plugin host compiled in (see
build features); the
prebuilt release binaries ship without it. The
default index is the zeroclaw-labs/zeroclaw-plugins repository’s
registry.json; private registries are a URL away
(--registry <url> per command, or the ZEROCLAW_PLUGIN_REGISTRY_URL
environment variable, resolved in that order per registry_url in
src/plugin_registry.rs).
A registry entry (PluginRegistryEntry in
crates/zeroclaw-plugins/src/registry.rs) carries: name, version,
optional description and author, capabilities, the archive url, and
an optional sha256 digest of the zip.
The archive contract
zeroclaw plugin install <name> resolves the entry, downloads the zip,
verifies the digest when present, safely extracts, and hands the extracted
directory to the same PluginHost::install path a local install uses. The
extraction is defensive by construction (src/plugin_registry.rs), and your
archive must survive it:
- The zip must contain either a root-level
manifest.tomlor exactly one nested plugin directory containing one. Zero manifests or more than one is a rejected archive. - Entry names with path traversal, absolute paths, or Windows drive prefixes are rejected.
- Download is capped while streaming (50 MiB) so a server withholding
Content-Lengthcannot force unbounded buffering; extraction is capped at the same bound so a zip bomb cannot expand without limit.
Version resolution: when the installer gets a bare name, it picks the last
matching entry in the index; a pinned name@version selects exactly that
version. Order repeated names in your registry intentionally, oldest first.
Search is not a trust boundary
zeroclaw plugin search is unauthenticated discovery over the index; it
never installs, enables, or executes anything. Install is where the security
happens: digest check, safe extraction, manifest validation, and the
operator’s signature policy, identical to a local-path install. Publish
accordingly: assume everything before install is untrusted transport.
The publisher’s checklist
Important
Compiled
.wasmand.cwasmfiles are binary artifacts, often megabytes each. Do not check them into a git source tree without Git LFS: every rebuild committed as a plain blob bloats the repository history permanently, andgit diff/review tooling chokes on them. Treat them like any other build output: addtarget/and*.wasm/*.cwasmto.gitignore, and distribute through a release artifact or plugin registry archive instead. If an artifact truly must live in the tree, track the pattern with LFS (git lfs track "*.wasm") before the first commit.
- Finalize the manifest: name, version, capabilities, and the narrowest permission set the code uses.
- Build the component; for skill bundles, validate frontmatter on every
SKILL.md(discovery enforcesnameanddescription). - Sign: generate or load your Ed25519 key, sign the canonical manifest
bytes, embed
signatureandpublisher_key. - Zip the plugin directory (one manifest, no path tricks, under 50 MiB).
- Compute the zip’s SHA-256 and publish the registry entry with the digest.
- Publish your public key hex somewhere operators can verify independently
of the registry (your repository, your site). The key, not the registry,
is what
strictmode operators trust. - On every release: bump
version, re-sign (the version line is inside the canonical bytes), re-digest, append the new entry after the old one.