Experimental. Behind the non-default plugin-sandbox feature on autumn-web. The native Plugin trait is unchanged and fully supported; nothing here affects an app that does not opt in.

A native Autumn plugin is full-trust: its build(self, app) receives the whole AppBuilder, and from there it can run migrations, read your credentials, open sockets, and crash your process. That is the right trade for a plugin you wrote or a first-party crate you already trust. It is the wrong trade for a plugin you found on crates.io ten minutes ago.

A sandboxed plugin is the other trade. It ships as a portable artifact — a wasm32-wasip1 module plus a manifest — and the runtime enforces the manifest:

  • it serves HTTP under one declared prefix, and only the routes it declares;
  • it has no filesystem, no network, no environment, no database;
  • it runs under hard per-request CPU and memory ceilings;
  • a trap, an exit, a runaway loop or a memory bomb is a 5xx on its own prefix and nothing else.

Everything above is a property of the runtime, not a promise by the author.


The trust model, in one table

Row labelNative PluginSandboxed plugin
What build receivesthe whole AppBuildernothing — the framework mounts it from the manifest
Filesystem, network, envfull process authoritynone, and each attempt is logged
Sessions, auth, filesystemfull accessnone exist as grantable capabilities
Database, cache, outbound HTTP, jobsfull accessonly with an explicit grant, and only within it: plugin-owned tenant-scoped tables, a per-(plugin, tenant) key namespace, declared hostnames, declared job types
Routes it can mountanywhereexactly its declared (method, path) list, under its prefix
A panictakes the process down502 on its own prefix
An infinite loophangs a worker504 on its own prefix, after its fuel budget
LanguageRust, compiled into your binaryanything that targets wasm32-wasip1
Speeda function callan interpreter, plus one instance per request
Review surfacethe crate's source and its whole dependency treeone page of TOML and a digest

Native plugins remain the full-trust path, and first-party plugins stay on it. Sandboxing is for code you have not audited and do not intend to.


Authoring a plugin

The whole ABI is one JSON object in and one JSON object out, over the guest's stdio. There is no SDK to depend on and no bindgen step: a guest reads one line from stdin, writes one line to stdout, and exits.

Text
host  → guest  {"op":"request","wire_version":1,"granted":["http-request"],
                "method":"GET","route":"/hello/{name}","path":"/hello/ada","query":"",
                "path_params":[["name","ada"]],"headers":[["accept","text/html"]],"body_b64":""}
guest → host   {"op":"response","status":200,
                "headers":[["content-type","text/plain"]],"body_b64":"aGkgYWRh"}

A guest that cannot answer says so, and the host turns it into a 502:

Text
guest → host   {"op":"error","detail":"no handler for that route"}

Bodies are base64 so a binary response survives a line-oriented protocol. Request headers arrive lower-cased and sorted, with credentials already stripped (see What never crosses).

In Rust, a complete guest needs serde, serde_json and base64 — and nothing from Autumn:

Rust
use std::io::{BufRead as _, Write as _};

use base64::Engine as _;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct Request {
    method: String,
    route: String,
    path_params: Vec<(String, String)>,
}

#[derive(Serialize)]
struct Response<'a> {
    op: &'a str,
    status: u16,
    headers: Vec<(&'a str, &'a str)>,
    body_b64: String,
}

fn main() {
    let mut line = String::new();
    if std::io::stdin().lock().read_line(&mut line).is_err() {
        return;
    }
    let request: Request = match serde_json::from_str(&line) {
        Ok(request) => request,
        Err(_) => return,
    };

    let name = request
        .path_params
        .iter()
        .find(|(key, _)| key == "name")
        .map_or("world", |(_, value)| value.as_str());
    let status = if request.method == "GET" && request.route == "/hello/{name}" {
        200
    } else {
        404
    };

    let response = Response {
        op: "response",
        status,
        headers: vec![("content-type", "text/plain; charset=utf-8")],
        body_b64: base64::engine::general_purpose::STANDARD.encode(format!("hi {name}\n")),
    };
    let mut stdout = std::io::stdout().lock();
    let _ = writeln!(stdout, "{}", serde_json::to_string(&response).unwrap_or_default());
    let _ = stdout.flush();
}

Build it as a command (it must export _start):

