Sandbox limits
Scripts on both surfaces run inside an iframe with sandbox="allow-scripts" and no
allow-same-origin, which forces an opaque null origin, under a document CSP of
default-src 'none'. The frame reaches the table through postMessage and reaches nothing
else.
Three separate mechanisms constrain what you can write, and they fail at three different moments. Knowing which one you have hit is most of the debugging:
| Mechanism | When it stops you | What it looks like |
|---|---|---|
| The type system | While you type, in the editor | A red squiggle: Cannot find name 'document'. |
| The frame's neutering | At run time | A thrown error, or an undefined where you expected a global |
| The static scanner | When a mod is validated, published, registered or served | A rejection naming a rule code, such as dom-access |
The language subset#
Table scripts are TypeScript, compiled in the editor with exactly these options
(apps/web/src/ui/editor/monacoSetup.ts):
target: ES2020
lib: ["es2020"] <- deliberately NO "dom"
strict: true
noImplicitAny: true
The scripting API declarations are loaded as an ambient library on top. That is the whole environment. What follows from it:
document,window,console,fetch,localStorage,matchMediaand every other browser global are compile errors — not because a rule forbids them, but because no declaration for them exists.world.log(message)is how a table script produces output;api.log(message)is the mod equivalent.- In a table script
setTimeoutis a compile error, and still runs. The frame provides it at run time, butlib: ["es2020"]does not declare it and neither does the table-scripting library. Useawait world.wait(seconds), which is declared, takes seconds rather than milliseconds, and reads as pacing rather than plumbing. (A mod script is different — see below.) - A script is a plain top-level program. No
import, noexport. Adding either turns the file into a module and changes what every top-level declaration means. - No top-level
await. ES2020 in a non-module does not have it. Wrap async work in anasync functionand start it withvoid myFunction();. noUncheckedIndexedAccessis off, soarray[0]has typeT, notT | undefined. Guard an index you are not sure about yourself.
A type error does not stop a script from running. The editor emits the transpiled body even when the type checker complains, so a script with cosmetic type problems still executes — a syntax error is what produces no output at all, and a script with no compiled body never runs. Read the diagnostics bar; it is telling you something real even when ▶ Play still works.
Mod scripts are plain JavaScript and are never transpiled — the file in your repository is the
file that runs, and entry.script must end .js. The same absent-globals list applies for the
same reason, with the same api object as the only way out of the frame — with three
declared exceptions, because the mod frame really provides them and the editor typechecks
against lib: ["es2020"] with no @types:
setTimeout,
clearTimeout and a two-member
crypto (randomUUID, getRandomValues). The looping timers
are still refused by the scanner, which is why they are not declared.
What the frame removes at run time#
Both sandbox documents overwrite the same escape hatches before any script body evaluates, using
Object.defineProperty so that a getter-only accessor cannot throw and abort the bootstrap:
| Replaced with a thrower | Replaced with undefined |
|---|---|
fetch · open · alert |
XMLHttpRequest · WebSocket · EventSource · localStorage · sessionStorage · indexedDB |
Calling a thrower raises API is not available inside script sandbox. (mod frame:
… inside mod sandbox.). This is belt-and-braces — the opaque origin already makes the storage
accessors throw and the CSP already blocks every network destination — and it is what makes the
failure legible instead of mysterious.
The static scanner, and why it reads your comments#
Five regular expressions are run over script text. A single match rejects the file.
| Code | Tokens that reject it |
|---|---|
dom-access |
document window parent top opener |
network-access |
fetch XMLHttpRequest WebSocket EventSource |
storage-access |
localStorage sessionStorage indexedDB cookie |
dynamic-code |
eval Function importScripts |
timer-loop |
setInterval requestAnimationFrame |
Full detail, including every rejection message and the per-rule workaround, is on Script safety. Two facts belong here because they surprise people writing table scripts, who reasonably assume the scanner is a mod-publishing concern.
Table scripts are scanned too. validateSceneScriptSafety
(packages/shared/src/modManifest.ts) runs the same five patterns over every scene script
embedded in a scene document, and the server refuses to serve a mod whose scene scripts stop
passing — "Mod scene scripts no longer pass sandbox compatibility checks."
(apps/server/src/githubScanner.ts).
It scans the compiled body, and the compiled body still has your comments in it. The editor
produces the transpiled JavaScript with Monaco's getEmitOutput, which does not strip comments,
and it is that text the scanner reads. So a comment reaches the five patterns verbatim.
The word that catches people is top. // draw the top card rejects the whole scene. So does
// re-attach to the parent and // don't touch the window.
Your script still runs locally in Edit Mode when it contains one of these — nothing scans on ▶ Play. The rejection arrives later, when you publish the mod or when a player's client asks the server for it, which is exactly the worst moment to discover it. Check your comments before you publish.
The boundary rule, and the reason for it#
By design. The five patterns are whole-word regular expressions applied to raw text with no lexing. They cannot tell a comment from code, a string from an identifier, or a property from a global. The scanner's own note states the trade: "false positives are acceptable (mod authors can adjust scripts), while false negatives are treated as boundary risks and should trigger a rule update." That is a security boundary and it is not expected to loosen. Rename or rephrase; there is no flag, no opt-out and no escape syntax, and none should be added.
What does not trip a rule#
\b needs a non-word character on both sides, so gluing letters, digits or an underscore
onto either end removes the boundary. All of these are safe:
topCard deckTop stackTop parentZone parentId myParent
fetchCount containerZone localStorageFallback function
The patterns are case-sensitive: Function is banned, function is not; Top is fine, top
is not. So the fix is always a rename or a rephrase:
| Instead of | Write |
|---|---|
const top = deck.pop(); |
const topCard = deck.pop(); |
// the top of the deck |
// the front of the deck |
// re-attach to the parent |
// re-attach to the ancestor |
@param {Function} handler |
@param {() => void} handler |
// don't fetch the next card |
// don't draw the next card |
setInterval(tidyUp, 1000) |
an onTick handler, or await world.wait(1) in a loop |
Capabilities — mod scripting only#
Table scripting has no capability system. Its restriction is a three-type intent allowlist
(spawn, object-action, transform) enforced on the host, and that is the whole of it.
Mod scripting declares capabilities in its manifest, and every one of the 23 api methods is
gated on one. The complete capability-to-method matrix, the detector regexes and the exact
rejection messages live on Mod capabilities; this page does
not repeat them.
What is worth understanding here is what a capability declaration is for. It is a scanned
declaration of intent, verified against the literal api.<method>( call sites in the script that
was scanned. It is a least-privilege and disclosure mechanism: a reviewer or a player can read a
mod's declared list and know what it can reach before opening a line of its code.
At run time nine of the eleven are checked twice. The Missing mod capability: … throw executes
inside the same untrusted frame as the mod's own script, so it is the error you see while
developing rather than a boundary — a script that posts to the host directly never runs it. The
host then re-validates every gated message against the mod's grants before acting on it, using the
shared MOD_API_METHOD_CAPABILITIES map (apps/web/src/mods/SandboxedModRunner.ts), the same way
it re-verifies an object action or a sound reference. That second check is the authoritative
one. It covers log, spawn-object, register-action, read-world, read-hidden-information,
object-action, play-sound, saved-data and ui.
One of the eleven is a wall rather than a disclosure. read-hidden-information gates
api.getUnredactedSnapshot() and nothing else, and it is the only route to the host's unredacted
table state: all six read-world reads return the least-privileged view — the one a spectator with
no seat and no team gets — on every peer including the host, so a mod cannot reach a hidden card
face, a pile's order or any secretMetadata by arranging to run on the host. Its host-side check
runs before the state is served, it is never implied by read-world, it is never granted by default,
and the publish scanner matches the call by name. It is the one grant whose absence genuinely
withholds data.
read-context and subscribe-events are checked in the frame only, because neither sends the
host anything to check: getMySeat, getMyTeam, getTurn and on are all answered in-frame from
state the host has already delivered. updateContext and dispatchEvent push a contextUpdate and
a hookEvent to every running frame unconditionally, so a frame granted neither capability still
holds the seat, team and turn context and receives every hook payload in its own realm. For those
two, the declaration is disclosure and not a wall — design accordingly, and do not treat a withheld
read-context as hiding anything.
None of this makes a declaration a statement about a mod's intentions: a manifest may still declare more than its script uses.
The boundaries that are independently enforced, and that a capability declaration has no
bearing on, remain intact: the opaque-origin iframe and its CSP, the neutered globals above, the
five scanner patterns, the host-side object-action allowlist, the per-mod namespacing of saved
data, and the ownership check on sound references. A mod cannot reach fetch, localStorage,
eval, another mod's saved data, or an object action outside its 10-item list, whatever its
manifest says.
The capability values#
The capability badge names which manifest-declared capability gates a call.
none#
Every table-scripting entry. The surface has no capability model, so there are no exceptions: if you are reading a table-scripting entry with any other value, the page is wrong.
The eleven mod slugs#
log · spawn-object · register-action · read-context · read-world ·
read-hidden-information · object-action · saved-data · subscribe-events · ui ·
play-sound.
Every mod entry carries exactly one of these — the slug in that method's own
requireCapability('…') call — so none is never correct on a mod entry. log is the only one
a manifest gets without asking: omit capabilities entirely and a mod is granted log and
nothing else.
See also#
- Script safety — all five patterns, their messages and per-rule workarounds.
- Mod capabilities — the capability-to-method matrix and how a declaration is checked.
- What gets rejected — every scanner rule, not only the script ones.
- Host authority — the intent allowlist that restricts table scripting instead.
- Known limitations — the documented gaps in both surfaces.
ModCapability— the 11 slugs, with what each one unlocks.- Scanner rule codes — every rejection code and its message.
- Limits and caps — the numeric ceilings the sandbox enforces.
- setup.json — the declarative alternative to doing it in a script.
- Mod hooks and capabilities —
ModCapabilityand the per-method gate each slug opens.
