Skip to content

Plugin sandbox

Verified against v0.10.0 · internal/plugin/host.go, internal/plugin/hostfn.go, internal/plugin/fetch.go, internal/plugin/breaker.go, internal/plugin/kinds.go, internal/session/registry.go, internal/api/plugins.go, internal/api/pluginpanels.go, internal/db/migrations/0031_plugins.sql, internal/db/migrations/0032_plugin_pending_upgrade.sql, web/src/pages/SessionPage.tsx, web/src/lib/pluginBridge.ts, web/src/components/PluginPanel.tsx, scripts/guard-mutation.sh, internal/api/standup_webhook.go, internal/plugin/describe.go

A Parley plugin is a WebAssembly module run in-process by Extism over wazero. Both are pure Go, so the single-container deploy and CGO_ENABLED=0 survive a plugin being installed.

The load-bearing consequence is the one worth understanding before you install anything: a plugin has no sockets, no filesystem, and no SQL. It cannot open a connection, read a file, or see the database pool. Everything it can do it does by calling a host function, and every host function checks a capability grant before it acts. That is not a workaround for a weak sandbox — it is what makes a capability grant mean something.

CapabilityStatusWhat to do instead
Key-value storageBuilt inNamespaced by install and by the granted scope, with a byte quota charged in the same statement as the write. A key or scope carrying the namespace separator is refused, not escaped, and the two together are length-bounded.
Outbound HTTPBuilt inhttps only, to an operator-approved allowlist, through the guard described below. Extism's own http_request is left with an empty allowed-hosts list, which disables it.
SecretsBuilt inRead-only, per named secret, encrypted at rest with AES-GCM. Unavailable entirely unless PLUGIN_SECRET_KEY is set — there is no plaintext fallback.
LoggingBuilt inThe plugin's text is an attribute of a plugin log line, never the message itself, so it cannot forge a line that reads as the server's own.
Event emissionBuilt inOn plugin.<install name>.<topic> only. A plugin cannot publish an event that reads as one of the core's.
Session read and patchBuilt inTwo separate capabilities. Reads return redacted, client-safe state; a patch is a proposal the core decides what to do with.
Job enqueueBuilt inDeferred work on the same at-least-once queue the core uses. Handlers must be idempotent.
SQLNot builtDeliberately absent and not planned. A plugin that could reach the pool is a plugin with every other capability.

Grants are checked at the effect, not at load

Section titled “Grants are checked at the effect, not at load”

Every check happens inside the host function, immediately before the effect, against the install record read fresh from the database on that call. It is never read from the bundle’s own manifest — the plugin writes that — and never cached at load time.

The practical consequence: revoking a grant or disabling an install takes effect on the next call, not the next restart.

An upgrade that asks for capabilities beyond what was approved does not get them by arriving. The requested version and its grants are parked as pending and the install keeps running on the version and the grants it already had, until an operator approves. An upgrade that asks for the same or less applies immediately.

Outbound HTTP is the highest-risk surface in the whole design, so the sequence is spelled out here in the order it runs, for every hop including every redirect:

  1. The scheme must be https.
  2. The host must match the install’s allowlist. An entry is a hostname with at most one leading *. label; *.example.com matches subdomains but not the apex, and api.*.example.com or a bare * is refused at install time.
  3. The host is resolved once, and every returned A/AAAA record is screened. One blocked record fails the whole request — a name that answers with a public address alongside a metadata address is a rebinding attempt, not a multi-homed service.
  4. The connection is made to the screened address, not to the hostname, so what the resolver would say a moment later cannot change where the bytes go.

Blocked ranges include loopback, 0.0.0.0/8, RFC 1918 private space, carrier-grade NAT (100.64/10), link-local (169.254/16, which is where every major cloud parks its metadata service), IPv6 unique-local (fc00::/7, which covers fd00:ec2::254), multicast, and reserved space — plus the embedded IPv4 address inside a 6to4, the well-known NAT64 prefix 64:ff9b::/96, an IPv4-mapped (::ffff:169.254.169.254) or IPv4-compatible (::169.254.169.254) address. The rest of 64:ff9b::/32 is blocked outright, including the local-use prefix 64:ff9b:1::/48: only the /96 form places the IPv4 address in the last four bytes, and any other layout is refused.

