Venus Lua API › Core
Environment & Sandbox
The sandboxed Lua subset scripts run in — which libraries and globals exist, and the per-script isolation model.
lua/venus_lua/src/sandbox.rs, lua/venus_lua/src/lua_state.rs
Environment & Sandbox
Scripts run on Lua 5.5, but not the stock interpreter. Before any script loads, the engine
opens a reduced set of standard libraries, removes several base-library globals, and hands every
script its own isolated global environment. Two goals: stop one script from corrupting another (or
the engine), and deny scripts the host filesystem / process / debug reach that game logic never
needs.
If a name from the Lua 5.5 manual is missing at runtime, this
page is why. The source is the authority: luaL_open_sandboxed_libs picks the library set,
sandbox_globals removes the base globals, and sandbox.rs builds the per-script environment.
Standard libraries
| Library | State | Notes |
|---|---|---|
base (_G) |
open (reduced) | Core functions — but several are removed; see below. |
coroutine |
open | Full. |
math |
open | Full. |
string |
open | Full. Its shared type-metatable is sealed against cross-script poisoning. |
table |
open | Full. |
utf8 |
open | Full. |
package |
excluded | No module loading — the engine loads scripts, not require. |
io |
excluded | No file or stream access. |
os |
excluded | No process, environment, clock, or os.exit. Use Time for game time. |
debug |
excluded | No introspection / upvalue reach — that is a sandbox escape route. |
Base-library globals removed
Even inside the base library, these are set to nil (or replaced) by sandbox_globals:
| Global | State | Why |
|---|---|---|
load, loadfile, dofile |
removed | No loading arbitrary chunks at runtime. |
require |
removed | No module system (package isn’t opened). |
collectgarbage |
removed | Scripts don’t drive the shared GC. |
getmetatable |
removed | Closes the entire metatable-introspection poisoning class — scripts have no way to obtain a metatable reference. |
rawget, rawset |
removed | No raw bypass of the environment’s protective metatable. |
_G |
resolves to nil |
The global table itself is unreachable, so there is no back door to shared globals. |
print |
replaced | Routed to the engine log / console (prefixed [Lua]), honoring __tostring — not raw stdout. |
setmetatable stays — script-level OOP (setmetatable(obj, Class)) needs it. It is blocked only
on protected targets (a script’s _ENV, engine userdata, the string type), which raise cannot
change a protected metatable.
Surviving base globals (the complete set): assert, error, pcall, xpcall, type, tostring,
tonumber, pairs, ipairs, next, select, rawequal, rawlen, setmetatable, warn,
_VERSION. (warn writes to stderr, unlike the engine-routed print.)
Per-script globals (_ENV)
Each loaded script chunk gets a fresh environment table as its _ENV. That table is the
isolation boundary:
- Assignments to “globals” are private.
foo = 1at script scope writes into that script’s
env table; another script readingfooseesnil. (This is distinct from file-scopelocals,
which are shared across instances of the same script — see
Scripting Model.) - Reads fall through to a frozen snapshot. The env’s metatable
__indexpoints at a snapshot of
the engine globals, taken once at init after every binding is installed — so the type tables
(GameObject,Vector3,Input, …) and the surviving stdlib are visible, read-through. - Poisoning a shared table doesn’t leak. Each script gets its own shallow copy of every library
and binding table, sostring.format = evil(orInput.Key = evil) lands in the offending
script’s copy, never the shared snapshot. Later scripts are unaffected. - Host globals set after init are invisible. The snapshot is frozen; a host-side global assigned
after that point does not appear to sandboxed scripts.
The one shared channel: GameState
A single global table, GameState, is intentionally shared across all scripts — it is excluded
from the per-script copy, so writes to GameState.<field> are visible everywhere. It is the
sanctioned way to hand objects between scripts; use script-local or
self state for everything else.
Populate fields on it; never reassign GameState itself. GameState = {...} writes the name
into the assigning script’s private env (per the rules above) and is invisible to everyone else —
only GameState.field = ... mutates the shared table. And guard your reads: a consumer script
may run before the producer has set a field, so the shipped idiom is GameState and GameState.x.
-- Producer — the player script publishes itself:
if GameState then GameState.player_go = self.object end
-- Consumer — an orbit camera, guarded because it may load first:
local player = GameState and GameState.player_go
if player then
-- follow player
end
The shipped scripts use exactly this: the session-setup script publishes camera_go and
editor_picker, the two player scripts publish player_go, and the camera / editor-pick / UI
scripts read them with the guarded idiom above.