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

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.

  1. Gate. If [plugins] enabled is false, the loader does nothing. This is the first and cheapest check.
  2. Discover. The loader scans the resolved plugins directory ([plugins] plugins_dir, default ~/.zeroclaw/plugins/) for subdirectories containing a manifest.toml. It skips dot-prefixed directories, including the hidden staging directory zeroclaw plugin install builds a package in before renaming it into place, so a half-written install is never loaded.
  3. 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.
  4. Enforce signature policy. Each plugin is checked against the configured [plugins.security] signature_mode and trusted_publisher_keys. A plugin that fails the policy is dropped from the loaded set, not surfaced as a tool.
  5. Admit executable bytes. The host opens the confined component once, verifies any declared wasm_sha256, and retains those exact bytes. In strict mode 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; in strict mode the signed wasm_sha256 is 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.
  6. 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 (default false, fail-closed): with enabled = true but auto_discover = false, no plugin tools or skills load, though channels you declare under [channels.plugin.<alias>], by hand or with zeroclaw plugin bind, still activate. The skill loader applies the same auto_discover gate.

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:

ModeWhat loadsUse when
disabledEvery well-formed plugin, signed or notLocal development against plugins you built yourself
permissiveEvery well-formed plugin; unsigned, untrusted, and invalid signatures load with a warningMigrating toward signing without breaking existing installs
strictOnly plugins with a valid signature from a trusted publisher loadAny 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:

  1. 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 a WARN event with error_key plugin_package_name_conflict in its attributes.
  2. 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 WARN event with error_key plugin_tool_name_conflict in its attributes. 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 carries error_key plugin_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, or skill. A tool plugin 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:

  1. Install the package. zeroclaw plugin install <directory> for a local package, or zeroclaw 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>, and zeroclaw plugin list --verify compile 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.
  2. Turn the plugin system on. zeroclaw config set plugins.enabled true, then zeroclaw config set plugins.auto_discover true so 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 needs plugins.enabled, a [channels.plugin.<alias>] declaration, and an enabled agent that lists plugin.<alias> among its channels; auto_discover plays no part in it. For a package that mirrors a native channel through provides, activation under [channels.<provides>.<alias>] is recorded but not constructed yet; the same package bound under [channels.plugin.<alias>] runs.
  3. 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_approve list, for example zeroclaw config set risk_profiles.default.auto_approve '["<tool-name>"]'. config set replaces the whole list: the built-in defaults are merged back into risk_profiles.default when 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 in always_ask, and an Always answer pre-approves the tool for the rest of that CLI session or channel turn, except for tools in always_ask, which prompt every time. Without an approver the call is denied and does not run.
  4. Choose a signature policy. signature_mode stays disabled by default so a plugin you built yourself installs without key management. Under strict, only packages signed by a key in trusted_publisher_keys load, and because the official registry publishes unsigned packages today, none of them install in strict mode 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.get import during an authorized service call. Tools receive access during execute. Channels receive config.get and secrets.get during configure and 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.