Redirects are followed by hand, up to a bounded number of hops, and each hop repeats all four steps. A guard that checks the domain once and then follows redirects freely is the classic bypass; this one does not.

Credentials do not follow a redirect off the host

Section titled “Credentials do not follow a redirect off the host”

A plugin’s request headers are its own, but a redirect is the far end choosing where they go next. So Authorization, Cookie, Cookie2, Proxy-Authorization and Www-Authenticate are dropped on any hop that lands on a different host than the one the plugin named — the strip net/http performs when it follows redirects itself, made explicit here because this guard follows them by hand. Other headers travel unchanged.

Without it, a single allowlist entry wide enough to cover two hosts is enough: the first host 302s to the second, and the credential an operator approved for one arrives at the other.

Parley’s one core outbound request, the standup webhook, is sent by this same guard rather than by a second HTTP client. Its allowlist is the operator’s STANDUP_WEBHOOK_HOSTS, never anything a space owner writes; a space owner’s URL must also match that list when it is saved, and a URL naming an IP address or carrying credentials is refused outright. The body carries no standup content — only the event, its id, the space and the room URL — and the signing secret is never logged.

No grant makes a fetch legal from a synchronous hook. A hook runs on the path a room’s state broadcast waits on, so it may not wait on the network. Remote data reaches a hook from a cache that a job filled.

CapabilityStatusWhat to do instead
Per-call timeoutBuilt inEnforced both by the call context and by the Extism manifest. Default 2s; PLUGIN_CALL_TIMEOUT.
Memory capBuilt inEnforced both by the wazero runtime page limit and by the manifest. Default 256 pages (16 MiB); PLUGIN_MEMORY_PAGES.
Recover at every call siteBuilt inA trap in guest code ends the call, not the goroutine that scheduled it.
In-flight call capBuilt inTotal and per install. Per-call limits bound one call; without a concurrency cap a plugin still exhausts the process with many individually well-behaved calls.
Circuit breakerBuilt inConsecutive failures degrade a plugin for a cooldown; repeated degradation disables it durably, in the database, so it does not come back on the next restart.
Bounded module cacheBuilt inCompiled once at enable time, evicted least-recently-used past the bound, and evicted outright when a plugin is disabled rather than left resident.

A plugin that draws something gets a rectangle, not the page.

Its UI is served from /plugin-ui/<name>/<version> and embedded in an iframe with sandbox="allow-scripts" — and nothing else. allow-same-origin is the attribute that would undo the whole thing: with it the frame shares the app’s origin, which means the session cookie, the API as the signed-in user, and a fetch it can monkey-patch. A grant it can bypass in three lines is decoration. Without it the frame has an opaque origin, and no second HTTP origin has to be deployed to get one.

The framed document’s policy is default-src 'none' with connect-src 'none'. Plugin UI cannot make a network request at all — no fetch, no socket, no beacon, no image. That is what makes a capability grant enforceable, because every byte the plugin sees has to come through the host, and it is also why an air-gapped install works.

Because the frame can load nothing, the host assembles the document: the bridge bootstrap and the plugin’s own bundle are inlined into it. A UI bundle lives beside its wasm as <name>-<version>.ui.js, and its name and version are screened for path separators exactly as the wasm bundle’s are. Beneath that screen, the read itself is confined to the plugin directory by the OS, not just by a string check: both the UI bundle and the wasm bundle are opened through an os.Root rooted at the plugin directory, which refuses any path that climbs out of it and refuses to follow a symlink placed inside the directory that points outside it — traversal is structurally impossible rather than merely screened for.

The header carve-out is a route group, not a path check

Section titled “The header carve-out is a route group, not a path check”

Parley sends X-Frame-Options: DENY on every response, and a framed document that carries it is blocked by every browser before its CSP is read. So securityHeaders splits into two route-scoped profiles: the plugin frame lives in a chi route group with its own middleware, which never runs the DENY-setting one, and answers frame-ancestors 'self' instead.

