docs/wml-engine/wavescript-security.md
WaveScript VM Security & Sandbox Guardrails
Purpose: define mandatory safety controls for executing WaveScript bytecode in engine-wasm/engine.
Threat Model (Current Scope)
- Untrusted deck/script input tries to crash host/runtime.
- Script execution attempts unbounded CPU or memory growth.
- Script attempts to escape engine ownership boundaries (network/filesystem/host logic).
- Malformed bytecode attempts undefined behavior.
- Untrusted deck navigation graph (
onenterforward/onenterbackward/<go>/<prev>, including cycles re-entered viaWMLBrowser.go()) attempts unbounded recursion.
Mandatory Guardrails
- Verification before execution
- Registered WAP-193 units are decoded and fully verified before external name lookup or bounded execution. Verification includes reference domains, standard-library/function identifiers, reachable instruction stack effects, branch-merge depth consistency, and return boundaries. A structural failure in any function quarantines the complete unit.
- Manual entry-point PCs explicitly select the legacy nine-opcode fixture decoder/VM; that compatibility lane is not normative WAP-193 evidence.
- Empty/oversized units fail deterministically.
- Bounded execution
- Instruction step limit.
- Operand stack limit (64 values for both the strict WAP verifier and legacy bounded fixture VM).
- Call-depth limit (
max_call_depthis the true maximum total call-frame count, including the root frame; the boundary check traps atframes.len() >= max_call_depth, not one frame later). - Navigation dispatch-depth limit (
MAX_NAV_DISPATCH_DEPTHinengine-wasm/engine/src/lib.rs): bounds recursive re-entry into navigation viaonenterforward/onenterbackwardactions andWMLBrowser.go()/prev()script effects, so a cyclic deck (e.g. two cards whoseonenterforwardactions target each other) traps with a typed error instead of recursing until the stack overflows. Mirrors the existingMAX_TIMER_DISPATCH_DEPTHguard for<timer>/ontimerre-entry. - Deterministic traps on overflow/underflow.
- Panic containment at public entrypoints
- Public entrypoints most exposed to untrusted deck/script content
(
loadDeckContext,render,handleKey,navigateToCard,navigateBack,advanceTimeMs, focused-edit session methods, and theexecuteScriptRef*/invokeScriptRef*family) run against an isolated engine candidate and commit it only after the boundary returns. A panic therefore cannot expose partially-mutated live engine state. - Native targets use
std::panic::catch_unwind. Stablewasm32-unknown-unknowncannot unwind, so the WASM implementation invokes the candidate operation through a JavaScript re-entry boundary and converts its WebAssembly trap into the same deterministicengine: internal panic containederror. The wasm-target test suite deliberately panics inside this boundary, verifies the typed error and state rollback, and then reuses the engine instance. - Platform limitation: allocating/cloning the isolated candidate and creating
the JavaScript callback happen before the trap boundary. An allocation
failure while establishing that boundary still aborts on
wasm32-unknown-unknown; bounded deck, script, and variable state remains the primary protection for that class of failure.
- Runtime-owned side effects only
WMLBrowsermutations happen only in engine runtime state.- Host never decides script semantics.
- Navigation/refresh effects apply only at post-invocation boundary.
- Host binding hard limits
- Variable name validation + max name length.
- Variable value max length.
go()href max length.- Unknown host functions trap deterministically.
- No capability escalation
- No network fetch in WaveScript VM/runtime core.
- No filesystem access from script runtime.
- No WBXML parsing in script/host adapter layers.
Test Expectations
Required recurring checks:
- Decode/VM trap matrix
- empty unit
- unsupported opcode
- truncated immediates
- execution limit exceeded
- Host binding safety matrix
- invalid variable name rejected
- oversized variable value rejected without mutation
- unknown host function traps
- Invocation boundary matrix
executeScriptRef*is raw (no deferred nav apply)invokeScriptRef*applies deferred nav/refresh at boundary- trap during invoke does not apply deferred navigation side effects
- Recursion/panic-containment matrix
- cyclic
onenterforward/onenterbackwarddeck navigation traps with a typed error (nav dispatch-depth guard) instead of overflowing the stack - deeply-nested-but-well-formed WML tag trees are rejected during parse (parse-tree depth budget), not only in the later semantic walkers
- native and wasm panic boundaries convert a deliberate panic into the same typed error, preserve the pre-call engine state, and leave the instance usable for subsequent operations
Operational Commands
Run from repo root:
make test-rust
Optional coverage gate:
make coverage-rust
# requires: cargo install cargo-llvm-cov
Optional pre-push coverage hook:
WAP_ENABLE_RUST_COVERAGE_HOOKS=1 \
WAP_RUST_COVERAGE_MIN=90 \
WAP_RUST_FUNCTION_COVERAGE_MIN=85 \
git push