Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_sha256 field 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 registry sha256 below independently protects the distribution archive in transit.
  • Nested fields with names such as config_schema.properties.signature, and similarly prefixed root fields such as signature_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-secret are therefore signature-covered policy. Changing a tool or channel property between public config exposure and scoped secrets.get access 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:

ModeUnsignedSigned, key not trustedSigned, signature invalidSigned and trusted
disabledloadsloads, not checkedloads, not checkedloads, not checked
permissiveloads with warningloads with warningloads with warningloads, verified
strictrejectedrejectedrejectedloads

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.toml or 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-Length cannot 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 .wasm and .cwasm files 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, and git diff/review tooling chokes on them. Treat them like any other build output: add target/ and *.wasm/*.cwasm to .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.

  1. Finalize the manifest: name, version, capabilities, and the narrowest permission set the code uses.
  2. Build the component; for skill bundles, validate frontmatter on every SKILL.md (discovery enforces name and description).
  3. Sign: generate or load your Ed25519 key, sign the canonical manifest bytes, embed signature and publisher_key.
  4. Zip the assembled package directory (one signed manifest, its referenced component and required assets, no path tricks, under 50 MiB).
  5. Compute the zip’s SHA-256 and publish the registry entry with the digest.
  6. Publish your public key hex somewhere operators can verify independently of the registry (your repository, your site). The key, not the registry, is what strict mode operators trust.
  7. 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.