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:

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.

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.