It is deliberately not a path-prefix check inside securityHeaders. A prefix check is a matching rule, and matching rules get evaded. A route group is reachable only by being registered in it — and a test walks the real routing tree and holds every other route to the whole header profile — on a method each route answers on and on one it does not — so the carve-out cannot widen without going red. The disallowed-method probe is there because chi answers a 405 from its own handler, outside the route tree: moving a header middleware onto a route group stops covering that response, and a walk over registered method+path pairs alone cannot see it.

The frame’s origin is the string "null". So is every other opaque frame’s, in this tab or any other, so event.origin proves nothing and checking it is not security. The port is the credential instead: the host creates one MessageChannel per frame, transfers one half in the handshake, and never reads window.onmessage again. A port is unforgeable and unicast, so a message arriving on it came from the frame it was handed to.

Getting the port into the frame is the one moment the frame reads its window, and any frame on the page can post to a window. So the frame answers the handshake only when the sender is its own embedder and the message carries the host’s marker. Without that, a sibling plugin’s frame could race the host, capture another plugin’s channel, feed it forged state and read every action it proposes.

Redaction runs host-side, before anything crosses

Section titled “Redaction runs host-side, before anything crosses”

Session state is pushed from the react-query cache the room already keeps — there is no second websocket — and it is redacted first. A frame is sent a room only when that room runs a ceremony the frame’s own install provides; the envelope names the providing install, and the bridge compares it with the install it framed. A planning poker room, a standup room and another plugin’s room send the frame nothing at all — not the state, not who is seated, not the title — so a plugin’s toolbar, export-menu or nested panel in one of those rooms is shown the design tokens and no room data. That is what the consent screen promises for session:read, and it is the same line the session host functions hold on the server.

A toolbar or export-menu frame stays mounted as the user moves between rooms, so when they leave a room the plugin provides for one it does not, the frame is sent a state message whose state is null — once, however many such rooms follow — and nothing further until one of its own rooms comes back. A frame that has never been shown one of its own rooms is sent nothing at all, not even the null. Plugin UI should treat a null state as “no room”.

For a room the plugin does provide, the view is built field by field rather than filtered: the envelope’s session fields, the roster, and the document the kind’s own StateFunc already built for every member. The space and org a room belongs to are never written into it. A plugin with no session:read grant is handed nothing at all, in any room.

A plugin proposes; the host performs, using the user’s own cookie against the same route the user’s own click would hit. The plugin never receives a credential and can only ask for what the user could already do — and the server re-authorises every call regardless of what was asked. X-Parley-Plugin-Route names the plugin so org_audit_log records which surface an action came from. It authorises nothing, and it is checked against this org’s installs before it is written: an unchecked header would let any visitor write arbitrary text into an org’s audit log, and an unscoped check would let a plugin any tenant installed vouch for an action in every other tenant’s room. The record names the acting user and lands in the room’s own org, never the instance’s default.

The action name is screened too, and this is a guarantee in its own right. A name becomes a path segment, and an unscreened one is a path expression: dot segments are resolved by the same URL parser fetch uses, so a proposed action of ../../../me leaves /api/sessions/{id}/actions/ entirely and lands somewhere the audit middleware is not even mounted, on the user’s own cookie and genuinely same-origin. So a name is refused unless it is letters, digits, underscore and hyphen, and no longer than 64 characters — screened in the bridge before it reaches the host, and again where the request is built, where both segments are percent-encoded rather than interpolated.

session:act is coarse on purpose, and an operator granting it should read it as everything the granting user can do in this room, without a further gesture. There is no per-action grant: a plugin holding session:act can reveal a round, advance a story, or take any other action the acting user is entitled to, at a moment of the plugin’s choosing rather than the user’s. When the acting user is the facilitator, that includes the facilitator’s own controls.

What bounds it is the server, not the plugin. Every proposed action is re-authorised host-side as if the user had clicked it: an action the kind does not register is a 404, the wrong verb a 405, a facilitator-only action from someone who is not the facilitator a 403, and anything at all in an ended session a 409. So a plugin can never exceed the acting user — the ceiling is that user’s own authority, and it does not rise because a plugin asked. Grant session:act to a plugin you would be willing to let press the buttons you can press; withhold it from one you only want to look.

