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):

  1. Add 'self' to the app's frame-src in its Content-Security-Policy, so the app may embed the sandbox document.
  2. Serve /app/plugin-sandbox.html with 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.