Shell
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1

What a guest will find missing

The sandbox implements the WASI preview-1 surface, but almost all of it says no. std::fs, std::net, std::env::var and std::env::args all compile and all fail. Two calls answer rather than refuse, and both are deliberately not ambient:

  • SystemTime::now() returns a fixed instant. The host's clock is not a capability a plugin was granted.
  • random_get returns a deterministic stream, seeded from the request. The same request replays byte-for-byte, so you can reproduce a bug from the request alone — but it is not entropy, and nothing security-relevant may be derived from it. (You hold no capability that would make a secret useful, which is the point.)

Both make a plugin a function of its request, which is what lets an author reproduce a bug from the request alone.


Packaging

The manifest is the whole review surface. Write it next to your crate:

TOML
name = "autumn-plugin-hello"
version = "0.1.0"
wire_version = 1
prefix = "/hello"
capabilities = ["http-request"]
# Stamped by `autumn plugin package` — write anything 64-hex-characters long here.
sha256 = "0000000000000000000000000000000000000000000000000000000000000000"

[[routes]]
method = "GET"
path = "/hello/{name}"

[limits]
fuel = 200_000_000          # CPU ceiling for one request, in wasm fuel units
memory_bytes = 33_554_432   # linear-memory ceiling for one request's instance
max_request_body_bytes = 1_048_576
max_response_bytes = 4_194_304
max_concurrency = 8         # requests this plugin may execute at once
request_body_timeout_ms = 5_000  # how long the host waits for a body

Then bind the two together:

Shell
autumn plugin package \
  --manifest plugin.toml \
  --module target/wasm32-wasip1/release/hello.wasm \
  --out hello.autumn-plugin

package computes the module's SHA-256 and stamps it into the manifest — you never type a digest — and it loads the module into the same sandbox the runtime uses before writing anything. A plugin that could not run is refused at your desk rather than at an operator's boot.

The manifest fails closed

Every one of these is a hard error, not a warning:

