Plugin Security
Plugins run in a WebAssembly sandbox with no ambient authority. Two mechanisms keep them contained: structural capabilities (a plugin can reach only what its world imports) and per-call metering (every call is bounded in instructions, wall-clock time, and memory).
Structural Capabilities
A plugin’s WIT world names every capability it can use. There is no way to reach a capability the world does not import – it is simply absent from the instance.
Three capabilities are always granted and carry no host authority: configuration, logging, and runtime context. Three are deny-by-default and present only when a world opts in:
- Filesystem – a world without a filesystem import gets no preopens and cannot touch any host path.
- Outbound HTTP – a world without the outbound-HTTP import cannot make network calls.
- Host services – a world without the host-services import cannot send
mail or touch the data store, and the manifest must additionally opt in per
service (
host_services = ["mail", "data"]).
This is stronger than a policy toggle: capability removal is enforced by the component instance, not by a runtime check the plugin might bypass. See The Plugin Contract for the worlds.
Metering
Every plugin call is metered three ways, all on by default:
| Limit | Mechanism | What it bounds |
|---|---|---|
| Fuel | instruction counter | runaway computation / infinite loops |
| Epoch | wall-clock deadline | a call that hangs (wired to the page-render budget) |
| Memory | per-plugin cap | a plugin growing its heap without bound |
When a call exceeds any limit it traps; its output is discarded and the server continues with the original content. The fuel budget is tunable:
plugins: enabled: true fuel_limit: 1000000 # instructions per call
To calibrate, run with debug logging (RUST_LOG=accent=debug accent serve) –
each call logs its fuel consumption.
Compilation performance
Compiling a component to native code is a one-time cost. Cache it across runs, and optionally compile at startup instead of on first use:
plugins: compilation_cache: .cache/plugins precompile: true
API Version Compatibility
Each plugin declares the host api_version it targets in its plugin.toml. At
load time the host checks that version against the supported range it
advertises: a minimum supported version and the newest version it implements.
Advertising a range, rather than a single pinned version, lets a plugin built
against an older-but-still-supported API keep loading – the previous API
generation keeps working until support for it is explicitly retired.
A plugin is accepted when its api_version falls within the supported range,
using the semver 0.x convention (below 1.0 the minor is the breaking unit; at and
above 1.0 the major is). A plugin outside the range is skipped with an actionable
warning:
- Newer than the host implements – upgrade Accent to a build that provides the required API.
- Older than the supported floor – rebuild the plugin against a still-supported API.
- Not valid semver – fix the
api_versionstring (for example,"0.1.0").
Filesystem Access
By default a plugin gets no filesystem access. A plugin that needs to read files (EXIF extraction, content indexing) imports the standard WASI filesystem in its world and the host confines it to operator-mapped preopens. This requires both sides to agree:
- The plugin declares
requires_fs = truein itsplugin.toml(and importswasi:filesystemin its world). - The site operator maps which host paths are available in
config.yaml.
Site operator side
Define allowed_paths to map virtual paths (as seen inside the component) to
host filesystem paths:
plugins: enabled: true allowed_paths: /content: "ro:./content" /media: "ro:./media" /data: ./data
- Keys are virtual paths visible inside the plugin (e.g.
/content). - Values are host paths (e.g.
./content). - Prefix a host path with
ro:for read-only access (recommended for content and media). - Paths without
ro:grant read-write access.
A plugin that declares requires_fs but is granted no paths runs without
filesystem access (a warning is logged).
Publisher-role guard
When the server runs in the publisher role, plugins that declare
requires_fs are loaded with no preopens regardless of allowed_paths –
they can still run pure-computation hooks but cannot read or write any host path.
This prevents a published content/ tree from drifting from git through a plugin
side channel. The guard is re-applied on every hot reload.
Host Services
Plugins reach the host’s own services – SMTP mail send and the per-plugin
data store – through the typed host-services capability, not over HTTP. The
world imports host-services and the manifest names the services it needs:
[plugin]
name = "accent-contact"
host_services = ["mail"] # and/or "data"
The grant is per service and deny-by-default: send-mail without the
"mail" scope (or data-read/data-write without "data") fails with a
typed not-permitted error, and a world that does not import host-services
cannot make the call at all. Two properties matter for security:
- The caller is known. The host builds each plugin’s instance, so it knows which plugin is calling. The data-store namespace is the plugin’s name – derived by the host, never taken from the payload – so one plugin cannot read or overwrite another plugin’s data.
- No network is involved. A mail-sending plugin needs no outbound-HTTP
capability and no allowlist entry at all, and host services work during
accent buildand CLI commands as well as underaccent serve.
The server warns at plugin load when a manifest allowlists a host that could
reach loopback – localhost, a loopback IP literal, or the bare * wildcard.
That is the signature of a plugin written against the removed HTTP-to-self
endpoints; such a plugin still loads, but every outbound call it makes is
refused by the address guard below, so its form submissions are dropped. The
warning is a heuristic on the manifest, not proof: a plugin that reaches
loopback by some other route gets no warning and fails the same way. See the
SMTP and data store pages for
usage and migration.
Outbound HTTP
A plugin that needs the network imports the outbound-HTTP capability and declares the hosts it may reach in its manifest:
[plugin]
name = "llm-tagging"
allowed_hosts = ["api.anthropic.com", "*.openai.com"]
The allowlist is deny-by-default: a request to a host not on the list fails.
Entries match exactly, as a bare * (any host), or as a *.suffix wildcard. A
plugin whose world does not import outbound HTTP cannot make network calls at
all, whatever its manifest says.
Internal-address protection
The allowlist checks the host name; on top of it the runtime guards against server-side request forgery at the address level, so an allowlisted name cannot be used to reach the host’s own private network:
- The request host is resolved once (after IDNA-normalising any
internationalised name), and the request is refused if it resolves to a
non-public address – loopback (
127.0.0.0/8,::1), private RFC1918 ranges (10/8,172.16/12,192.168/16), shared/CGNAT space (100.64.0.0/10), link-local (169.254.0.0/16, including the169.254.169.254cloud metadata endpoint, andfe80::/10), IPv6 unique-local (fc00::/7), or the unspecified address. Both IPv4-in-IPv6 encodings – IPv4-mapped (::ffff:a.b.c.d) and the deprecated IPv4-compatible (::a.b.c.d) – are rejected too. - The connection is pinned to the address validated above, so a DNS rebind cannot swap a public answer for an internal one between the check and the connect.
- Redirects are not followed. A
3xxresponse is returned to the plugin verbatim; to follow it the plugin must issue a fresh request, which re-runs both the allowlist and the address checks.
A refused address surfaces to the plugin as a transport error, the same as any other connection failure.
There are no exemptions. Loopback is blocked like every other non-public range, whatever the URL path and whether or not a server is running. A plugin that needs to reach a service on its own host uses the host-services capability instead – it is a direct call into the host, so it needs no allowlist entry, no port, and no hole in this guard.