@cesium-ai/codegen-sandbox
Browser-executed QuickJS-wasm sandbox that runs already-verified CesiumJS code against a live
Viewer, plus the client-side execution guardrails around it.
This is the execution half of the "Code Mode" pipeline; generation and static verification
lives in @cesium-ai/codegen-cesium:
@cesium-ai/codegen-cesium(server-side, Node-safe): turns a modelintentinto a CesiumJS snippet and statically verifies it (AST parse only — never executes).@cesium-ai/codegen-sandbox(this package, frontend-only): executes an already-verified snippet, isolated in a QuickJS-wasm interpreter bound to the liveViewer.
They're separate packages because this one depends on cesium (WebGL/DOM) and
quickjs-emscripten (browser wasm) and must never be imported server-side, while
@cesium-ai/codegen-cesium stays Node-safe by carrying none of that.
What is QuickJS?
QuickJS is a small, embeddable JS engine, compiled to WebAssembly
here via quickjs-emscripten so it can run
entirely inside the browser, isolated from the page's own JS engine. The untrusted,
LLM-generated CesiumJS snippet runs inside this separate WASM VM ("the guest") instead of the
app's real JS context ("the host") — it has no shared memory with the host (every value crossing
the boundary is JSON data or an opaque handle id), no access to the DOM/fetch/window, and its
time/memory usage is bounded and enforced (see Restrictions below).
In short: QuickJS-wasm gives per-call, disposable sandboxes that let this package run
untrusted/model-generated code with real crash/hang isolation, at the cost of the marshaling layer
(src/bindings/) needed to bridge guest calls back to the real Cesium Viewer.
How It Works
%%{init: {"themeVariables": {"fontSize": "16px"}, "flowchart": {"nodeSpacing": 40, "rankSpacing": 55, "padding": 10}}}%%
flowchart TD
A["Verified CesiumJS snippet\n(from @cesium-ai/codegen-cesium)"] --> C["runCesiumCodeInSandbox"]
subgraph Sandbox["Fresh QuickJS-wasm interpreter (per call)"]
C --> D["newAsyncContext()"]
D --> E["Set interrupt handler (timeout)\nSet memory limit"]
E --> F["createProxiedViewer(viewer)\nentity / primitive / data-source caps"]
F --> G["Register host bridge functions\n__cesiumSandboxHostGetSync__ / __cesiumSandboxHostSetSync__ /\n__cesiumSandboxHostApplySync__ / __cesiumSandboxHostConstructSync__"]
G --> H["Build guest prelude:\nvalue-type marshaling + remote proxy + viewer binding"]
H --> I["Wrap snippet in async IIFE\nand evalCodeAsync(...)"]
I --> J{"Guest code calls\nviewer.* / Cesium.*"}
J -->|get/set/apply/construct| K["Host resolves handle,\nReflect.get/set/apply/construct,\nwraps result"]
K -->|apply result is a Promise| L["Bridge via ctx.newPromise(),\nsettle once the real Promise resolves"]
K --> J
L --> J
J -->|caps exceeded| M["EntityCapExceededError /\nCollectionCapExceededError"]
J -->|script settles| N["ctx.dump result"]
end
N --> O["{ success: true, result }"]
M --> P["{ success: false, error }"]
Sandbox -->|throw / crash / timeout| P
O --> Q["vm.dispose()\n(interpreter + handles discarded)"]
P --> Q
Key points:
- One interpreter per call. Fresh VM per invocation, always disposed in a
finallyblock — no state, bindings, or object handles leak between runs. - The guest never sees real Cesium objects.
SandboxHandlesmarshals class instances as opaque handle ids and value types (e.g.Cartesian3,Color) transparently through the generic__cesiumSandboxHostGetSync__/__cesiumSandboxHostApplySync__bridge. Any call that returns a host-sidePromiseis bridged back as a real QuickJS promise viactx.newPromise()— one path handles both sync and Promise-returning calls. - Guardrails are enforced host-side, transparently.
createProxiedViewerwraps the realViewerso calls likeentities.add(...)are checked against caps before being forwarded — the generated code itself needs no changes to respect them. - Failure is always structured, never thrown. Timeouts, memory-limit hits, cap violations,
blocked callbacks/properties, and rejected Promises all resolve to
{ success: false, error }rather than an unhandled rejection or crashed tab.
Restrictions
Everything generated code is restricted from doing, in one place:
| Restriction | Limit / rule | Enforced by |
|---|---|---|
| Execution timeout | 5000 ms default (DEFAULT_TIMEOUT_MS), configurable via timeoutMs |
QuickJS interrupt handler (shouldInterruptAfterDeadline) |
| Memory limit | 64 MiB default (DEFAULT_MEMORY_LIMIT_BYTES), configurable via memoryLimitBytes |
ctx.runtime.setMemoryLimit(...) |
| Live handle count | 500 max (MAX_HANDLES) |
SandboxHandles |
Entities per viewer.entities |
200 default (DEFAULT_MAX_ITEMS_PER_COLLECTION), configurable via maxItemsPerCollection |
EntityCapExceededError in createProxiedViewer |
| Items per scene collection | Same ceiling — scene.primitives, groundPrimitives, postProcessStages, imageryLayers, dataSources |
CollectionCapExceededError in createProxiedViewer |
| Entities per data source | Same ceiling, checked before viewer.dataSources.add(...) |
createProxiedViewer |
| Blocked properties | Any _-prefixed member, plus destroy, document, window, canvas, container, contentWindow, prototype, __proto__, removeAll, isDestroyed, and others |
assertSandboxPropertyAllowed on every get/set/apply/construct |
| Blocked static exports | e.g. Resource, IonResource, TaskProcessor — network- or global-state-capable |
blockedStaticExports in cesium-capabilities.json |
| Network access | Default-deny; only exact-match origins in allowedNetworkOrigins, optionally relative URLs via allowRelativeNetworkUrls |
URL policy check before any host call receives a URL argument |
| Guest callbacks | Not supported — a bare guest function (event handler, CallbackProperty, etc.) is rejected, not silently dropped |
__marshalArg__ in the guest prelude |
DOM / window / fetch |
Never exposed to the guest at all | No binding exists for them |
| State between runs | None — every call gets a fresh interpreter, disposed after | newAsyncContext() / vm.dispose() |
All of the above fail the same way — a structured { success: false, error }, never a thrown
exception, unhandled rejection, or crashed tab — so callers never need to distinguish a resource
limit from a domain guardrail from the generated code's own error.
How Cesium Values Are Resolved
The guest starts with a local Cesium object and then wraps it in a fallback Proxy. Resolution
depends on which kind of Cesium value generated code requests:
| Kind | When it is used | How it is resolved |
|---|---|---|
| Guest-local value type, enum, or constant table | Pure math/data APIs such as Cesium.Cartesian3, Cesium.Color, and Cesium.Math, plus automatically discovered immutable primitive records such as Cesium.ArcType and Cesium.VerticalOrigin. |
The property already exists on the guest's local Cesium object, so the fallback proxy returns it directly. Construction and static methods run entirely inside QuickJS with no host bridge call. |
| Static Cesium export | Any installed top-level Cesium export that is not in blockedStaticExports, such as Cesium.defined, Cesium.Rectangle, or Cesium.Cesium3DTileset. |
The property is absent from the local object, so guest-prelude-static-fallback.ts reads it from the denylist-filtered host namespace through __cesiumSandboxHostGetSync__. The returned function, class, or object is represented by an opaque remote proxy. |
| Dynamic Promise result | Any non-blocked host function or method whose actual return value is Promise-like, such as Cesium.Cesium3DTileset.fromUrl(...), viewer.flyTo(...), or viewer.dataSources.add(...). |
It uses the normal __cesiumSandboxHostApplySync__ call path. host-bridge.ts detects the returned thenable at runtime and creates a genuine QuickJS promise with ctx.newPromise(). There is no separate async allowlist or async dispatch function. |
| Host object instance | A real Viewer, entity, tileset, provider, collection, function, or other non-JSON host value. |
SandboxHandles keeps the real value host-side and gives the guest an opaque handle-backed remote proxy. Reads, writes, calls, and construction on that proxy use the four bridge functions below. |
Value types stay guest-local while calculations are local. If one crosses into a real host call,
for example viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(...) }), the guest tags
it as JSON-safe value-type data and SandboxHandles.unwrap reconstructs the real Cesium instance
before invoking the host API (and vice versa for a value type returned from the host).
The reviewed valueTypes map in cesium-capabilities.json is the single source of truth — each
key is a Cesium constructor, and its ordered field list is both the serialized shape and
constructor argument order:
"valueTypes": {
"Cartesian3": ["x", "y", "z"],
"Color": ["red", "green", "blue", "alpha"]
}
npm run generate:value-type-registry -w @cesium-ai/codegen-sandbox regenerates
bindings/generated/value-type-registry.ts from that map — both host and guest marshaling consume
it generically, so a reviewed addition needs no new manual branches. The list is intentionally
reviewed, not inferred from every Cesium class: a candidate must be pure data, safe inside QuickJS,
and fully reconstructable from its listed public fields. DOM, WebGL, network, worker, lifecycle,
and identity-bearing classes (e.g. Cesium3DTileStyle) stay opaque host handles instead.
Enums and constant tables (ArcType, ClockRange, TimeConstants, ...) are discovered
automatically: every frozen top-level record with uppercase keys and JSON-safe primitive values is
copied into the guest. Enums that also expose helper functions don't qualify, and remain available
through the static host fallback instead.
Typical resolution paths are:
Cesium.Cartesian3.fromDegrees(...)
-> guest-local Cesium.Cartesian3
-> no host bridge until the result is passed to a host API
Cesium.defined(value)
-> __cesiumSandboxHostGetSync__(staticCesiumHandle, "defined")
-> __cesiumSandboxHostApplySync__(definedHandle, args)
-> synchronous JSON result
await Cesium.Cesium3DTileset.fromUrl(url)
-> __cesiumSandboxHostGetSync__(staticCesiumHandle, "Cesium3DTileset")
-> __cesiumSandboxHostGetSync__(classHandle, "fromUrl")
-> __cesiumSandboxHostApplySync__(methodHandle, args)
-> host thenable detected -> QuickJS promise -> opaque tileset handle
new Cesium.PinBuilder()
-> __cesiumSandboxHostGetSync__(staticCesiumHandle, "PinBuilder")
-> __cesiumSandboxHostConstructSync__(classHandle, args)
-> opaque PinBuilder instance handle
Host Bridge Functions
The guest never talks to the real Viewer/Cesium module directly — every viewer.* / Cesium.*
property read, assignment, call, or new is rewritten by the guest-side __remoteProxy__
(guest-prelude-host-bridge.ts) into a call to one of four host functions registered on the
QuickJS global object before the script runs (registerHostBindings in bindings/host-bridge.ts):
| Function | Direction | What it does | Triggered by (guest side) |
|---|---|---|---|
__cesiumSandboxHostGetSync__(handleId, prop) |
guest → host, sync | Checks assertSandboxPropertyAllowed(prop), reads the property with Reflect.get, preserves method this binding, and wraps the result as plain data, tagged value-type data, or a new opaque handle. |
Any property read on a remote proxy, including static fallback lookup: Cesium.defined, viewer.entities, or entity.position. |
__cesiumSandboxHostSetSync__(handleId, prop, valueJson) |
guest → host, sync | Checks the property denylist, unwraps JSON data and opaque handles, revives tagged value types into real Cesium instances, and writes with Reflect.set. |
Any remote property assignment: tileset.style = ..., viewer.clock.shouldAnimate = true, or entity.polygon.material = .... |
__cesiumSandboxHostApplySync__(handleId, argsJson) |
guest → host, sync or Promise-bridged | Resolves a function handle, unwraps its arguments, and invokes it. A synchronous result is wrapped immediately. A Promise-like result increments pending host work and returns a genuine QuickJS promise created with ctx.newPromise(). |
Calling any remote function or method: Cesium.defined(value), viewer.camera.flyTo(...), or await Cesium.Model.fromGltfAsync(...). |
__cesiumSandboxHostConstructSync__(handleId, argsJson) |
guest → host, sync | Resolves a class/function handle, unwraps constructor arguments, constructs it with Reflect.construct, and wraps the resulting instance as data or an opaque handle. |
new against a remote class, such as new Cesium.PinBuilder() or new Cesium.WebMapServiceImageryProvider(...). |
All four return a JSON envelope: { ok: true, value } or { ok: false, error } (the latter
eventually, via the QuickJS promise, for Promise-bridged calls). A false envelope becomes a normal
thrown Error inside the guest — caught by runCesiumCodeInSandbox's own try/catch — so
guardrail violations, blocked properties, rejected host Promises, and unknown handle ids all
surface as { success: false, error }, never an unhandled rejection.
Bindings Modules (src/bindings/)
The generated snippet is never rewritten or transformed — it runs verbatim, wrapped in an
async IIFE. src/bindings/ instead fakes a working viewer/Cesium API inside the guest (which
otherwise has no real access to either, since guest and host are separate JS runtimes with no
shared memory), via:
- A prelude — guest-side JS defining
viewer,Cesium, and helpers like__remoteProxy__/__marshalArg__, so reads/writes/calls/newlook like ordinary JS. - A marshaling layer deciding what crosses the VM boundary: opaque handles for class instances, plain JSON for value types, and a reimplemented subset of pure Cesium math that never crosses at all. Everything else is a JSON round trip through the host bridge functions, checked against the caps and denylists above.
Each module owns one narrow slice of that design; none maintain an exhaustive manifest of Cesium's API surface — they lean on generic proxies and dynamic dispatch so the bound surface tracks real CesiumJS automatically as it evolves.
| Module | Why it's needed |
|---|---|
cesium-capabilities.json (package root) |
Single reviewed policy manifest: Cesium version, blocked static exports, guest value types, blocked properties, dynamic Promise coverage, unsupported capabilities. |
capabilities-registry.ts |
Typed runtime view of cesium-capabilities.json; binding modules derive their sets/names from this. |
sandbox-handles.ts |
SandboxHandles: the core JSON marshaling boundary — opaque handle ids for class instances, transparent value types, rejects forged handle ids. |
guarded-viewer-proxy.ts |
createProxiedViewer/createGuardedProxy: wraps the real Viewer (and nested camera/scene/entities/dataSources) so cap checks and the property allowlist apply. |
guest-prelude-host-bridge.ts |
Builds the guest-side __remoteProxy__ — the recursive Proxy dispatching reads/writes/calls/new to the four bridge functions above. |
guest-prelude-static-fallback.ts |
Upgrades the guest's Cesium namespace into a Proxy falling back to non-blocked static Cesium exports through the same bridge. |
guest-prelude-value-types.ts |
Reimplements the most common pure value types (Cartesian2/3, Color, Cartographic, HeadingPitchRange/Roll, NearFarScalar) directly in guest JS. |
generated/value-type-registry.ts |
Generated constructor/field metadata from cesium-capabilities.json; regenerate with npm run generate:value-type-registry -w @cesium-ai/codegen-sandbox. |
function-source.ts |
extractFunctionBody — lets guest-prelude "body" functions be real, type-checked TypeScript with their source extracted for injection, not raw template literals. |
execution-guards.ts (package root) |
Client-side defense-in-depth caps (assertEntityCapNotExceeded, assertCollectionCapNotExceeded), independent of process isolation and static verification. |
Usage
import { createConsoleLogger, runCesiumCodeInSandbox } from "@cesium-ai/codegen-sandbox";
const result = await runCesiumCodeInSandbox({
code: verifiedSnippet,
viewer,
// Network-capable Cesium loaders are default-deny — opt in only expected origins.
allowedNetworkOrigins: ["https://assets.example.com"],
allowRelativeNetworkUrls: false,
// Override the default 200-item-per-collection ceiling for this run.
maxItemsPerCollection: 50,
// Opt-in logging (off by default) — see Logging below.
logger: createConsoleLogger("debug"),
});
if (!result.success) {
console.error(result.error); // e.g. a blocked property, cap violation, or timeout
}
Origin matching is exact (https://assets.example.com does not allow
https://assets.example.com.evil.test); protocol-relative URLs are rejected as ambiguous. The
policy inspects nested guest-provided arguments for absolute HTTP(S), root-relative, and
dot-relative URL values before any host setter, function, or constructor receives them. Cesium
calls that resolve configured Ion asset IDs internally don't need their implementation URLs
exposed to guest code.
Upgrading CesiumJS
The package pins Cesium to the version recorded as reviewedCesiumVersion in
cesium-capabilities.json. When upgrading:
- Install the new exact Cesium version.
- Run
npm run validate:cesium-compat -w @cesium-ai/codegen-sandbox. - Review
CESIUM_COMPATIBILITY.md, especially new top-level exports and new or removed Promise-returning APIs. Under the denylist policy, new top-level exports become available by default after the reviewed version is updated. - Add new network, DOM, lifecycle, worker, global-state, or otherwise unsafe top-level exports to
blockedStaticExportsbefore updating the reviewed version. - Update
reviewedCesiumVersiononly after that review. - Run the package tests and browser domain tests.
The validator fails when the installed and reviewed versions differ, when a blocked static export or guest value type disappears, or when a dynamic Promise runtime path no longer exists. The generated report inventories the static denylist and Promise-returning declaration paths.
Guest-native Number, String, and Boolean constructor references are supported as Cesium
constructor arguments; other guest functions are rejected explicitly since callbacks cannot safely
outlive the disposable guest VM. Any host method that returns a Promise (not just a fixed allowlist
of factories) is awaited transparently across the host boundary via the generic apply bridge.
Callers that need to bound how often the sandbox itself is invoked (e.g. this app's ChatPanel)
should pair this with their own call rate limiter — this package no longer ships one, since it has
no dependency on cesium/quickjs-emscripten and was never invoked internally by
runCesiumCodeInSandbox. See frontend/src/utils/sandbox-call-rate-limiter.ts for this app's copy.
Logging
Logging is opt-in and OFF by default — runCesiumCodeInSandbox never writes to the console unless
a logger is supplied (see the Usage example above). logger.debug reports each run's
start/completion plus every host-bridge call crossing the guest/host boundary — useful for
diagnosing "sandbox reports success but nothing visibly changed" bugs. logger.warn reports
blocked property access and other per-call failures; logger.error reports a run's overall
failure. createConsoleLogger(level) builds a console-backed logger with a given minimum level
("debug" | "info" | "warn" | "error" | "silent"); omitting logger (or createSandboxLogger({
enabled: false })) returns the no-op noopLogger.
Trade-offs
Running generated code directly (new Function(...)) shares the page's own JS heap and call
stack, so one bad snippet — an infinite loop, a runaway allocation, a _-prefixed DOM escape —
can hang or crash the tab. The sandbox trades WASM overhead plus the ongoing cost of keeping
src/bindings/ in sync as Cesium evolves for real crash/hang isolation instead. For anything
executing untrusted or model-generated code in production, that trade-off favors keeping the
sandbox; direct execution only makes sense if the generated code is already fully trusted and
pre-verified before it ever reaches the browser.
Exports
| Export | Description |
|---|---|
runCesiumCodeInSandbox |
Runs untrusted/verified code in a fresh QuickJS-wasm interpreter bound to a live Viewer. |
SandboxHandles |
Host/guest JSON marshaling: opaque handles for class instances, transparent tagging for value types. |
createProxiedViewer |
Wraps a live Viewer with collection caps and a guard policy that blocks lifecycle, DOM, private, and bulk-removal properties. |
buildCesiumHostBridgeGuestPrelude, buildCesiumValueTypeGuestPrelude |
The generic remote-proxy bridge and guest-side prelude generators — see src/bindings/. |
assertEntityCapNotExceeded, DEFAULT_MAX_ITEMS_PER_COLLECTION, SceneCollectionCapOptions, EntityCapExceededError |
Configures the ceiling applied independently to guarded scene collections. maxItemsPerCollection falls back to DEFAULT_MAX_ITEMS_PER_COLLECTION. |
createSandboxLogger, createConsoleLogger, noopLogger, SandboxLogger, SandboxLoggerOptions, LogLevel |
Configurable logging for sandbox runs and host-bridge calls, off by default. See Logging. |
Why this isn't part of @cesium-ai/tools-cesium or @cesium-ai/codegen-cesium
@cesium-ai/tools-cesiumis scoped to schema-only viewer tools (flyTo, ...) whose arguments are bounded, typed data — not arbitrary generated code.@cesium-ai/codegen-cesiumis scoped to generation + static verification and must stay Node-safe ("parse-only, never executes generated code"). Folding acesium+quickjs-emscriptenexecution sandbox into it would drag browser/WASM/WebGL dependencies into that server-side bundle.
Growing this package means extending the marshaling/proxy/prelude modules listed under Bindings Modules, not adding new bespoke capability functions.