WrittenWhy it is refused
a key this build does not knowyour reading of the manifest and the runtime's would differ
a capability name this build does not knowan older host must never silently drop a newer grant
wire_version other than 1a host and an artifact from different versions must not guess
a route outside prefixthe prefix is a containment boundary
prefix = "/", /{tenant}, /a//b, /a/../ba boundary that matches dynamically is not one
no [[routes]]a sandboxed plugin serves exactly what it declares
a zero or oversized limita zero ceiling means "cannot run", not "no limit"
memory_bytes × max_concurrency over 1 GiBthat product, not its factors, is what the plugin can cost the host
a route path the router would refuse — :id, *rest, {id, {*rest}/moreaxum::Router::route panics on one, so a manifest that passed would take the app down at boot
a route on a path the app already serves, or on the configured OpenAPI, Swagger UI or MCP pathRouter::nest would panic. The build returns a RouterBuildError that names the path.
a module importing an allowlisted WASI name with the wrong signature, or exporting a _start that is not () -> ()it would load and then fail on every request, as a gateway error nobody can explain from outside
a module with more than 4096 data + element segments, or more than 16 MiB of themevery request re-instantiates the module, and that copying happens before the first guest instruction — so it is bounded at load rather than discovered per request
a module that exports no linear memory named memory, or whose initial memory is already over the manifest's ceilingevery host function reads and writes through that export, so without it the plugin loads and then fails every request
two routes that are one route to the router (/{a} and /{b})same
a route path with a second spelling — /a/./b, /a/%2e/b, /a%2Fb, /%74ransfer, lower-case hex like %c3%a9a client or proxy normalises it to another path, so the router's collision check would not see that it names an app route
a literal non-ASCII route path — /caféa client sends /caf%C3%A9, so the literal route is unreachable while the consent screen lists it. Write /caf%C3%A9.
a version or route path carrying a control characterboth are printed on the consent screen, where an escape sequence can rewrite what you read
a digest that is not 64 lowercase hex charactersit is the only thing binding manifest to bytes

These rules are enforced when a manifest is parsed and again when a host is built from one, because SandboxManifest's fields are public: a manifest that was constructed or edited in Rust rather than parsed gets the same answer as one read off disk.


Installing

Review it first. autumn plugin inspect is a consent screen, and it exits non-zero if the artifact is not fit to install:

Shell
autumn plugin inspect hello.autumn-plugin
Text
Sandboxed plugin: autumn-plugin-hello 0.1.0
  module sha256: 9f2c…
  mounts prefix: /hello
  routes it serves (and only these):
    GET /hello/{name}
  capabilities granted:
    http-request — handle HTTP requests routed to this plugin's own prefix (no other authority)
  denied, with no way to ask for it in this version:
    filesystem access, outbound network access, environment variables,
    database access, and any host authority not listed above
  resource ceilings per request:
    cpu 200000000 fuel units, memory 33554432 bytes, request body 1048576 bytes,
    response 4194304 bytes, at most 8 concurrent requests
  artifact sha256 (record this one): 4b7e…
  host functions it imports:
    wasi_snapshot_preview1::fd_read
    wasi_snapshot_preview1::fd_write
    …

✓ loads into this build's sandbox

Plugin conformance: autumn-plugin-hello — PASS
…

--format json gives the same verdict machine-readably, for a CI gate or a review diff. The conformance section runs the same route-attribution, route-prefix, route-collision, sensitive-surface and duplicate-registration checks autumn plugin-check runs against a native plugin — offline, because a sandboxed plugin's manifest is its route table.

Then mount it like any other plugin:

Rust
use std::path::Path;

use autumn_web::plugin_sandbox::SandboxedPlugin;

#[autumn_web::main]
async fn main() {
    let hello = SandboxedPlugin::from_file(Path::new("plugins/hello.autumn-plugin"))
        .expect("the sandboxed plugin loads");

    autumn_web::app()
        .routes(routes![index])
        .plugin(hello)
        .run()
        .await;
}

from_file verifies the module digest before the module is compiled, so a file whose manifest and module have come apart is refused with a message naming the mismatch.

Two digests appear on the review screen, and they answer different questions. The module sha256 is the one the manifest declares and the loader verifies: it answers "are these the author's bytes". It is not the one to write down. What you are reviewing is the grant as much as the code — the prefix, the routes, the capabilities, the ceilings — and every one of those lives in the manifest, not the module. Rewrite them and the module digest is still correct, because the module really did not change, so an artifact reviewed under a narrow grant can be deployed under a wide one and still match.

The artifact sha256 covers the whole container, manifest included, and it is the one inspect labels record this one. Reviewing an artifact means recording that number and comparing it against what your deployment loads. The mount log carries it too, as artifact_sha256, so "what did we agree to run" can be checked against what was approved rather than merely read.

Either digest is a binding, not a signature: anyone who can rewrite the file can recompute both. What the artifact digest buys is that the number you wrote down covers everything you agreed to. At mount time the resolved grant is written to the log at info, so "what did we agree to run" is answerable from a production log alone.

The app still deploys as a single binary. wasmi is a pure-Rust interpreter — no daemon, no subprocess, no native codegen backend, no extra artifact.


What the runtime enforces

The manifest is the mount

The router is built from the manifest's [[routes]], one axum route per declared pair. A request to an undeclared path under the prefix is a 404 the guest never sees; a request outside the prefix never reaches the plugin's router at all.

One implication is worth knowing before you write a guest: a declared GET also serves HEAD. HTTP defines HEAD as GET without a body, and axum's method router dispatches a HEAD with no HEAD route to the GET one — so the alternative to naming it is not "HEAD is refused", it is "HEAD is served by an accident the manifest never mentions". autumn plugin inspect and autumn routes both list the implied HEAD, and your guest will see "method":"HEAD"; answer it as you would the GET and the host discards the body for you.

Resource bounds

CeilingWhat it boundsWhat happens at the edge
fuelinstructions executed for one request, and the host-side bytes copied on the guest's behalf — the module's data and element segments, which every request re-instantiates, and the request frame itself, which is cloned and base64-expanded into the guest's stdin — at 64 bytes per unit. Each byte the guest writes to stdout also costs one unit, because the host parses it504 on the plugin's prefix
memory_bytesone instance's linear memorythe guest's memory.grow fails; usually a trap, then 502
max_request_body_bytesthe body forwarded in413, guest never started
max_response_bytesthe frame accepted back. It also limits each frame the guest writes. The host refuses at load a manifest when a call frame is larger than 2 × max_response_bytes + 4096. The frame is outbound_request_bytes × 6, kv_value_bytes, or a full row (db, jobs), plus 4096502 for an oversized frame; 504 for one that never ends
frame shapethe host counts the containers and commas of each guest frame before it parses the frame. It refuses a frame with more than 65,536, or nesting deeper than 32 levels. No legal frame comes near either502
max_concurrencyinstances alive at once503 with Retry-After; requests are shed, not queued
shared guest poolguests that run at once in the whole process, all plugins together. Default 128. Set it with plugin_sandbox::set_guest_slots before you build any plugin, to at most a quarter of max_blocking_threads503 with Retry-After. It can refuse a request that max_concurrency allows. The first request to arrive gets the slot, so keep each max_concurrency well under the limit
request_body_timeout_mshow long a request may take to send its body408, and the permit goes back

The number worth reviewing is max_concurrency × the per-request footprint, where the footprint is the guest's linear memory plus every host buffer a request pins outside it, counted at their simultaneous peak:

TermWhat holds it
memory_bytesthe guest instance's linear memory
4 × max_request_body_bytesthe body is buffered, cloned into the frame, and base64-expanded into the NDJSON line that becomes the guest's stdin — all live at once
table storagethe instance's tables, bounded to 16,384 references
5 × max_response_bytesthe response side peaks while the answer is parsed, not after: the raw NDJSON line the guest wrote is still live (up to 2 ×), the base64 field may be copied out of it, and the decoded body is allocated while both are held

A manifest with a tiny memory_bytes and 64 MiB body/response ceilings would pass a memory-only check and still allocate hundreds of gigabytes, so the validator checks the whole footprint against a 1 GiB ceiling.

The permit is held from the moment a request is admitted, before its body is read — that is what makes the footprint a bound on the whole request rather than on the part a guest is running. The cost of that choice is that a client dribbling a body could otherwise hold a permit without ever starting a guest, so the read has its own deadline. Unlike the interpreter, an async body read is genuinely cancellable, so that is a real bound rather than a hopeful one.

Both of the host-side costs a request pays before the guest runs — instantiating the module and encoding the request frame — are charged against fuel before they are performed, so a manifest that declares a large body ceiling and almost no budget is refused rather than served for free. A price is not a ceiling, though, and the structurally expensive cases have one of their own at load: see the segment limits above.

Each request gets a fresh instance, so no state survives a request and one request's misbehaviour cannot reach the next.

Fault isolation

The interpreter runs on a blocking worker, so a spinning guest never stalls the async runtime, and every failure — trap, proc_exit, fuel exhaustion, refused allocation, malformed frame, no answer at all — comes back as a value. Nothing a plugin does can abort, exit, or panic the host process.

What never crosses

Only an allowlist of request headers reaches the guest: accept, accept-charset, accept-encoding, accept-language, cache-control, content-length, content-type, if-match, if-modified-since, if-none-match, if-range, if-unmodified-since, range, user-agent. Everything else is dropped.

A denylist of credential headers is a losing game. The RFCs name Cookie and Authorization, but every authenticating proxy invents its own — Cf-Access-Jwt-Assertion, X-Forwarded-User, X-Amzn-Oidc-Data, X-Ms-Client-Principal, X-Goog-Iap-Jwt-Assertion — and Autumn's own htmx integration attaches X-CSRF-Token to every same-origin request. Each of those is a bearer credential the sandbox promised would not cross, and there is no version of that list that is finished. What a plugin actually needs is content negotiation and conditional-request metadata, which is short and does not grow.

On the way back, an allowlist — because a deny-list is a blocklist against a header registry that keeps growing. A plugin may set content-type, content-language, content-disposition, cache-control, etag, expires, last-modified, location, retry-after, vary, age, accept-ranges and content-range — and nothing else. There is deliberately no x- escape hatch: X-Accel-Redirect (nginx) and X-Sendfile (Apache) make your reverse proxy serve an internal URI or a local file, so a hatch there would hand a filesystem-free plugin the filesystem one hop upstream. Everything outside the list is stripped and logged as a denial — set-cookie (a plugin that could set a cookie could forge a session in your origin), strict-transport-security and clear-site-data (origin-wide and persistent), the framing headers your HTTP stack owns, and the host's own x-autumn-sandboxed / x-content-type-options, which a plugin must not be able to forge. A header name or value carrying \r\n is refused outright — that would be response splitting.

The content type is part of the same boundary, and it is the one most worth understanding. A response is served from your origin, so an application/javascript body under a default script-src 'self' is script execution in your origin, and a text/html body is a document in it. Neither is a capability this slice grants and neither can be made safe by a header your own security middleware is entitled to overwrite. So the first slice serves data, not documents:

AllowedRefused
text/plain, text/csvtext/html, application/xhtml+xml
application/jsonapplication/javascript, text/css
application/octet-streamimage/svg+xml (a document that can script)
image/png, image/jpeg, image/gif, image/webp, image/avifeverything else

Anything else is a 502 with the type named in the log. Widening this is a later slice's job, and the honest way to do it is to serve a plugin's documents from an origin of their own.

Every response — including the 413, the 503 and the 502 the guest never ran for — carries x-autumn-sandboxed: <plugin-name> and x-content-type-options: nosniff, and a response with no content-type gets application/octet-stream rather than being left for a browser to guess at.

Denials are observable

Every refused reach is recorded and logged at warn with the capability class and the operation:

Text
WARN sandboxed plugin was denied a capability it reached for
     plugin="autumn-plugin-hello" capability="filesystem" operation="path_open"
     detail="a sandboxed plugin has no filesystem"

Denials are deduplicated per request, so a guest calling path_open in a loop produces one line, not a flood. Capability denials are deduplicated the same way, by (operation, outcome) — and every one of them is counted in the activity log even when the line is not repeated. A Rust guest's std touches environ_sizes_get during start-up, so an environment denial per request is normal for one and means exactly what it says: it asked, and it did not get.

The log is a surface the plugin can write to, so it is bounded like every other one. Anything a guest influenced — its own error detail, its stderr, the interpreter's account of a trap — is truncated to 512 characters and has its control characters escaped before it is logged. A plugin that fails on every request cannot fill a disk with one, and a detail containing a newline or an ANSI escape reads as text that tried to forge a record rather than as one.


The capability vocabulary

A plugin that can only answer HTTP is a demo. Real ones need to read data, call an API, queue work and put something on a page — so capabilities names five more words, and [grants] says what each is scoped to:

TOML
capabilities = ["http-request", "kv", "http-outbound", "db", "jobs", "render"]

[grants]
hosts     = ["api.example.com"]   # http-outbound may call exactly these
tables    = ["orders"]            # db owns exactly these, tenant-scoped
job_types = ["reindex"]           # jobs may enqueue exactly these
slots     = ["order-summary"]     # render may fill exactly these

[quotas]
kv_reads       = 64               # per request; every quota is operator-set
outbound_calls = 4
CapabilityWhat it grantsWhat it can never reach
kva key/value namespace private to (this plugin, this tenant)another plugin's or tenant's keys
http-outboundthe hostnames in [grants].hosts, through the framework clientany other host; redirects are not followed for it
dbthe plugin-owned tables in [grants].tables, scoped to the active tenanthost-application tables, another tenant's rows, raw SQL
jobsenqueueing the job types in [grants].job_typesany other type; the record carries this plugin and tenant, so a runner cannot widen it
renderthe host-declared slots in [grants].slotsscript, style, event handlers, off-origin links

The grant table and the capability list must agree in both directions. A hosts list without http-outbound is refused, because the operator read "no outbound network" in one place and api.example.com three lines below it. A db grant naming no table is refused too, because it is authority the consent screen shows and the runtime can never honour.

How a plugin asks

Over the channel it already has. A guest writes a call frame to stdout and reads the answer from stdin — the same NDJSON dialogue it answers requests on:

Text
guest → host  {"op":"call","call":"kv-get","id":1,"key":"cart"}
host  → guest {"op":"call_result","status":"ok","id":1,
               "value":{"kind":"value","value":"one item","found":true}}
guest → host  {"op":"call","call":"http-fetch","id":2,"method":"GET",
               "url":"https://api.example.com/v1/orders"}
host  → guest {"op":"call_result","status":"denied","id":2,
               "reason":"quota-exceeded","detail":"…"}
guest → host  {"op":"response","status":200,…}

What an outbound call may carry

Methods: GET, HEAD, POST, PUT, PATCH, DELETE. Request headers are an allow-list — accept, accept-language, content-type, idempotency-key, if-match, if-none-match, user-agent — and so are the response headers that come back: content-type, etag, last-modified, location, retry-after. Host, Cookie, Authorization and the Forwarded/X-Forwarded-* family are absent on purpose: the first re-points the request past the allow-list at the connection layer, the next two would carry credentials the sandbox never gave the plugin, and the last let it forge the provenance of a call the host is making on its behalf.

Redirects are never followed for a plugin. A 3xx comes back as a 3xx. A granted host that answers 302 Location: https://attacker.test/ would otherwise walk the request straight out of the allow-list, so the host also re-checks where the bytes actually came from before the guest sees any of them.

Capabilities are data, not code. A plugin granted db imports exactly what a plugin granted nothing imports — fd_read and fd_write. Growing the vocabulary never widened the module's import surface, which is why the consent screen's import list still means what it meant.

Framing: call is the one non-terminal frame — every other frame ends the exchange, and this one suspends it. The answer arrives as one NDJSON line appended to the guest's stdin; the id is the guest's to choose and is echoed back. Read each answer before making the next call: a guest that writes calls and never reads them fills a bounded queue and the request is ended.

A write keeps fuel to read its answer. A call that changes state — kv-set, kv-delete, db-insert, db-update, db-delete, job-enqueue, or an outbound call that is not GET or HEAD — runs only when the fuel left can also read its largest reply and the replies not yet read, at 128 units per byte. If it cannot, the call comes back quota-exceeded and nothing changes. This stops a known failure: the write occurs, the guest uses all its fuel to read the reply, the request fails, and a retry does the write again.

With the default fuel (200 million) and outbound_response_bytes, an outbound write needs about 74 million units. A plugin with less fuel left cannot make one. A smaller outbound_response_bytes needs less: 4096 needs about 10 million. A KV, DB or job write needs about 0.6 million. After a write has run, every later call keeps this fuel too. Before that, a read does not keep it, because a read that fails changes nothing.

A call_result carries one of six value kinds: done (a write or delete), value (from kv-get, with a found flag so a stored null is not a miss), row-id (from db-insert), rows (from db-get and db-query), http, and job-id. A db-query with limit: 0 — or no limit — returns as many rows as the db_rows quota allows, and as many as fit 512 KiB: rows are the one result whose size the guest chooses, so the byte budget travels into the store rather than being applied once the whole answer has been built. When it stops an answer short, rows comes back with "truncated": true.

Continue with after: rows arrive in ascending row_id order, so the last row_id of one page is the after of the next.

Json
{"op":"call","call":"db-query","id":1,"table":"orders","filter":{"kind":"open"}}
→ {"op":"call_result","id":1,"rows":[…,{"row_id":"r40",…}],"truncated":true}

{"op":"call","call":"db-query","id":2,"table":"orders","filter":{"kind":"open"},
 "after":"r40"}

Read truncated, or a plugin paging through its own table will take a short page for the end of the table. A PluginStore you implement yourself reports the same flag in QueryPage, must return rows in ascending row_id order for after to mean anything, and is expected to stop reading at max_bytes rather than gather and let the host discard. A filter may not carry row_id — use db-get for a single row, because stripping it would turn "the row with this id" into "every row this tenant has". A single row is bounded at 256 KiB across its columns when it is written, so a row that was stored can always be read back.

A refusal comes back as a call_result the guest can read, not as a trap. A plugin that hits a ceiling should degrade — render the panel without the live number — and its author needs to see which rule refused: capability-not-granted, not-in-grant, quota-exceeded, malformed, unavailable, backend-error, response-too-large or result-rejected. The last two mean the call ran. Do not retry it.

Why cross-tenant access is unspellable

The guest names a logical thing; the host derives the physical one:

The guest saysThe host uses
key: "cart"plugin-kv:<plugin>:<tenant>:cart, every segment escaped
table: "orders"plugin_<escaped plugin>__orders, filtered by the active tenant
job_type: "reindex"a PluginJob stamped with this plugin, this tenant and this artifact's sha256

The plugin half is hex-escaped rather than tidied, so autumn-plugin-shop owning orders is plugin_autumn_2dplugin_2dshop__orders — which looks odd in a schema and is the point: folding punctuation would let a plugin named shop_orders owning v2 land on the table shop owning orders_v2 already has.

There is no field in the protocol where a tenant, another plugin, or a physical table name would go. Cross-tenant access is not denied — it cannot be written down. That is also why a row may not carry a tenant_id column: a row that could set it would be a row that chooses its own tenant. A row_id column is accepted and ignored — the id is the row's address and travels in its own field — so the row you just read from db-get writes straight back through db-update without stripping anything.

Render hooks are trees, not HTML

A granted plugin fills a slot by returning a fragment tree, which the host renders:

Text
guest → {"op":"fragment","nodes":[
           {"node":"element","tag":"p","attributes":[["class","panel"]],
            "children":[{"node":"text","text":"3 orders"}]}]}
host  → <p class="panel">3 orders</p>

Not "the guest sends HTML and the host sanitises it". Sanitising is a filter in front of a parser, and the notable sanitiser bypasses of the last decade have been parser differentials — the filter's HTML parser and the browser's disagreeing about one input. There is no parser here, so there is nothing to disagree with: the tag list, the attribute list and the escaping are a function this framework writes. Nothing rendered needs unsafe-inline, so a host page with a nonce-based CSP keeps it.

A hook that traps, runs out of fuel, answers with a tag the renderer will not emit, or overruns render_bytes produces no fragment and no error — the host omits it and serves the page. A plugin's failure is never the page's.

Both halves have to agree on a slot. The manifest names the slots the plugin will fill, which is what an operator approves; the application declares the slots that exist, which is what stops a plugin appearing somewhere the app never offered:

Rust
use std::sync::Arc;
use autumn_web::plugin_sandbox::RenderSlots;

// `SandboxedPlugin` is `Clone`, and a clone shares the host, the permits and
// the activity log — one plugin with one ceiling, registered twice.
let slots = RenderSlots::declaring(["order-summary"]).with(Arc::new(plugin.clone()))?;

// ...in the order-page handler, where `id: String`:
let extra = slots.render("order-summary", &[("order".to_owned(), id)]).await;

with refuses at boot when a manifest names a slot this app does not declare, rather than leaving an operator to wonder later why nothing appears. render returns a String and never a Result — every failure contributes nothing and is logged.

Every capability carries a per-request ceiling, plus a calls budget that bounds their sum and a calls_per_second ceiling shared across a plugin's requests. Exceeding one denies that call and records it; it does not fail the request, and it does not touch another plugin or any host route.

An upgrade is the moment a plugin's authority can grow without anyone looking, so review it as an upgrade:

Shell
autumn plugin inspect shop-0.2.autumn-plugin --against shop-0.1.autumn-plugin

It prints exactly what the new manifest asks for that the approved one did not — new capabilities, hosts, tables, job types, slots, raised quotas — and exits non-zero when there is anything, so an unattended install stops rather than consenting on your behalf. Asking for less is not a prompt.

For "what did this plugin do in the last hour", every capability call — allowed, denied or over quota — is recorded at the point it happens, into a bounded per-request ledger. A plugin writes its own quotas, so the ceiling is reachable by one that wants to reach it: when it is, the summary says so in a line above the counts, and every number below it is a floor rather than a total.

Rust
let summary = plugin.activity().summary("shop", Duration::from_secs(3600));
println!("{summary}");   // `Display`: the summary already knows its plugin and window
Text
sandboxed plugin `shop` — last 3600s
  performed:
    db-insert × 12
    http-fetch × 3
  denied:
    job-enqueue × 1
  hosts called:
    api.example.com × 3
  targets refused:
    drain-accounts × 1

Records carry the shape — the key, the table, the host, the job type — and never a value. An audit surface that logged what a plugin stored would be a second copy of the tenant data this whole subsystem exists to contain.

Wiring the backends

Capabilities are honoured against backends you supply, so a host that wires none still runs a granted plugin — its calls are answered unavailable and recorded, which is a refusal like any other rather than a silent success:

Rust
use std::sync::Arc;
use autumn_web::plugin_sandbox::{CapabilityServices, KvStore, MemoryKvStore, SandboxedPlugin};

// The `as Arc<dyn KvStore>` is load-bearing: unsizing does not pass through
// `Option`, so `Some(MemoryKvStore::new())` will not coerce on its own.
let plugin = SandboxedPlugin::from_file(path)?.with_services(CapabilityServices {
    kv: Some(MemoryKvStore::new() as Arc<dyn KvStore>),
    ..CapabilityServices::none()
});

The tenant is not one of them: it is resolved per request from the tenancy middleware, because one compiled plugin serves every tenant.


Mount and unmount at runtime

Give the app a PluginMounts and keep a clone:

Rust
let mounts = PluginMounts::new();
let app = autumn_web::app().plugin_mounts(mounts.clone());
// Later, with the app running:
mounts.mount(&SandboxedPlugin::from_file(path)?)?;
mounts.unmount("/hello");

Rules:

  • The app does not swap its router. The 404 fallback serves mounted plugins. A request that matches a static route never reads the table.
  • A static route wins over a mounted prefix. Use a prefix that no static route uses.
  • A framework prefix is refused with MountError::Reserved. /static and /_autumn always count. plugin_mounts adds the configured actuator prefix, the probe paths (if enabled) and the dev inspector path. A prefix that contains a reserved one is refused too. Add more, such as a custom MCP path, with PluginMounts::reserve.
  • One plugin holds a prefix. A prefix that contains, or is inside, a held prefix is refused with MountError::PrefixTaken.
  • Unmount drains. New requests get 404 immediately. A running request finishes. It keeps its concurrency permit until it ends.
  • A new plugin instance at a reused prefix has its own permits. While the old instance drains, both can run requests.
  • Each mount logs the capability grant and a new generation.
  • PluginMounts::list shows what is mounted now. PluginMounts::routes shows the routes of those plugins.
  • The access log and metrics name a plugin route from its response. A request that middleware rejects first, such as a bad CSRF token or a rate limit, is logged as _unmatched.
  • autumn routes and autumn plugin-check read the build. They do not show runtime mounts.

Limits of this slice

  • Sessions, mail and file storage are not "not granted" — they do not exist as grantable capabilities, so no manifest can ask for them.
  • A plugin's DB and KV backends are whatever the host wires. The framework ships in-process reference implementations that enforce the scoping exactly as a scoped statement would; a durable Postgres-backed store is the operator's to supply through PluginStore.
  • Outbound calls do not follow redirects on a plugin's behalf, and the allow-list is a name list: IP literals and single-label names are refused, so localhost and 127.0.0.1 cannot appear in [grants].hosts and a plugin cannot be pointed at a local upstream in development. IP-range (SSRF) guarding for the app-level client is #1627's.
  • The jobs capability enqueues. Nothing in this slice runs the job. The record carries the plugin, the tenant and the artifact sha256. A runner must refuse a job when its sha256 is not the installed artifact's. Drain the queue before an upgrade. No wire frame sends a job back to a guest. A later slice adds one.
  • The shipped PluginStore, JobSink and OutboundHttp implementations are in-process: MemoryPluginStore and MemoryJobSink are bounded — the job queue holds DEFAULT_JOB_DEPTH and there is no unbounded spelling, because this slice ships no consumer that drains it — and RecordingHttp answers from a fixed table and is a test double. Wiring a real upstream means implementing OutboundHttp against the framework client and honouring the contract on the trait.
  • A plugin loaded at boot cannot be unmounted. To upgrade or remove a plugin without a restart, mount it at runtime (see above).
  • There is no registry; you get artifacts the same way you get any other file.
  • An interpreter is slower than native code. For a hello-world route the overhead is sub-millisecond, but a sandboxed plugin is not where compute-heavy work belongs.
  • Existing first-party plugins stay on the native Plugin trait.
  • Nothing stops a manifest declaring a prefix that shadows a namespace your app already uses. Your own static routes still win, but unmatched paths under it become the plugin's. The prefix is on the consent screen for exactly this reason — read it.

See also

  • Plugins — the native, full-trust plugin trait
  • Edge Capsules — the other wasm lane: read-path routes compiled into a CDN-runnable artifact