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.
What a plugin gets
Section titled “What a plugin gets”| Capability | Status | What to do instead |
|---|---|---|
| Key-value storage | Built in | Namespaced 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 HTTP | Built in | https 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. |
| Secrets | Built in | Read-only, per named secret, encrypted at rest with AES-GCM. Unavailable entirely unless PLUGIN_SECRET_KEY is set — there is no plaintext fallback. |
| Logging | Built in | The 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 emission | Built in | On plugin.<install name>.<topic> only. A plugin cannot publish an event that reads as one of the core's. |
| Session read and patch | Built in | Two separate capabilities. Reads return redacted, client-safe state; a patch is a proposal the core decides what to do with. |
| Job enqueue | Built in | Deferred work on the same at-least-once queue the core uses. Handlers must be idempotent. |
| SQL | Not built | Deliberately 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.
The fetch guard
Section titled “The fetch guard”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:
- The scheme must be
https. - The host must match the install’s allowlist. An entry is a hostname with at
most one leading
*.label;*.example.commatches subdomains but not the apex, andapi.*.example.comor a bare*is refused at install time. - 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.
- 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.
The same guard carries standup webhooks
Section titled “The same guard carries standup webhooks”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.
Synchronous hooks cannot fetch at all
Section titled “Synchronous hooks cannot fetch at all”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.
Containment
Section titled “Containment”| Capability | Status | What to do instead |
|---|---|---|
| Per-call timeout | Built in | Enforced both by the call context and by the Extism manifest. Default 2s; PLUGIN_CALL_TIMEOUT. |
| Memory cap | Built in | Enforced both by the wazero runtime page limit and by the manifest. Default 256 pages (16 MiB); PLUGIN_MEMORY_PAGES. |
| Recover at every call site | Built in | A trap in guest code ends the call, not the goroutine that scheduled it. |
| In-flight call cap | Built in | Total 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 breaker | Built in | Consecutive 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 cache | Built in | Compiled once at enable time, evicted least-recently-used past the bound, and evicted outright when a plugin is disabled rather than left resident. |
Plugin UI
Section titled “Plugin UI”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.
Authentication is by channel identity
Section titled “Authentication is by channel identity”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.
Actions are host-mediated
Section titled “Actions are host-mediated”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.
What session:act actually grants
Section titled “What session:act actually grants”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”| Capability | Status | What to do instead |
|---|---|---|
| Message size cap | Built in | 64 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 cap | Built in | 30 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 coalescing | Built in | State pushes are coalesced to one every 100ms with the newest winning, so a busy room's traffic cannot become the frame's load. |
| Explicit failure | Built in | A handshake that never lands renders a card saying so, never a blank rectangle. |
| Crash breaker | Built in | Repeated failures stop the panel reloading that plugin; the window slides, and the reader can retry. |
| Inert under modals | Built in | Frames 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 that owns a ceremony
Section titled “A plugin that owns a ceremony”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.
Reading a room is reading the envelope
Section titled “Reading a room is reading the envelope”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.
Patching a room is a closed document
Section titled “Patching a room is a closed document”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.
How these claims are kept honest
Section titled “How these claims are kept honest”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.
The consent surface
Section titled “The consent surface”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 thanfetch: *.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.commatchesapi.example.combut notexample.comitself). The examples are generated from the guard’s own matching, andguard-mutation.shbreaks 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_kindsrows 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 reportsunknownrather thanhealthy: 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:readwith no scope is read access to every session id the plugin may reach andsecretswith 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: whatsession:readandsession:patchreach 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.
Writing a plugin
Section titled “Writing a plugin”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.
What is out of scope
Section titled “What is out of scope”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.