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.
Consequences worth knowing:
- Put the component’s SHA-256 in the root
wasm_sha256field before signing. The signature then binds that digest to the manifest. The host verifies it over the exact bytes it admits and compiles; strict mode rejects executable manifests without it. The registrysha256below independently protects the distribution archive 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. sign_manifest_document
does both steps and returns the signed manifest with the two root fields
already in place. There is no zeroclaw plugin sign command; publishers call
these functions from their release pipeline. The crate ships a ready-made
helper, crates/zeroclaw-plugins/examples/sign_manifest.rs. Run it from a
ZeroClaw checkout with paths into your plugin’s directory, and keep the private
key outside both trees. Use a new, separate package directory and a new archive
path for each release;
the helper creates it when it writes the signed manifest:
cargo run -p zeroclaw-plugins --example sign_manifest -- keygen ~/.keys/my-plugin.pk8
cargo run -p zeroclaw-plugins --example sign_manifest -- \
sign path/to/my-plugin/manifest.toml path/to/my-plugin-package/manifest.toml \
--key ~/.keys/my-plugin.pk8 --payload path/to/my-plugin/my-plugin.wasm
cp path/to/my-plugin/my-plugin.wasm path/to/my-plugin-package/my-plugin.wasm
(cd path/to/my-plugin-package && zip -r ../my-plugin.zip .)
This recipe assumes wasm_path = "my-plugin.wasm". --payload hashes the
component but does not copy it. Copy those exact bytes, and any other required
assets, to the paths the manifest declares relative to the package directory
before making the zip. Archive only that directory, containing the signed
manifest.toml; do not include the unsigned source manifest or edit the signed
manifest after signing.
keygen writes a new private key, creating its directory when needed
(readable by the owner only on Unix), refuses to overwrite an existing file,
and prints the hex public key. sign records the component’s SHA-256 in
wasm_sha256 when --payload is given, signs, checks the signature with the
host’s verifier, writes the signed manifest, and prints the same public key.
For a manifest that declares wasm_path, it warns when no wasm_sha256 is
recorded, because strict mode refuses such a package. A manifest without
wasm_path, such as a skill bundle, takes no --payload: the host rejects a
digest there.
On Windows the key file inherits its directory’s ACL. Keep the private key in a directory that only the publisher can read.
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.
If you embed the fields by hand after signing, place them directly after
another root field. The host’s TOML editor attaches a blank line or comment to
the entry or table header below it. If the fields land directly under a blank line or comment
that was already in the manifest you signed, verification removes that line
together with the fields, the remaining bytes no longer match what you signed,
and strict mode rejects the package. A blank line or comment you add directly
above the fields is removed together with them and is harmless. Anything you
add below them attaches to the next entry or table header and changes the
signed bytes, so add nothing there. sign_manifest_document and the helper place the fields before
they sign, so the question does not arise.
name = "my-plugin"
version = "0.1.0"
wasm_path = "my-plugin.wasm"
wasm_sha256 = "<64-hex-character-component-sha256>"
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
release archives for the 64-bit desktop and server targets carry it, and
every release executes a plugin from each of them before it publishes; the
32-bit ARM archives, the experimental Android archive, and default source
builds do not carry it (the
per-artifact table
lists every artifact). Fetching an index through either
command caches it locally. zeroclaw plugin list then combines installed
packages with the cache without making a network request; it keeps installed
and registry versions separate rather than guessing whether an arbitrary
version string is newer. 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 digest is optional only for
zeroclaw plugin install, which verifies it when it is present: the Plugins
step of zeroclaw quickstart refuses an entry without one, so publish a digest
for every entry.
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 admission and install path a local install uses: the
manifest is signature-checked and validated, a name that is already installed
is refused, the component is read once (confined to the package, no symlinks,
capped at 64 MiB), and the install-time load check instantiates those admitted
bytes against this host’s
WIT world before anything is installed. A component that does not load fails
the install with the instantiation diagnostic; --no-verify installs it
anyway, skipping the check, and the daemon will skip the plugin at startup
instead. 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.
zeroclaw plugin update selects the same way and compares version strings only
for equality: an operator on the version the rule selects is up to date and
downloads nothing. Publish every changed package under a new version, because
an entry republished under the installed version never reaches an update.
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 assembled package directory (one signed manifest, its referenced component and required assets, 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.