Powered by AppSignal & Oban Pro

Sandboxing

guides/examples/sandboxing.livemd

Sandboxing

Mix.install([
  {:lua, "~> 1.0.0-rc.0"}
])

The default sandbox

Lua.new/0 sandboxes a curated list of functions (os.execute, os.exit, os.getenv, require, loadfile, …). Calling a sandboxed function raises, which pcall turns into a false, message pair so the script can recover in-band.

{[ok?, message], _lua} =
  Lua.eval!(Lua.new(), ~S[return pcall(function() return os.getenv("HOME") end)])

{ok?, message}
{false, "Lua runtime error: os.getenv(_) is sandboxed"}

Allowing specific operations

Lua.new(exclude: [...]) lifts the sandbox for specific paths while keeping everything else locked down. Here we allow os.getenv only.

allowed = Lua.new(exclude: [[:os, :getenv]])

{[kind], _lua} = Lua.eval!(allowed, ~S[return type(os.getenv("HOME"))])
kind
"string"

os.execute is still blocked on the allowed VM — we only lifted getenv.

{[exec_ok?, _message], _lua} =
  Lua.eval!(allowed, ~S[return pcall(function() return os.execute("echo hi") end)])

exec_ok?
false

Replacing or disabling the sandbox

Lua.new(sandboxed: [...]) replaces the whole sandbox list, and Lua.new(sandboxed: []) disables sandboxing entirely. Reach for these only when you fully trust the script you are running.

Bounding CPU work

Sandboxing controls which functions a script may call, but it does not stop a script from spinning forever (while true do end) or recursing without bound. Two options give you deterministic limits without wrapping each evaluation in a host Task plus a wall-clock timeout.

Call depth

Lua.new(max_call_depth: n) caps the depth of nested function calls. Recursing past the cap raises a catchable "stack overflow" runtime error instead of letting the recursion exhaust the host process. The default is :infinity (no limit).

Instruction budget

Lua.new(max_instructions: n) caps the number of VM instructions a single evaluation may execute. When a script exceeds the budget it raises a catchable "instruction budget exceeded" runtime error — so a runaway loop terminates deterministically inside the VM:

{[ok?, message], _lua} =
  Lua.eval!(Lua.new(max_instructions: 1000), ~S[return pcall(function() while true do end end)])

{ok?, message}
{false, "instruction budget exceeded"}

The budget is enforced at loop back-edges and call boundaries, so the default :infinity carries no per-instruction cost, and it applies to both the interpreter and the compiled-dispatcher execution paths. Each top-level evaluation gets a fresh budget, and because the error is an ordinary runtime error, pcall recovers from it in-band like any other.