Script Safety
Every mod entry script (entry.script) and every scene script embedded in an edit-scene
setup file is checked against 5 banned patterns before it is allowed to run. This is
the first, coarsest layer of the sandbox — it runs on raw source text, before the script
ever executes, and it runs every time the script is scanned: at registration, at
serve time (see the serve-time re-scan risk),
and continuously while you edit a draft in Edit Mode.
Read this before you hit one#
These are whole-word regexes with no lexing. The scanner does not parse your script
as JavaScript — it runs \b(...)\b patterns over the raw source text. It cannot tell a
comment from code, a string literal from an identifier, or a property access
(api.window) from a bare global (window). Concretely, all of these reject the mod:
- a comment that says
// don't touch the window state(contains the standalone wordwindow) - a bare property access like
card.parent.destroy(), or a variable declared aslet top = deck.pop();(contains the standalone wordparent/top) - a JSDoc line
@see Function.prototype.bind(contains the standalone wordFunction) - a string literal
"reconnect via WebSocket if needed"(contains the standalone wordWebSocket) - a comment
// don't fetch the next card, draw it(contains the standalone wordfetch)
All five verified against the live patterns, 2026-07-27 (node -e against the exact regex source, not guessed). Note what does not trip a rule, because it matters for the workaround below: topCard, parentZone, deckTop, fetchCount, containerZone and localStorageFallback are all safe — see why in the workaround for each pattern.
The scanner's own doc comment states this trade-off explicitly
(apps/server/src/githubScanner.ts):
"the scanner intentionally uses conservative regex checks as a first-pass gate: 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 deliberate, permanent design choice, not a bug to be filed. This page tells you how to word your way around a false positive by renaming or rephrasing — it does not suggest disabling, narrowing, or bypassing a rule, and no such workaround exists or should be attempted. Every one of these 5 patterns is also a live security boundary against scripts that actually do try to escape the sandbox; treat a rejection as a prompt to rename, not as an obstacle to route around.
The 5 patterns#
Identical in both SANDBOX_SCANNER_RULES (apps/server/src/githubScanner.ts)
and bannedScriptPatterns (packages/shared/src/modManifest.ts) — see
the duplication note
for why there are two copies.
The mechanic behind every workaround below#
\b only matches at a transition between a "word" character (letter, digit, or
underscore — count as a word character regardless of case, but the patterns themselves
are case-sensitive: Function matches, function does not) and a non-word
character (space, punctuation, quote, parenthesis, dot, start/end of string). It does
not match inside an unbroken run of word characters. So parent standing alone is
banned, but attaching more letters, digits, or an underscore directly onto either side —
parentZone, parent_zone, myParent, parentId, topCard, deckTop, fetchCount,
containerZone, localStorageFallback — removes the boundary on that side and the
whole-word match fails. All nine were verified (2026-07-27, against the live regex
source) to not trip any rule. The only thing that still trips a rule is the banned
word appearing with a non-word character (space, dot, quote, bracket, parenthesis, or
string start/end) on both sides — i.e. as its own token.
dom-access#
- Pattern:
/\b(document|window|parent|top|opener)\b/ - Message:
Scripts cannot access DOM/window globals. - Why: mods run in an isolated iframe and must not inspect or mutate host/browser UI state.
- Real trigger: using
window,document,parent,top, oropenerfrom the sandbox — never legitimate for a mod script. - False-positive trigger: a variable, parameter or property used standalone —
let top = deck.pop();,card.parent.destroy();,function parent() {}— or a comment mentioning "the browser window." (top/parentare common short names for "the containing thing" and "the top of a stack" in game logic, and both are real matches here — verified.) - Workaround: rename to a compound identifier:
parent→parentZone/parentId/ownerZone;top→deckTop/stackTop/topCard. Rephrase comments to avoid "window," "top," "parent," "document," or "opener" as standalone words — e.g. "don't touch global state" instead of "don't touch the window." - The one API value that could not be renamed away got an alias instead. A quoted
"top-left"trips this rule —"and-are both non-word characters, so the boundary fires on each side oftop— which made the three top-row screen anchors unwritable in a mod script. Narrowing the pattern to skip quoted text was rejected: matching inside string literals is precisely what catchesself["top"], and turning a false positive into a false negative is the failure mode this rule exists to prevent. Insteadpresentation.anchoracceptsupper-left,upper-centerandupper-rightas input and normalizes each to its canonicaltop-*value before storing it (packages/shared/src/tableObjects.ts,TABLE_UI_SCREEN_ANCHOR_ALIASES). Write the alias, expect the canonical value back, and never compare a read-back anchor against the alias. This is the shape a genuine, unrenameable false positive is fixed in: the value grows a second accepted spelling, the rule does not move. See Table UI widget types.
network-access#
- Pattern:
/\b(fetch|XMLHttpRequest|WebSocket|EventSource)\b/ - Message:
Scripts cannot open arbitrary network connections. - Why: all mod communication must stay on the host-controlled RPC surface (the
apiobject) — a mod must never open its own network connection. - Real trigger: calling
fetch(...), constructingnew XMLHttpRequest(),new WebSocket(...)ornew EventSource(...)— those four names are the whole rule. - False-positive trigger: a string or comment mentioning "fetch the next card," "poll like a WebSocket," or a variable named
fetchCount. - Workaround: rephrase — "draw the next card" instead of "fetch the next card"; rename
fetchCount→drawCount.
storage-access#
- Pattern:
/\b(localStorage|sessionStorage|indexedDB|cookie)\b/ - Message:
Scripts cannot access browser storage or cookies. - Why: persistence must go through the snapshot-synchronized saved-data API (
api.getSavedData/api.setSavedData, capabilitysaved-data), never browser storage. - Real trigger: referencing
localStorage,sessionStorage,indexedDB, orcookie. - False-positive trigger: rare in practice — these are not common English words, but a comment explaining why you're using
api.setSavedDatainstead oflocalStoragewill still trip the rule, because the word appears regardless of context. - Workaround: don't name the browser API in comments at all; say "persistent per-player state" instead of "instead of localStorage."
dynamic-code#
- Pattern:
/\b(eval|Function|importScripts)\b/ - Message:
Scripts cannot use dynamic code loading. - Why: dynamic evaluation bypasses static review and increases sandbox-escape risk.
- Real trigger:
eval(...), theFunctionconstructor,importScripts(...). - False-positive trigger: capital-F
Functionas a JSDoc type annotation (@param {Function} callback), or as a plain English word ("call this function," capitalized at a sentence start — note the pattern is case-sensitive and requires the literal capitalized wordFunction, so lowercase "function" is safe). - Workaround: lowercase it. Use
@param {function} callbackor@param {() => void} callbackinstead of{Function}; avoid starting a sentence in a comment with the word "Function" if it would otherwise read as the identifier — rephrase to lowercase "the function does X."
timer-loop#
- Pattern:
/\b(setInterval|requestAnimationFrame)\b/ - Message:
Scripts must be deterministic and event-driven. - Why: unbounded timers can cause non-deterministic, host-diverging behavior and resource abuse. (Note
setTimeoutis not in this pattern — a mod script may use a bounded, one-shotsetTimeout; only the two repeating/frame-loop APIs are banned.) - Real trigger:
setInterval(...),requestAnimationFrame(...). - False-positive trigger: uncommon as an accidental word match, since neither is an English word — this one is almost always a real usage. If you're hitting it, the fix is architectural, not lexical: move the recurring work onto the event-driven
api.on(...)hooks (capabilitysubscribe-events) instead of a client-side loop.
What "the same 5" means elsewhere#
scanScriptText (githubScanner.ts) applies SANDBOX_SCANNER_RULES during
GitHub scan and re-scan. scanModScriptSafety (modManifest.ts) applies
bannedScriptPatterns during local draft validation, and is reused by
validateSceneScriptSafety (modManifest.ts) for every script embedded in an
edit-scene setup document. Every documented Surface B api example on this site is
run through scanModScriptSafety in CI — see
/docs/scripting-api/api — so a published example can never be
one these rules would reject.
See also#
- What Gets Rejected — the full 35-rule reference, including the other 30 rules and the serve-time re-scan risk.
- Fixing a Rejection — symptom → cause → fix, by code.
- Capabilities Reference — the runtime capability model these patterns sit alongside.
- Surface B —
apireference — the safe RPC surface a script should use instead. - Scanner rule codes — the five codes in one table.
TableUiWidgetType— why three of the nine anchor names need a mod-safe alias, and what it normalizes to.- Known limitations — why the patterns match comments, and why that is not expected to loosen.
