How Plugins Work
This page explains the plugin system from an operator’s point of view: how a plugin is discovered, what it is allowed to do, and how the host keeps an untrusted plugin contained. For the on-disk contract a plugin author implements (manifest fields, bridge exports, host functions), see Plugin protocol.
The shape of the system
A plugin is a sandboxed WebAssembly module plus a manifest. The host loads it, reads the capabilities and permissions it declares, and exposes its tools to the agent only when the operator has turned the plugin system on. Nothing about a plugin is implicit: a plugin gets exactly the capabilities its manifest declares and the operator’s policy allows, and nothing else. To build one yourself, start with the plugin guides.
Three properties hold at every layer:
- Disabled by default. The plugin system does not load anything unless
[plugins] enabled = true. A default build with no plugin configuration runs no plugin code. - Deny by default. A plugin reaches a host capability (HTTP egress, config, memory) only by declaring the matching permission in its manifest. An undeclared capability is unreachable, not merely unused.
- Verified by policy. Whether an unsigned or untrusted plugin loads at all is the operator’s decision, set once in config and enforced uniformly at discovery.
Lifecycle of a plugin load
When the runtime builds its tool set, the plugin loader runs through these stages in order. A plugin that fails an earlier stage never reaches a later one.
- Gate. If
[plugins] enabledis false, the loader does nothing. This is the first and cheapest check. - Discover. The loader scans the resolved plugins directory
(
[plugins] plugins_dir, default~/.zeroclaw/plugins/) for subdirectories containing amanifest.toml. It skips dot-prefixed directories, including the hidden staging directoryzeroclaw plugin installbuilds a package in before renaming it into place, so a half-written install is never loaded. - Validate shape. Each manifest must declare at least one capability, and
a non-skill plugin must name a confined relative
wasm_path. Traversal and symlink paths are rejected. A malformed manifest is skipped with a warning, never loaded. - Enforce signature policy. Each plugin is checked against the configured
[plugins.security] signature_modeandtrusted_publisher_keys. A plugin that fails the policy is dropped from the loaded set, not surfaced as a tool. - Admit executable bytes. The host opens the confined component once,
verifies any declared
wasm_sha256, and retains those exact bytes. Instrictmode the signed manifest must declare this digest. Adapters compile the admitted buffer rather than reopening its path. On Unix the host opens the package directory once and reaches the component from that handle one path component at a time, never following a symlink, so moving or replacing a directory during admission either fails it or leaves the read inside the package that was confined. The component must be a regular file, and a FIFO or device fails at once instead of blocking. The package directory and the manifest are still found by pathname when admission starts; instrictmode the signedwasm_sha256is what binds the manifest to the bytes. Other platforms keep pathname checks, which refuse a replacement still in place when they run but do not make the lookup atomic or keep a FIFO from blocking the open. - Register tools. Surviving tool plugins are wrapped as agent tools and
appended after the built-ins. A plugin whose package name or tool name
conflicts with an already registered tool is refused with a warning instead
of being registered; see Tool name conflicts.
Tool and skill plugins are auto-discovered, so this
enumeration happens only when
[plugins] auto_discover = true(defaultfalse, fail-closed): withenabled = truebutauto_discover = false, no plugin tools or skills load, though channels you declare under[channels.plugin.<alias>], by hand or withzeroclaw plugin bind, still activate. The skill loader applies the sameauto_discovergate.
The signature stage is the one most easily misconfigured, so it is worth understanding on its own.
Recovering legacy installations
Run zeroclaw plugin migrate to relocate legacy package directories into
[plugins] plugins_dir. Automatic recovery searches the configured data root’s
plugins/, the unsplit <install>/workspace/plugins, and the actual V2
producer’s <install>/data/plugins destination even if the data field differs.
New V2 filesystem splits move plugins to host data, so fresh and resumed splits
stay reachable without using
the default agent’s live writable workspace as a recovery source. Host data is
not necessarily the configured runtime plugins directory; plugin migrate
performs that later relocation. The configured target and canonical aliases of
it are excluded, and duplicate roots are reported once.
Supported aliases of these legacy roots are followed. Canonical target aliases are excluded and aliases of the same root are deduplicated. A root overlapping a known agent workspace is excluded whether it lies inside that workspace or contains it, including aliases. Known roles come from each configured agent’s actual workspace path, the implicit default, and the historical default workspace. Existing paths are compared canonically; absent declared workspaces are conservatively compared as normalized lexical paths, resolving an existing ancestor alias when available. Selection does not create a missing workspace or treat the declared path as evidence of old migration.
This is root selection: the existing mover still rescans an eligible root with its own package checks. Both the recovery hint and mover require an ordinary child package directory, matching runtime discovery. Child directory symlinks stay at the source; they are neither moved nor materialized, even in a root with ordinary packages. A manifest alias inside an ordinary package directory still uses the existing manifest-loading and admission checks.
This does not create a global no-follow guarantee, an authenticated snapshot, or a new concurrent no-clobber contract. Its existing same-name destination check leaves colliding packages at the source for operator resolution and retry.
The directory-migration helper does not edit plugin configuration, grants,
publisher trust or enablement. Config loading before the command retains its
normal initialization and migration effects. Moving packages makes them
eligible for normal discovery: tool and skill plugins may become available
when plugins are enabled and auto_discover = true, subject to admission.
Relocation itself neither verifies ABI compatibility nor authenticates origin.
Packages already split into an agent workspace
Earlier versions could move packages to
<install>/agents/default/workspace/plugins. That historical location and a
custom live agent workspace are no longer automatic sources for
plugin migrate. A manifest, matching backup name, or caller-written marker
does not authorize directory-wide adoption. Inspect each package you intend to
adopt, then select its individual directory with the existing install command:
zeroclaw --config-dir '/operator/install' plugin install \
'/operator/install/agents/default/workspace/plugins/package-name'
Use your actual install and package paths; do not pass the entire plugins/
directory. A package in a backup can likewise be selected explicitly by its
actual path. The operator’s install action supplies the authority, not the
path’s apparent history. This requires a build with plugin CLI support.
This is a fresh installation of a compatible package. The host applies the current manifest, signature, payload and config-schema rules, and the CLI load-checks executable packages against its WIT ABI and limits by default. Unsigned/untrusted packages under strict policy, incomplete old manifests, incompatible components, or unsupported legacy config rows may be refused; follow the diagnostic and typed-config migration guidance before retrying. No historical-format conversion or signing-policy bypass is provided. Existing same-name installed packages are refused rather than silently replaced.
Explicit install retains the source directory. It writes the admitted manifest and executable payload and copies declared skill content; arbitrary auxiliary files are not a full-directory recovery contract, and skill copying is not an immutable generation snapshot. For tool bindings needing host-owned state, install can create missing typed config rows and seed declared HTTP egress destinations into new rows. Existing rows and grants are not automatically extended, and old package-name rows are not silently converted. Review these installation effects and configure required values before enabling runtime use. Explicit adoption is therefore separate from the automatic migration helper’s unchanged-config/grant behavior.
Signature policy
Every plugin manifest may carry an Ed25519 signature and the hex-encoded public
key of the publisher who signed it. The operator decides how strictly that
signature is enforced through [plugins.security] signature_mode:
| Mode | What loads | Use when |
|---|---|---|
disabled | Every well-formed plugin, signed or not | Local development against plugins you built yourself |
permissive | Every well-formed plugin; unsigned, untrusted, and invalid signatures load with a warning | Migrating toward signing without breaking existing installs |
strict | Only plugins with a valid signature from a trusted publisher load | Any shared or production host |
In strict mode the manifest’s publisher_key must appear in
[plugins.security] trusted_publisher_keys, and the signature must verify
against the canonical manifest bytes. Executable plugins must also declare a
signed wasm_sha256 matching the exact admitted bytes. A plugin that fails any
of these checks is dropped at discovery and never becomes a tool. The default
is disabled so a fresh local checkout works without key management, but a
host that loads plugins from anywhere you do not control should run strict.
This policy is enforced uniformly: the same check that the host applies when you
list plugins is the check the agent runtime applies when it builds the tool set,
so a plugin you cannot see in strict mode is also a plugin the agent cannot
call.
Tool name conflicts
Registration refuses a name conflict instead of letting one tool shadow another. Tool plugins register in package-name order, and the host checks each one twice:
- Package name. A plugin whose package name (the manifest
name) matches an already registered tool is refused before its component is instantiated. The host logs aWARNevent witherror_keyplugin_package_name_conflictin itsattributes. - Tool name. The guest declares its own tool name, so the host learns it
only by instantiating the component to read its metadata. A plugin whose
tool name matches an already registered tool is not registered. The host
logs a
WARNevent witherror_keyplugin_tool_name_conflictin itsattributes. A tool name that contains__is refused even when nothing holds it yet, because MCP tools are named<server>__<tool>and register after plugin tools. That refusal carrieserror_keyplugin_tool_name_reserved.
The names checked are the tools that registry build has already registered,
including plugin tools accepted earlier in the same pass, plus
execute_pipeline when [pipeline] enabled = true. This is not a fixed list
of every built-in name: a built-in that a build does not register, for example
because its config section is disabled, is not reserved in that build. Apart
from the MCP namespace, tools that join the registry after plugin registration
are outside this check. Give plugin packages and tools names that are unique
outright rather than relying on it.
Capabilities and permissions
A manifest declares two separate things, and the distinction matters.
- Capabilities are what kind of extension the plugin is:
tool,channel,memory,observer, orskill. Atoolplugin contributes tools the LLM can call. - Permissions are what host services the plugin’s code may reach at runtime: HTTP egress, configuration, memory. A permission the manifest does not declare is a host function the plugin cannot reach.
The host grants permissions narrowly: a permission the manifest does not
declare is a host function the plugin cannot reach. Config is resolved from a
host-issued instance identity, so a plugin cannot select another package or
binding and never reads the raw process environment. http_client gates the
outbound wasi:http surface; the shared SSRF-guarded egress policy remains
companion plugin-hardening work. This page covers the signature-policy
boundary.
Configuration reference
All settings live under the plugins.* config paths and are set through any
config surface (zerocode, the gateway, or the CLI):
# Master switch. Nothing loads while this is false.
zeroclaw config set plugins.enabled true
# Load auto-discovered tool and skill plugins at runtime (default: false).
# Without this, `enabled = true` activates only explicitly-declared channels.
zeroclaw config set plugins.auto_discover true
# Where plugins are discovered (default: ~/.zeroclaw/plugins).
zeroclaw config set plugins.plugins_dir ~/.zeroclaw/plugins
# disabled | permissive | strict
zeroclaw config set plugins.security.signature_mode strict
# Hex-encoded Ed25519 public keys allowed to publish plugins under strict mode.
zeroclaw config set plugins.security.trusted_publisher_keys '["a1b2c3d4e5f6..."]'
A host meant to load third-party plugins should set enabled = true,
signature_mode = "strict", and list only the publisher keys you trust. To load
auto-discovered tool and skill plugins as well, also set auto_discover = true;
it is false by default, so enabled = true alone activates only the channels
you declare under [channels.plugin.<alias>] and no plugin tools or skills. A
host that runs only plugins you build yourself can leave signature_mode at its
disabled default during development and tighten it before the host is shared.
A channel binding lives outside plugins.*, under [channels.plugin.<alias>].
You can write that table by hand, or let zeroclaw plugin bind <package> --channel-alias <alias> write it for an installed channel package. The command
also seeds the instance’s [[plugins.entries]] row, requires an explicit
--egress declared or --egress none flag before it creates that row from a
manifest that declares destinations and holds a transport that can reach them
(http_client, websocket_client, or socket_client), and reports what the
instance still needs before it can start. It never sets plugins.enabled and
never edits an agent’s channels. See
Binding a channel instance.
Running plugins from a release binary
The plugin system is experimental: the wit/v0 contract is unstable, so a
plugin must be built against the WIT shipped with the release that runs it, and
a mismatch surfaces at the install-time load check rather than in the manifest.
Within that limit, the release archives for the 64-bit desktop and server
targets carry the plugin host and the compiler that loads .wasm components,
and every release executes a plugin from each of those archives before it
publishes. The 32-bit ARM
archives, the experimental Android archive, and source builds with Cargo
defaults carry no plugin host; the
protocol page
lists every artifact. Running a plugin on a release binary takes four operator
decisions, each of them off by default:
- Install the package.
zeroclaw plugin install <directory>for a local package, orzeroclaw plugin install <name>for one from the plugin registry. Install validates the manifest, runs the signature policy, checks the payload digest when the manifest declares one, and instantiates the component against this host’s WIT (skipped with--no-verify); a package that fails any of these is not installed. Install (unless--no-verify),zeroclaw plugin info <name>, andzeroclaw plugin list --verifycompile and instantiate the component whether or not the plugin system is enabled. Instantiation calls none of its exports, but it does run any start function the component’s core modules carry, in a store with no files, no network, and the per-call fuel, memory, and time limits. - Turn the plugin system on.
zeroclaw config set plugins.enabled true, thenzeroclaw config set plugins.auto_discover trueso tool and skill plugins are enumerated. An installed tool or skill package contributes nothing until both are set. Once they are, every build of the tool registry compiles and instantiates each admitted tool component and asks it for its name, description, and parameter schema; that is guest code running under the package’s declared permissions and egress policy, before any approval, and the tool is then offered to the model. A channel package needsplugins.enabled, a[channels.plugin.<alias>]declaration, and an enabled agent that listsplugin.<alias>among its channels;auto_discoverplays no part in it. For a package that mirrors a native channel throughprovides, activation under[channels.<provides>.<alias>]is recorded but not constructed yet; the same package bound under[channels.plugin.<alias>]runs. - Approve each tool call. A plugin tool call needs operator approval like
any other tool that is not pre-approved: the CLI prompts on the terminal,
channels send their approval card. To let an agent call a plugin tool
unattended, add the tool to that agent’s risk profile
auto_approvelist, for examplezeroclaw config set risk_profiles.default.auto_approve '["<tool-name>"]'.config setreplaces the whole list: the built-in defaults are merged back intorisk_profiles.defaultwhen the config loads, but any entry you added yourself must be repeated, and a profile with another name keeps only what you pass.level = "full"and the*wildcard pre-approve every tool that is not inalways_ask, and an Always answer pre-approves the tool for the rest of that CLI session or channel turn, except for tools inalways_ask, which prompt every time. Without an approver the call is denied and does not run. - Choose a signature policy.
signature_modestaysdisabledby default so a plugin you built yourself installs without key management. Understrict, only packages signed by a key intrusted_publisher_keysload, and because the official registry publishes unsigned packages today, none of them install instrictmode until their publishers sign them.
Compiling a component needs memory that becomes executable after it is
written. A host that forbids that, such as a systemd unit with
MemoryDenyWriteExecute=yes (the Nix module’s default), refuses every plugin
load because the compiler cannot map code as executable; the rest of the
daemon needs no such mappings and keeps running.
To roll back, remove the package with zeroclaw plugin remove <name>, or set
plugins.enabled = false to stop loading any plugin without uninstalling. To
return to a release without the plugin host, pin the binary with
zeroclaw update --version <previous> --force; without --force the updater
refuses to move to an older version. The updater resolves Linux musl hosts to
the glibc archive, so a musl install, like one that came from a package
manager, rolls back by reinstalling the previous archive or package.
What a plugin still cannot do
Even with every permission granted, the sandbox bounds a plugin:
- It runs as a WebAssembly module with no ambient access to the host process or the filesystem outside its rooted workspace. Network egress is gated by the HTTP permission; the SSRF-guarded egress boundary itself is delivered by the companion plugin-hardening work.
- A trusted tool or channel plugin can read a schema-designated secret’s
plaintext through its scoped
secrets.getimport during an authorized service call. Tools receive access duringexecute. Channels receiveconfig.getandsecrets.getduringconfigureand operational calls; reads within one call use one canonical revision, so a same-binding public/secret rotation is available on the next operation. Instantiation and static metadata discovery cannot use either import. The host prevents public config injection and cross-instance selection, but a plaintext-returning import cannot prevent a malicious guest from retaining what it reads. Compliant channel plugins must resolve config and credentials at each point of use. - It cannot take the name of an already registered tool. Registration refuses the conflicting plugin instead of registering its tool, within the bounds described in Tool name conflicts.
The sandbox and namespace bounds hold regardless of what plugin code attempts. The no-retention rule is instead part of the trusted channel-plugin contract, which is why publisher review and signature policy still matter.