-
Notifications
You must be signed in to change notification settings - Fork 0
Lua Sandbox Internals
Audience: Engine Developer Status: ✅ Ready
Content authors write Lua, and content is untrusted at scale — a buggy or hostile script must never crash the world, stall a zone, leak the host, or escalate a player. The sandbox is what makes running arbitrary builder Lua safe: one VM per zone, a stdlib stripped and rebuilt from an allowlist, a single guarded chokepoint with instruction and wall-clock budgets, and a per-script circuit breaker that quarantines a bad script without touching the zone. This page is the engine-internals view; the author-facing API and hook catalog are on Pack Lua Scripting and Pack Lua Hooks.
Related: Zone Runtime & Actor Model, Abilities & Effects, Content Loading & Hot Reload.
The runtime is a fork of gopher-lua — github.com/double-nibble/gopher-lua, pinned via a go.mod replace — that adds a per-call instruction-count abort. Each zone owns one *lua.LState, called only from that zone's goroutine. gopher-lua is not goroutine-safe, and nothing locks it because nothing else touches it — the single-writer model is what makes that sound.
Lua is compiled lazily, once per zone, on first use and cached (keyed by a stable content key like ability:fireball:on_resolve), never at load. A compile error keeps the def inert — no-Lua content still boots.
The sandbox is a shared package, used by two hosts. The allowlist, the amplifier caps, the single chokepoint, and the circuit breaker live in one host-agnostic core (
internal/luasandbox). The zone VM aliases its cap constants and shares that core; the world-director script runs a second VM built from the same core (with adirectorhost table instead ofmud/self). A build-failing parity test cross-checks the two live sandboxes — both their behavior and their global member key-sets — so the two can never drift, and a "sole chokepoint" lint enforces the same SetContext-or-no-budget invariant on the new host. Extracting the core deliberately left the world's proven capped-builder mechanism untouched, to bound the blast radius.
The VM is created with SkipOpenLibs: true — no stdlib at all. Base/string/table/math are opened onto a scratch table only to harvest genuine closures, then the globals are wiped and repopulated from an allowlist. This is deletion-proof: an unsafe capability is absent, never merely hidden.
-
Kept base functions:
assert, error, pcall, xpcall, select, type, tostring, tonumber, pairs, ipairs, unpack.printis redirected to structured logging. -
Kept namespaces:
string,table,math— each a read-only proxy (a write raises "attempt to modify a read-only table"). -
Dropped entirely (never registered):
os,io,debug,require,load/loadstring/dofile,coroutine,getmetatable/setmetatable,rawget/rawset,_G/_ENV,string.dump. There is no environment self-reference to reach through. -
RNG:
math.randomis rebound to the per-zone seeded RNG (so scripts are reproducible under a fixed seed);math.randomseedis a no-op. -
Amplifier caps:
string.rep/format/gsub/find/match/gmatchandtable.concatare wrapped to reject over-cap output before allocating (1 MiB output cap, 64 KiB pattern-input cap) — closing the single-op allocation-bomb class.
Every Lua run goes through one function, pcallGuarded, which arms a fresh wall-clock deadline, resets the instruction count and spawn budget, PCalls, and clears the context. A build-failing lint forbids any raw PCall/Call/DoString elsewhere. Budgets:
| Bound | Value | Catches |
|---|---|---|
| Instruction budget | 100,000 (default; operator-tunable) | a runaway loop → "instruction budget exceeded" |
| Wall-clock deadline | 5 ms (default; operator-tunable) | a low-instruction stall (e.g. a GC pause) the count can't |
| Call-stack cap | 200 | runaway recursion |
mud.spawn census |
64/call, 1024/zone | spawn floods |
mud.after live timers |
256 | timer floods |
| Builder log output | ~1 KB/line, a per-call line budget, 50 lines/s sustained | log-flood / disk-fill DoS (below) |
Nesting (a harm op firing an event whose handler is Lua) reuses the parent's context and budget — it does not reset the instruction tally, so a script can't re-nest to escape its budget.
Every invocation is pcall-isolated: a runtime error fizzles just that action; the raw error/stack goes to ops logs only (never to a player, though staff watching with debug on see it echoed). Fail-closed defaults by return type: a broken formula → engine default; a broken pvp_allowed → deny; a broken display template → built-in fallback; a broken loot hatch → no drops.
A per-script weighted error budget (threshold 10.0): a logic error costs 1.0, an instruction-budget abort 0.5, a wall-clock deadline just 0.1 (so host load can't quarantine a correct script, and an attacker can't drive a victim's breaker by inducing latency). A success decays the budget, so only a sustained failure rate trips. On trip the script is disabled — its invocations no-op — never the zone.
Scope matters: entity-scoped scripts (triggers) key per-instance, so one buggy mob is quarantined without taking down its prototype; shared defs (ability/affect/formula/policy) key per-(kind, ref), so a broken shared def stops content-wide, by design. The breaker resets on a successful hot reload.
The instruction budget and the wall-clock deadline are settable per deployment (tunables.lua_instr_budget / tunables.lua_call_deadline_ms, or TELOS_LUA_*), defaulting to the compiled-in values so an untouched deployment is byte-identical. luasandbox stays host-agnostic: values are injected through Opts, never read from config inside the package.
They are not two independent knobs. Measured against the pinned fork on the production path, the default 5 ms deadline means the instruction budget stops firing at roughly 850k instructions. Past that the wall clock always wins — so an operator who sets a 10M budget has not raised the primary bound, they have disabled it.
That is worse than a knob that does nothing, because of what it does to the circuit breaker. An instruction abort is weighted 0.5 (pathological, deterministic — quarantine it); a wall-clock abort is weighted 0.1 (probably transient host load, deliberately light so an attacker can't trip a victim's breaker by inducing load). Reclassify every runaway as the latter and a script failing four times in five goes from tripping the breaker to never tripping it. So ValidateCaps rejects a budget that cannot be reached within the deadline, and tells the operator the deadline they would need.
Three structural details:
-
Validation lives at the injection point (
world.SetLuaCaps/director.SetLuaCapsboth return an error), not in the config package. Importingluasandboxfrom config to reach two constants would link a Lua interpreter intotelos-gate,telos-migrate,telos-pullandtelos-seed— none of which ever build a VM. Config stays a leaf, and a host cannot apply the caps without validating them. -
A malformed
TELOS_*value refuses the boot rather than warning and running the default:Atoiyields 0 and 0 means default, so the operator would otherwise believe a setting had taken effect when it had not. -
Newclamps the pair structurally. Enforcing the invariant only at the twoSetLuaCapssites leftluasandbox.New(Opts{InstrBudget: MaxInstrBudget})with a default deadline silently accepted — through the very API the package advertises as safe.Newnow lowers an unreachable budget to what the deadline can execute, so the budget always remains the guard that fires.
The rate constant was wrong in the unsafe direction. InstrPerMS was 100,000, with a comment claiming it was deliberately conservative because measured throughput was "several times this." Measured on the real production path (budget armed and a deadline context, so the per-instruction ctx.Done() select is in the loop): ~90k instr/ms for a tight arithmetic loop and ~37k/ms for anything allocating tables. Real throughput is at or below the old constant, not above it — and over-estimating the rate makes ValidateCaps demand too little deadline, so a mis-paired configuration passes and the wall clock still wins. It is now 20,000, below even the table-allocating figure and below what slower ARM nodes achieve.
A deadline may not reach a pulse. The package's MaxCallDeadlineMS ceiling is a full second — four zone pulses — so it now refuses a deadline at or above pulseInterval: a call that outlives a heartbeat stops combat rounds and affect ticks landing for every player in that zone.
Raising the budget still costs memory. A single call at the ceiling allocates on the order of hundreds of MB in a table-building loop, so raising it trades a bounded stall for heavier heap pressure. This is stated at
MaxInstrBudgetand in the shipped config example — which ships both values commented out, since setting them pins them and an explicit deadline defeats the automatic-racescaling the same block advertises.
Memory was long the one uncapped dimension, and closing it corrected the premise it was filed under. Two obvious remedies do not work:
- No instruction budget can bound memory. Bytes-per-instruction spans five orders of magnitude across programs a script can legally write — 25 instructions can allocate 63 MB — so deriving a memory ceiling from the instruction budget is not conservative, it is impossible.
- Sampling the process heap cannot work either. The noise floor from other goroutines is tens of MB per 5 ms window, so any threshold tight enough to bound this call fires on a neighbour's traffic — letting a player quarantine content they don't own by inducing load, reopening the exact hole the deadline's 0.1 weight exists to close.
So the bound is a per-call allocation budget, charged where the bytes are actually requested. The dominant vector turned out not to be table growth (genuinely ~140 B/instr) but concatenation — a VM opcode, and therefore the one script-reachable path that allocates unboundedly in a single instruction. Every string builtin was already capped; an opcode has no wrapper. At the shipping defaults, doubling a 1 MiB seed with s = s .. s allocates 64 GB. The charge lives in the pinned gopher-lua fork at stringConcat, before the join — the join is one uninterruptible allocation, so measuring after it is measuring the harm — and the engine's own capped wrappers charge the same per-call counter, since a per-operation cap stops one operation being a bomb but says nothing about ten thousand legal ones.
It also fixed a live misclassification, arguably worth more than the cap itself: a memory bomb allocates for the whole deadline and then trips it, so the most dangerous thing a script could do landed in AbortDeadline — weight 0.1, the "probably transient host load, don't punish the script" bucket. It is neither transient nor the host's fault. A new AbortAlloc is weighted alongside AbortBudget in both breakers. (Neither weight switch has a default arm, so an unwired abort kind costs zero — which is exactly how a mutation deleting AbortAlloc from the zone breaker left the whole suite green.)
"Doesn't amplify" is not "doesn't allocate."
string.lower/upper/reverse/charwere passthroughs justified as non-amplifying. True, and irrelevant: each allocates a whole new string per call, so a loop over a legally-built 1 MiB string reached 2.2 GB in one call while charging 1 MiB — and, aborting on the wall clock, landed in the very 0.1 bucket this work exists to move memory bombs out of.string.formatvalidated a field width against the per-op cap but never charged it, so"%1000000d"allocated a megabyte from an eleven-byte format with no arguments: a ~100,000× undercharge. Converselytable.concatwas charged twice — gopher-lua implements it on the concat opcode's helper, which the fork already charges — halving the effective budget for the very idiom the docs recommend instead of the quadratic accumulator.
The abort message names the remedy, because the idiom authors actually hit is the O(n²) accumulator: they are refused after building tens of KB and told about a cap measured in megabytes, with nothing connecting the two.
Three host functions — print, mud.log, and director.log — let semi-trusted builder Lua write arbitrary strings into the process log, and two of them default to Info, so under the ordinary production posture they already reach the log store. That was a latent write primitive with no length cap and no rate limit, and it becomes a live one now that container stdout ships into Loki: unbounded, it is a disk-fill / ingest-flood DoS against the whole node (the game shares it), and it lets a script bury real signal during an incident. Three defences, shared through internal/luasandbox so the zone and director tiers behave identically:
-
Length cap — every builder message is clamped to ~1 KB (
CapLogMsg, rune-boundary safe), so one call can't emit a megabyte. -
Per-call rate limit — a line budget reset at the same chokepoint as the instruction and spawn budgets, so nested calls share it and a script can't re-nest to reset its tally. Going over raises a flood error that unwinds through the
pcalland is fed to the circuit breaker as a logic-weight abort — so a script flooding every call is quarantined, not merely throttled. The counter is reset per frame (saved and restored across nesting), not per cascade, so a flood is charged to the flooding script rather than to a co-firing victim whose frame happened to run after the cap was hit. -
Per-runtime wall-clock token bucket — the per-call cap bounds one frame, not the call rate: a self-rescheduling
mud.aftertimer can emit a per-call burst every tick forever (~200 MB/s/zone). A token bucket (burst = the per-call cap, refill 50 lines/s) drops lines past the sustained rate regardless of how many calls, timers, or nested frames produce them; drops surface on the per-zonebuilder_logs_droppedmetric. This is the bound nesting cannot reset, which is why the per-frame reset above doesn't reopen the disk-fill vector.
Every builder log also carries source=builder_lua, so ops can route content output to short retention independently of engine logs. The director shares the same per-call budget as its own print via an exported hook — its single-process blast radius is wider than a per-zone VM, so bounding it matters as much.
Two bypasses the review closed, both the same class through a different door. A builder controls the Lua error message via
error(msg), and every isolated-callback path logserr.Error()— soerror(string.rep("E", 8MB))streamed a multi-megabyte line on every call, evading both the length cap and the rate abort. Fixed at the source: the Lua error is run throughCapLogMsgbefore wrapping, so every downstreamerr.Error()log inherits the cap. The same held for compile errors — gopher-lua echoes the offending token verbatim, so two adjacent huge identifiers inflated a parser error to source size on every hot-reload recompile — now capped at the four compile-failure log sites too.
The 1 KB cap itself lives in a zero-dependency internal/logcap leaf, and CapLogMsg delegates to it — so the same bound covers the parallel exposure through the content-load channel, where the DTO parse/validation layer echoed builder-controlled field values (a 200 KB dice/formula field, or a reference-valued exit target) into build- and reload-time log lines. See Content Loading & Hot Reload.
self.state is a plain-data Lua table, one per entity instance, keyed by RuntimeID. It's transient for mobs/items (dropped when the entity leaves the world tree) but durably persisted for players (into the character's state JSONB, and carried across a handoff).
The marshaller is the trust boundary: only plain data crosses. A function, closure, userdata, or entity handle is rejected at save with a clean error naming the bad key path — never silently dropped, never a persisted pointer. Content stores h:id() and re-resolves, never a live handle. Caps (64 KiB, depth 16, 4096 keys) are mirrored on load, so a corrupted or hostile DB row degrades to empty rather than ballooning the VM; load reconstructs a plain table only, never executing code or resurrecting a handle. On a hot reload the code is swapped but the self.state data is preserved.
An entity reaches Lua only as a validated userdata handle carrying (rid, zone, zoneGen) — never an *Entity. Every method re-resolves the rid to a live entity in this zone; a dead, departed, cross-zone, or stale-generation handle is a safe no-op. __tostring returns <entity #rid>, never a pointer, and handle metatables are engine-owned, never exposed as globals. (The zoneGen check is reserved scaffolding today — see Zone Runtime & Actor Model.) The curated method/global surface those handles expose is documented on Pack Lua Scripting; the harm ops among them route the same gated funnel as everything else (Abilities & Effects).
There is no os/wall-clock time — mud.now is the deterministic pulse counter — and no real sleep: mud.after schedules on the zone timer wheel and runs its callback inline on the zone goroutine. So content can never spawn a goroutine, block the loop, or observe non-deterministic time.
TelosMUD — Wiki under construction.
- Builder Reference
- Builder Commands
- Trust Tier Model
- Pack Authoring
- Pack MUD Settings
- Pack Lua Scripting
- Pack Lua Hooks
- Pack Entity Reference
- Building Instanced Zones
- Engine Developer Reference
- Architecture Overview
- Entity Component Model
- Zone Runtime & Actor Model
- Instanced Zones
- Command Parser & Targeting
- Edge & Protocol
- GMCP Reference
- Persistence & Durability
- Content Loading & Hot Reload
- Abilities & Effects
- Combat System
- Loot, Spawns & Crafting
- Accounts & Auth Internals
- Orchestration & Directors
- Scoped Event Bus
- Cross-Shard Handoff
- Lua Sandbox Internals
- Distributed Systems Model
- RPC & Protobuf