The Standard Library
Three concepts: capability sets, what the safe default contains, and what it deliberately does not.
The interesting part of a standard library, for an embedded runtime, is the absences. Anything you ship by default is something every host grants to every script forever, and removing it later is a breaking change.
Concept 1: Capability Sets
1. Concept
A capability is a permission to do something the runtime cannot do by itself: read a file, get the time, generate a random number, print. Ember's library is partitioned into sets, and a host grants sets explicitly.
#![allow(unused)] fn main() { pub struct Capabilities(u32); impl Capabilities { pub const CORE: Capabilities; // type, tostring, tonumber, assert, error, pcall, select, // ipairs, pairs, raw*, setmetatable/getmetatable pub const MATH: Capabilities; // math.*, EXCLUDING math.random (see below) pub const STRING:Capabilities; // string.* pub const TABLE: Capabilities; // table.* pub const PRINT: Capabilities; // print — writes to a host-supplied sink pub const SAFE: Capabilities; // CORE | MATH | STRING | TABLE // Granted only by explicit request, never in SAFE: pub const CLOCK: Capabilities; // os.time, os.clock pub const RANDOM:Capabilities; // math.random, math.randomseed pub const IO: Capabilities; // there is no io. See Concept 3. } }
2. Problem
The classic Lua sandbox is "load everything, then delete the dangerous globals". It has been
escaped repeatedly, because reachability is transitive: string.dump leaks bytecode, load
compiles new code, debug.getupvalue reaches into closures, and os.exit was never in the list
someone wrote down.
3. Mental model
Nothing is available unless it was registered. A capability set is a list of registrations, not a list of deletions. The difference is that a forgotten deletion is a hole and a forgotten registration is a
nil.
That is the whole argument for building the library this way, and it is an instance of the general "no ambient authority" principle from capability-security literature.
4. Implementation
#![allow(unused)] fn main() { pub fn install(engine: &mut Engine, caps: Capabilities) -> Result<()> { base::install(engine)?; // CORE is unconditional if caps.has(Capabilities::MATH) { math::install(engine)?; } if caps.has(Capabilities::STRING) { string::install(engine)?; } if caps.has(Capabilities::TABLE) { table::install(engine)?; } if caps.has(Capabilities::PRINT) { engine.register_function("print", print_to_sink)?; } if caps.has(Capabilities::CLOCK) { clock::install(engine)?; } if caps.has(Capabilities::RANDOM) { random::install(engine)?; } Ok(()) } }
Capabilities::SAFE is the default in Engine::builder(). A host that wants a clock says so:
#![allow(unused)] fn main() { Engine::builder().capabilities(Capabilities::SAFE | Capabilities::CLOCK).build() }
5. Alternatives
| Option | How the surface is controlled | Weakness |
|---|---|---|
| A. Explicit registration (ours) | nothing exists unless installed | Requires building the library this way from the start |
| B. Load-then-delete | _G.os = nil | Transitive reachability; misses what you forgot |
C. A restricted _ENV | give the chunk a table containing only what you allow | Genuinely good, and Lua's real answer. Needs _ENV (which Ember omitted); A achieves the same end |
| D. Per-function permission checks | each function checks a context flag | Every function must remember; a forgotten check is a hole |
6. Decision
A, with the enumerated capability-surface golden test as the review mechanism.
C is what a Lua host does and it is a fine design — Ember's A gets the same property one layer down,
without needing _ENV, and with the extra benefit that a capability the host never granted has no
code in the binary at all.
7. Tradeoffs
| We gain | We lose |
|---|---|
A forgotten registration is a nil, not a hole | Every library function must be written for this structure |
| The granted surface is enumerable and diffable | A host that wants "everything Lua has" cannot have it |
| Unused capabilities are not compiled in | Slightly more ceremony in the builder |
8. Production concerns
SAFEmust actually be safe, and that is a claim with a test. Thedefault_globals_contain_no_ambient_authoritytest enumerates every global in aSAFEengine and compares against a golden list. It fails when someone adds something.- Capabilities are per-engine. Two tenants means two engines (the host boundary).
- A capability that is granted cannot be revoked mid-run. A script can stash
os.timein a local before the host would like to take it away. Grant for the whole evaluation, or not at all.
9. References
- Lua 5.4's
loadwith anenvargument, and thelua-userssandboxing page (plus its many corrections). rhai'sEngine::disable_symboland its package system — option A in a Rust scripting language.- The E language and Capsicum, for capability security as a discipline rather than a feature.
Concept 2: What SAFE Contains
4. Implementation
The full default surface, and every entry earns its place:
| Function | Notes |
|---|---|
type, tostring, tonumber | tostring honors __tostring; tonumber accepts an optional base |
assert, error | error(msg, level) — the level adjusts which frame the position comes from |
pcall, xpcall | Turn an error into a value. ErrorKind::Limit is NOT catchable — see below |
select, ipairs, pairs, next | select('#') is the vararg count |
rawget, rawset, rawequal, rawlen | The metamethod escape hatches |
setmetatable, getmetatable | No __metatable protection (and why) |
collectgarbage("count" | "collect") | Observability, and a script may not disable the collector |
math.* | floor, ceil, abs, sqrt, min, max, fmod, modf, huge, pi, maxinteger, mininteger, type, tointeger |
string.* | len, sub, upper, lower, rep, byte, char, find, match, gmatch, gsub, format |
table.* | insert, remove, concat, sort, unpack, pack |
Four of those need a note, because each is a decision:
pcalldoes not catchErrorKind::Limit. If a script could catch "instruction budget exhausted", it couldwhile true do pcall(function() while true do end end) endand the budget would never terminate anything. Limits unwind pastpcalland out ofexecute. This is the single most important sandboxing detail in the library and it is easy to get wrong by implementingpcallas "catch everyErr".math.randomis NOT inSAFE. It is nondeterministic, which Ember has promised not to be. Hosts that want it grantRANDOMand supply a seed; the capstone seeds per request from the request id, so an A/B experiment is reproducible.table.sortmust be deterministic and must not trust the comparator. A comparator that is not a strict weak ordering can make a naive quicksort read out of bounds — a real CVE class in several languages. Ember's sort validates or uses an algorithm that cannot escape its bounds regardless of the comparator, and errors on an inconsistent one.string.format("%d", …)and friends are a parser over a script-controlled format string. Bound the output, reject unknown specifiers, and never pass the string to a C-style formatter.
8. Production concerns
string.rep,table.concat, andstring.formatmust check the memory budget before allocating, not after.("x"):rep(1e9)is a one-line memory bomb.string.find/gsubwith patterns are a CPU vector. Lua patterns are not regexes (no alternation, no unbounded backtracking in the regex sense), which helps — butstring.rep("a", 1e6):match("(a*)*b")-shaped inputs still burn time. Ember charges pattern matching against the instruction budget in proportion to work done, which is the only mechanism that generalizes.printwrites to a host-supplied sink, never tostdoutdirectly. A service wants it in the log pipeline with a request id, and a test wants it captured.
Concept 3: What SAFE Deliberately Omits
1–3. Concept, problem, mental model
Every omission is a decision with a reason. A list of absences without reasons is an oversight; with reasons, it is a design.
4. The list
| Absent | Lua has it | Why Ember does not |
|---|---|---|
io.* | yes | Filesystem access. A host that wants it registers exactly the operations it wants, scoped to exactly the paths it chooses. There is no safe general io. |
os.execute, os.remove, os.rename, os.tmpname, os.exit | yes | Process and filesystem control. os.exit alone can terminate the host. |
os.getenv | yes | Environment variables routinely hold credentials. |
os.time, os.clock, os.date | yes | Nondeterministic. Available under CLOCK, and hosts should prefer injecting a fixed timestamp. |
math.random | yes | Nondeterministic. Available under RANDOM, seeded by the host. |
load, loadstring, dofile, loadfile | yes | Compiling new code at run time defeats static review of the policy, and loadfile is filesystem access. require (Lab 22) is the controlled alternative. |
string.dump | yes | Emits bytecode. A capability nobody in this profile needs. |
debug.* | yes | Defeats every sandbox. debug.getupvalue/setupvalue reach inside closures; debug.getlocal reads frames; debug.setmetatable bypasses protection. If debug is present, nothing else in this table matters. |
coroutine.* | yes | Not implemented (capstone project 4), not a security decision. |
utf8.* | yes | Not implemented; a Lab 16 challenge. |
package.*, require from the filesystem | yes | Replaced by a host-supplied resolver (ADR-010). |
The debug row is the one to internalize. Every published Lua sandbox escape that is not a
runtime bug goes through debug, load, or string.dump. A sandbox that removes os and keeps
debug has removed nothing.
5–8. Alternatives, decision, production concerns
Decision: omit all of the above from the default build, and let hosts register narrow, purpose-built replacements.
The pattern for a host that needs one of these:
#![allow(unused)] fn main() { // NOT: grant `io`. // INSTEAD: grant exactly the operation, over exactly the data, with the host's own checks. engine.register_function("load_policy_fragment", move |ctx, args| { let name: String = arg(ctx, args, 0, "load_policy_fragment")?; let src = registry.get(&name) // an in-memory allowlist, not a path .ok_or_else(|| ctx.error(format!("unknown fragment '{name}'")))?; // ... })?; }
A narrow capability is a function, not a library. That is the design guidance to give hosts,
and it belongs in docs/embedding.md.
Production concerns:
- Document the omissions where a Lua programmer will look:
appendix/lua-differences.md, the crate docs, and the README. A script author who writesos.time()getsattempt to call a nil value (field 'time'), which is correct and unhelpful. Consider a friendlier error for known-absent names — a small table of "this exists in Lua and is not in Ember because …". - Do not add "just for debugging". A capability added for a debug build ships. If you need
printin tests, that is what thePRINTcapability and a captured sink are for.
9. References
- Lua 5.4 Reference Manual §6, read specifically for what each library can reach.
- The history of Lua sandbox escapes on the
lua-userswiki and in game-modding communities — the best available evidence for thedebugrow. rhai's "safety" documentation, which enumerates its limits and its non-goals in a way worth imitating.
Things to Notice
- A forgotten registration is a
nil; a forgotten deletion is a hole. That asymmetry is the entire argument for building the library as explicit grants. pcallmust not catch limit errors. One line, and the sandbox depends on it.math.randomis excluded for determinism, not security — a different reason fromos.execute, and mixing the two up makes the list look arbitrary.debugalone defeats every other control. If you remember one row, remember that one.- A narrow capability is a function, not a library. Give hosts that guidance explicitly.
table.sortmust survive a hostile comparator. This is a real CVE class, not a hypothetical.- Every absence has a reason, and the reasons differ. That is what makes it a design.
Validation / Self-check
- Why is "register what you allow" structurally safer than "delete what you forbid"?
- What is in
Capabilities::SAFE, and what are the four entries that needed a decision? - Why must
pcallnot catchErrorKind::Limit? Give the script that exploits it otherwise. - Why is
math.randomexcluded, and how does that reason differ fromos.execute's? - Name three functions that must check the memory budget before allocating.
- Why does
debugdefeat every sandbox built on metatables and global deletion? - What is the recommended shape for a host that needs filesystem access? Why a function rather than a library?
- Why does
printwrite to a host-supplied sink? - Why must
table.sortbe robust against an inconsistent comparator?
Next: Modules and Resolution.