The sandbox
Most plugins run in-page — a trusted <script> on the app origin with the
full Orbit API. That is fine for code an operator wrote or fully vets, but it
also means such a plugin can read anything on the page: cookies, localStorage
(including the SASL handoff password), the store. For code you don't fully
trust — a community or third-party plugin — Orbit has a real isolation boundary.
The sandbox runs code in an opaque-origin iframe
(sandbox="allow-scripts", no allow-same-origin) that can reach the app only
through a capability-gated message bridge. Sandboxed code cannot touch the page
DOM, cookies, localStorage, or the store — it can do only what its declared
permissions allow, and nothing else.
Experimental. The sandbox API is small and may change between releases. It is a core subsystem (
src/modules/sandbox/), not a plugin itself.
Two ways it is used
Built-in features
Orbit ships a few small features that run sandboxed by default — bundled into
the app and mounted by the app itself, no external file to load. They are
opt-in per deployment: a built-in only mounts if its name is listed in
config.json.
{
"builtins": ["dice"] // enables the sandboxed 🎲 / 🪙 footer widget
}
The default is [] (no built-ins). Currently the only built-in is dice.
Sandboxed plugins
Any config.json plugin entry can opt into the sandbox with sandbox: true and a
list of permissions. Use this for community / third-party / less-trusted plugins.
"plugins": [
"/app/plugins/orbit-clock.js", // in-page (trusted)
{ "url": "/app/plugins/community-thing.js", // sandboxed (isolated)
"sandbox": true, "permissions": ["irc", "storage"] }
]
Capabilities
A sandboxed plugin can do nothing privileged unless the operator grants the matching permission. Reading a cached state snapshot and rendering UI inside its own iframe need no grant — they are already contained.
| permission | unlocks |
|---|---|
irc |
orbit.irc.say / msg / send / join / part / list — acts as the user |
notify |
orbit.notify(title, body) |
storage |
orbit.storage.set(key, value) (namespaced, persisted for the plugin) |
Ungranted or unknown calls are refused by the host (fail-closed).
The sandboxed API
A sandboxed plugin registers the same way as any plugin —
Orbit.plugin(name, fn) — but fn receives a constrained orbit:
| member | notes |
|---|---|
orbit.log(...) |
namespaced console log |
orbit.on(event, fn) |
forwarded events: connected, message, buffer.active, status |
orbit.irc.* |
say / msg / send / join / part / list (needs irc) |
orbit.notify(title, body?) |
desktop notification (needs notify) |
orbit.state.active() / nick() / account() / buffers() |
synchronous, from a pushed snapshot |
orbit.server.network() / isupport() / hasCap(cap) / caps() |
synchronous, from the snapshot — gate cap-dependent behaviour; no numeric() (the sandbox gets no raw events) |
orbit.storage.get(k, fallback?) / set(k, v) |
set needs storage |
orbit.ui(slot, build) |
build(el) fills the plugin's own iframe; the host sizes the frame to the content |
The app's theme variables (--bg, --ink, --accent, --muted, --border,
--accent-soft) are mirrored into the sandbox and kept in sync on theme
change, so sandboxed UI can use var(--accent) and look native in light and dark.
Writing one
Orbit.plugin('coinflip', function (orbit) {
orbit.ui('footer_item', function (root) {
var b = document.createElement('button');
b.textContent = '🪙';
b.style.cssText = 'border:1px solid var(--border);background:var(--accent-soft);' +
'color:var(--ink);border-radius:9px;padding:.3rem .5rem;cursor:pointer';
b.onclick = function () { orbit.irc.say('🪙 ' + (Math.random() < 0.5 ? 'heads' : 'tails')); };
root.appendChild(b);
});
});
With "permissions": ["irc"] this can post to the channel but has no access to the
page, cookies, localStorage, or the store. UI is built with plain DOM inside the
plugin's own iframe — the app's React is not shared across the isolation boundary.
Self-hosting
The sandbox iframe needs two things from your web server, both contained (the sandbox document is opaque-origin, so its looser script policy cannot touch the app):
- Add
'self'to the app'sframe-srcin its Content-Security-Policy, so the app may embed the sandbox document. - Serve
/app/plugin-sandbox.htmlwith its own tight CSP that lets the bootstrap run but blocks all network egress:
Content-Security-Policy: default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; frame-ancestors 'self'
Until (1) is applied the sandbox iframe is blocked, so sandboxed code is simply inert — in-page plugins are unaffected.