Lua sandbox model
asobi_lua runs every Lua script in a hardened Luerl state. Sandbox construction lives in asobi_lua_loader:new/1 and asobi_lua_loader:init_sandboxed/0.
Removed from the global environment
The following standard-library entries are cleared (= nil) so a hostile script cannot reach them:
- OS escape hatches:
os.execute,os.exit,os.getenv,os.remove,os.rename,os.tmpname - Code loading:
dofile,loadfile,load,loadstring - I/O: the entire
iolibrary - Package machinery: the entire
packagelibrary, plus the defaultrequire - Unstructured logging:
print,eprint— Luerl's defaults bypass the structured logger and write straight to BEAM stdout. Scripts that need to log should go through the asobi-sidegame.logAPI.
os.clock, os.date, os.difftime, and os.time remain available so games can timestamp.
Replaced
require/1is provided by asobi_lua. Names must match[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*— letters, digits, underscores, with.separating segments. Names like../foo,/etc/passwd,foo/bar,42, or''are rejected. The validator uses thedollar_endonlyregex flag sorequire("foo\n")does not slip through. The resolver joins the validated name to the directory of the loading script and reads the file withfile:read_file/1. Symlinks at the resolved path are rejected before reading.math.randomdispatches to Erlang'srand:uniform. Single-arg form returns an integer in[1, N]; no-arg form returns a float in[0, 1). The two-argmath.random(a, b)form upstream Lua exposes is not supported.math.sqrtdispatches to Erlang'smath:sqrt/1. Negative input returns0.0(upstream Lua returns NaN; Erlang would crash).
Per-callback wall-clock limits
Every Lua callback (init, tick, join, leave, handle_input, get_state, vote_requested, vote_resolved, generate_world, phases, spawn_templates, on_phase_started/ended, on_zone_loaded/unloaded, on_world_recovered, terrain_provider, spawn_position, post_tick, zone_tick, bot think) runs in a child process with a wall-clock budget. A runaway script (while true do end, deep recursion, huge allocation) is killed when its budget elapses; the parent gen_server logs a warning and continues with the previous state. Limits are tuned per callback — init/generate_world get more time, per-tick callbacks get less.
The same wall-clock wrapper is applied to the initial script body load (asobi_lua_loader:new/1), the hot-reload path, and the config manifest evaluator. A while true do end at the top of match.lua therefore can no longer hang application start or the match gen_server.
Cross-script isolation
Each match and each zone gets its own Luerl state. Globals, modules, and the require cache live inside that state — there is no shared table reachable from script code that crosses match boundaries.
Atom exhaustion
asobi_lua_api's safe_to_atom helper and terrain_provider decoding both use binary_to_existing_atom/1 so a Lua-supplied string cannot inflate the global atom table. Additionally, the terrain provider module name is matched against an explicit allowlist (asobi_terrain_flat, asobi_terrain_perlin by default; configurable via the asobi_lua, terrain_providers env) so a script cannot dispatch into arbitrary loaded modules even if the underlying atom already exists.
Decode depth cap
asobi_lua_api's deep-decode helper recurses on Lua-side tables; depth is capped at 64 levels and over-deep subtrees are replaced with the atom too_deep. A malicious script returning a 100k-deep table from a callback can no longer blow the parent process heap.