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
- Bytecode decode gate runs before VM execute.
- Empty/oversized units fail deterministically.
- Bounded execution
- Instruction step limit.
- Operand stack limit.
- 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
- The engine crate does not set
[profile.release] panic = "abort", so the public entrypoints most exposed to untrusted deck/script content (loadDeckContext,render,handleKey,navigateToCard,navigateBack,advanceTimeMs, focused-edit session methods, and theexecuteScriptRef*/invokeScriptRef*family) run understd::panic::catch_unwindand convert an unwinding panic into the engine’s existing typedResult<_, String>(or, for the two methods with noResultin their public signature, a trace entry plus the existing “no-op” return value/outcome shape). This exists as a last-resort net so a future defensive-programming bug degrades to a recoverable error instead of trapping the whole WASM instance uncatchably from JS.
- 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
catch_engine_panicconverts an unwinding panic into the engine’s typed error path
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