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. Two 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.
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.
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.