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:

LimitMechanismWhat it bounds
Fuelinstruction counterrunaway computation / infinite loops
Epochwall-clock deadlinea call that hangs (wired to the page-render budget)
Memoryper-plugin capa 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_version string (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:

  1. The plugin declares requires_fs = true in its plugin.toml (and imports wasi:filesystem in its world).
  2. 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 build and CLI commands as well as under accent 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 the 169.254.169.254 cloud metadata endpoint, and fe80::/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 3xx response 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.