A panel list is one org’s, not the instance’s

Section titled “A panel list is one org’s, not the instance’s”

What plugin UI a room shows is read from a route hung off the room, and the org is resolved from the room rather than defaulted. That a grant is safe to disclose — the host re-checks every one at the effect — says nothing about enumeration: which plugins a tenant has installed is that tenant’s metadata, and this is the one plugin surface a link guest can reach. A guest sees its own room’s org’s panels and no others. Nav chrome is omitted for a link guest; export-menu chrome is kept only when that room’s kind already exports. Org nav listing lives on GET /api/orgs/{org}/plugins/panels and is behind RequireUser, so a guest never reaches it.

Toolbar, nav and export-menu slots use the same sandboxed iframe and MessageChannel as a nested panel or a full-room ceremony — opaque origin, no allow-same-origin, host-mediated actions. Notifications are not a slot.

Bounds in both directions, and failure that shows

Section titled “Bounds in both directions, and failure that shows”
CapabilityStatusWhat to do instead
Message size capBuilt in64 KiB per message, both ways, measured in bytes of UTF-8 rather than JavaScript string length, and checked before parsing — parsing is the expensive half.
Inbound rate capBuilt in30 messages a second from the frame. Real plugin UI sends one per gesture; exceeding it closes the port rather than throttling, because a plugin that floods is broken or hostile and both want the same answer.
Outbound coalescingBuilt inState pushes are coalesced to one every 100ms with the newest winning, so a busy room's traffic cannot become the frame's load.
Explicit failureBuilt inA handshake that never lands renders a card saying so, never a blank rectangle.
Crash breakerBuilt inRepeated failures stop the panel reloading that plugin; the window slides, and the reader can retry.
Inert under modalsBuilt inFrames carry the platform's inert attribute while a host modal is open, so focus cannot tab underneath the overlay.

Design tokens are pushed into the frame over the same port, so plugin UI re-themes with the app without being able to read the app.

A plugin can provide a whole session kind: the uploaded package declares the ceremony in kinds (kind, display, actions), the kind is registered when the install is enabled and unregistered when it is disabled or uninstalled, and rooms of it are created, dispatched and closed by exactly the paths a core kind uses. That is a wider surface than a panel, and it is bounded in five places.

The shipped proof is the retrospective plugin: columns, cards, hidden authorship, grouping, dot voting and action items, with no commit under internal/ or web/src.

The room itself is the same sandboxed iframe a nested poker panel uses, filling the session chrome rather than an h-64 strip. The envelope carries the install (plugin.name, plugin.version, plugin.grants) so the browser can build /plugin-ui/… and redact without a second websocket. What crosses the bridge still goes through redactSession: the plugin-owned kind’s StateFunc already built client-safe state, and that document is what the providing install’s frame receives. Any other plugin’s frame in the same room receives nothing about it. kindUnavailable still wins first when the install is switched off, and a switched-off room sends every frame nothing.

A ceremony belongs to the org that installed it

Section titled “A ceremony belongs to the org that installed it”

An install belongs to one org, and so does the kind it provides. Another org is never offered the kind, is refused when it names it — the same kind must be one of … an org gets for a kind that does not exist — and its rooms never block the owning org’s uninstall. The refusal is enforced twice: in the registry the create handler checks, and in the insert itself, which carries the space’s own org in its predicate so a kind cannot be claimed between the check and the write. The two core kinds belong to no org and stay available to every one of them.

A plugin reaches its own ceremonies and nothing else

Section titled “A plugin reaches its own ceremonies and nothing else”

The session host functions refuse any room whose kind this install does not provide. Being in the install’s org is necessary and is not sufficient, and the reason is worth stating rather than implying: revealing a planning poker room is a facilitator-only action, so a plugin patching {"revealed": true} on a poker room in its own org would step around that check and turn every hidden vote in the room into a readable one. A grant cannot narrow that, since an unscoped session:patch means “any session id” and a scoped one is chosen by whoever writes the manifest. So the boundary is structural instead: every call resolves the room’s session_kinds row and requires that its provider is this install’s name and its org_id this install’s org. The two core kinds are provider = 'core' with no org, so they are provided by no install and are closed to every plugin on the instance — a plugin can neither read a poker room nor reveal one.

session_kinds.org_id is NOT NULL for everything but the core rows, enforced by a check constraint, because every one of those org-matched predicates is an equality and SQL equality never matches NULL: a plugin row without an org would be un-retirable, would not block its install’s uninstall, and would stay on offer to every org.

parley_session_get returns the same envelope a browser is sent, built by the session registry, so the kind’s own StateFunc has already decided what is client-safe. That is a projection rather than a filter: whatever the kind’s State decided is client-safe is the whole of what a plugin gets, and there is no second path that reaches storage directly. TestAPluginReadsItsOwnRoomThroughTheRegistryEnvelope asserts that a read returns the registry’s envelope and nothing beside it.

The stronger claim about a poker room is now the ownership boundary above rather than the redaction: TestAPluginCannotReadOrRevealAPokerRoomItDoesNotProvide patches {"revealed": true} at a poker room mid-vote and asserts both that the call is refused and that the room’s votes are still hidden afterwards — the redaction is never even reached, because the room is not the plugin’s to read.

parley_session_patch accepts a phase and a reveal, and nothing else. Unknown fields are refused rather than ignored, an ended room refuses every patch as it refuses every action, and a plugin can only patch a room of its own ceremony in its own org. What a plugin owns beyond that lives in its own key-value store.

An action of a plugin kind is dispatched like any other

Section titled “An action of a plugin kind is dispatched like any other”

The dispatcher runs one ladder for every kind: an unknown action is 404, the wrong verb 405 naming the verb that works, a facilitator-only action asked by a member 403, and anything at all on an ended room 409. A kind arriving from a manifest is registered through the same Register a core kind is, which refuses an action answering GET or HEAD — the cross-site guard exempts those verbs, so such an action would be a write with no protection at all.

The manifest is screened before any of that, at install: a kind name and an action name must be 1–64 characters of a-z, 0-9 and dashes, and a verb is upper-cased and then has to be one of POST, PUT, PATCH or DELETE. The normalisation is the point of the whitelist as much as the whitelist is: a manifest writing "get" used to pass the registry’s comparison against GET and then never match the dispatcher’s exact comparison either, which is a dead action rather than an exposed one — confusing, and confusion in a security surface is worth spending a refusal on.

Switching a ceremony off degrades its rooms, and never breaks them

Section titled “Switching a ceremony off degrades its rooms, and never breaks them”

Disabling a plugin unregisters its kind at once. The rooms stay: a session outlives the install that offered its kind by design, so a room whose ceremony is not registered comes back from the API with kindUnavailable and a null state, and the page says the ceremony is not running. Nothing is deleted, nothing is rewritten, and enabling the plugin again restores every one of those rooms exactly as they were. TestARoomOfADisabledPluginsKindStillLoadsAndComesBack disables a plugin with a live room, loads the room, and enables it again.

The registry is written while rooms are dispatching against it, so it is copy-on-write behind an atomic pointer: a reader takes an atomic load and never a lock, and a registration publishes a whole new map rather than mutating the one readers hold. A -race test registers and unregisters a kind in a loop while another goroutine dispatches against it.

Each guarantee above has a hostile fixture behind it — a WebAssembly guest that hangs, exhausts memory, traps, fetches a link-local address, follows a redirect to a host it was never granted, runs past its storage quota, or forges a key into another namespace.

Each fixture asserts its own mechanism: a lower bound on elapsed time for the timeout, a memory-specific error for the cap, a specific blocked reason for each fetch refusal. “The room survived” also passes when the fixture did nothing at all.

And because a fixture that passes proves nothing on its own, scripts/guard-mutation.sh runs as its own required CI leg: it breaks each guard on purpose — at every site, for the guards deliberately enforced twice — and fails the build if the test covering it stays green. It also checks that each mutated package still compiles before running its test, because a mutation that breaks the build makes go test exit non-zero for a reason that has nothing to do with the assertion, and a harness that scores that as a catch is grading itself.

An operator administers plugins at /o/{org}/admin/plugins, and the routes behind it are restricted to the org’s admins server-side — a member who reaches the URL another way gets 403 from the API, not a blank screen.

An install belongs to one org, and the admin gate is not on its own enough to say so. That gate resolves the org slug in the caller’s own path, which proves only that they administer an org — so every lookup this surface makes is scoped to plugin_installs.org_id as well. An install belonging to another org answers 404, deliberately the same answer an id that was never issued gets: 403 would confirm the id exists somewhere, which is enough to enumerate what other tenants run. A plugin’s name is unique within an org rather than across the instance, so two orgs installing the same plugin get two installs, with two separate key-value stores and two separate sets of secrets.

That page is where the grant model becomes real, so its wording is treated as part of the boundary rather than as copy:

  • A grant is named by what it permits in consequence. “Can send anything it holds — including session data it has read — to any subdomain of example.com” rather than fetch: *.example.com.
  • Every wildcard is expanded, in full, at install time. The screen shows worked examples of what an entry covers and what it deliberately does not (*.example.com matches api.example.com but not example.com itself). The examples are generated from the guard’s own matching, and guard-mutation.sh breaks the expansion on purpose to prove the test behind it is real — a description a human cannot read is not consent, and one that disagrees with the guard is worse.
  • Installing is impossible without an explicit grant decision. The install request carries grantsAccepted, and the server refuses without it.
  • An upgrade that widens capabilities renders as a diff and approval is never the default action. The plugin keeps running on the old version under the old grants until an operator ticks the acknowledgement and presses a deliberately secondary control; keeping the current capabilities is the primary one.
  • Uninstall is a distinct, irreversible act, never a disable. It cascades to the plugin’s key-value store and its encrypted secrets, which cannot be recovered, and it is refused while any session of a kind the plugin provides still exists — the refusal names those kinds. The refusal, the retirement of those kinds, the delete and the audit row are one transaction: a check and a delete on separate round trips left a window in which a room of a provided kind could be created between them, and an audit insert that only logged its own failure could leave the one irreversible action on this surface with no record of who performed it. Retiring the kinds is part of it because session_kinds rows are retired rather than deleted, and an unretired kind whose provider is gone is a kind a new room can still be created with.
  • Health is visible, and never asserted past what the server can see. A plugin the circuit breaker has degraded or disabled says so on the page, with its reason and its last error, instead of silently doing nothing. The breaker’s judgement stays in memory, where it belongs — a persisted cooldown could not be cleared by a restart — and is read out through the administration API; the durable half, whether an install is enabled, was already in plugin_installs. When no plugin host is running at all, an enabled install reports unknown rather than healthy: nothing is running to have checked it, and the page says so instead of implying a check that never happened.
  • An unscoped grant is never described in the singular. An empty scope means “any”, so session:read with no scope is read access to every session id the plugin may reach and secrets with no scope is every secret on the instance. The sentence says exactly that, and a scoped grant names the one thing it covers instead — wording that understates the reach of what is being agreed to is a defect in the boundary, not in the copy. The session sentences say what bounds them too: what session:read and session:patch reach is decided by the host’s kind-ownership check and not by the scope, so the copy names the plugin’s own ceremonies rather than promising every session on the instance. Overstating is its own defect — an operator who reads every grant as total cannot tell the plugin that needs the access from the one asking for the world.
  • The theme reset is drawn in literal colours. A theme pack owns every colour token in the app, so a reset control styled with those tokens can be made invisible by the pack it exists to undo. That one control uses no theme variable at all.

The guest can be written in any language with an official Extism PDK: Rust, JavaScript, Go (via TinyGo), Haskell, AssemblyScript, C, Zig, and .NET.

Python is not a guest PDK — do not plan on it. The Go PDK targets TinyGo, so a guest cannot use goroutine-heavy or reflection-heavy parts of the standard library.

There is no central plugin registry and no signature verification: an operator places a bundle in PLUGIN_DIR and approves its grants, and that approval is the trust decision. There are no per-plugin Postgres schemas and no auth-provider plugins.