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

OptionHow the surface is controlledWeakness
A. Explicit registration (ours)nothing exists unless installedRequires building the library this way from the start
B. Load-then-delete_G.os = nilTransitive reachability; misses what you forgot
C. A restricted _ENVgive the chunk a table containing only what you allowGenuinely good, and Lua's real answer. Needs _ENV (which Ember omitted); A achieves the same end
D. Per-function permission checkseach function checks a context flagEvery 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 gainWe lose
A forgotten registration is a nil, not a holeEvery library function must be written for this structure
The granted surface is enumerable and diffableA host that wants "everything Lua has" cannot have it
Unused capabilities are not compiled inSlightly more ceremony in the builder

8. Production concerns

  • SAFE must actually be safe, and that is a claim with a test. The default_globals_contain_no_ambient_authority test enumerates every global in a SAFE engine 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.time in 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 load with an env argument, and the lua-users sandboxing page (plus its many corrections).
  • rhai's Engine::disable_symbol and 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:

FunctionNotes
type, tostring, tonumbertostring honors __tostring; tonumber accepts an optional base
assert, errorerror(msg, level) — the level adjusts which frame the position comes from
pcall, xpcallTurn an error into a value. ErrorKind::Limit is NOT catchable — see below
select, ipairs, pairs, nextselect('#') is the vararg count
rawget, rawset, rawequal, rawlenThe metamethod escape hatches
setmetatable, getmetatableNo __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:

  1. pcall does not catch ErrorKind::Limit. If a script could catch "instruction budget exhausted", it could while true do pcall(function() while true do end end) end and the budget would never terminate anything. Limits unwind past pcall and out of execute. This is the single most important sandboxing detail in the library and it is easy to get wrong by implementing pcall as "catch every Err".
  2. math.random is NOT in SAFE. It is nondeterministic, which Ember has promised not to be. Hosts that want it grant RANDOM and supply a seed; the capstone seeds per request from the request id, so an A/B experiment is reproducible.
  3. table.sort must 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.
  4. 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, and string.format must check the memory budget before allocating, not after. ("x"):rep(1e9) is a one-line memory bomb.
  • string.find/gsub with patterns are a CPU vector. Lua patterns are not regexes (no alternation, no unbounded backtracking in the regex sense), which helps — but string.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.
  • print writes to a host-supplied sink, never to stdout directly. 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

AbsentLua has itWhy Ember does not
io.*yesFilesystem 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.exityesProcess and filesystem control. os.exit alone can terminate the host.
os.getenvyesEnvironment variables routinely hold credentials.
os.time, os.clock, os.dateyesNondeterministic. Available under CLOCK, and hosts should prefer injecting a fixed timestamp.
math.randomyesNondeterministic. Available under RANDOM, seeded by the host.
load, loadstring, dofile, loadfileyesCompiling new code at run time defeats static review of the policy, and loadfile is filesystem access. require (Lab 22) is the controlled alternative.
string.dumpyesEmits bytecode. A capability nobody in this profile needs.
debug.*yesDefeats 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.*yesNot implemented (capstone project 4), not a security decision.
utf8.*yesNot implemented; a Lab 16 challenge.
package.*, require from the filesystemyesReplaced 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 writes os.time() gets attempt 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 print in tests, that is what the PRINT capability 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-users wiki and in game-modding communities — the best available evidence for the debug row.
  • 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.
  • pcall must not catch limit errors. One line, and the sandbox depends on it.
  • math.random is excluded for determinism, not security — a different reason from os.execute, and mixing the two up makes the list look arbitrary.
  • debug alone 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.sort must 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

  1. Why is "register what you allow" structurally safer than "delete what you forbid"?
  2. What is in Capabilities::SAFE, and what are the four entries that needed a decision?
  3. Why must pcall not catch ErrorKind::Limit? Give the script that exploits it otherwise.
  4. Why is math.random excluded, and how does that reason differ from os.execute's?
  5. Name three functions that must check the memory budget before allocating.
  6. Why does debug defeat every sandbox built on metatables and global deletion?
  7. What is the recommended shape for a host that needs filesystem access? Why a function rather than a library?
  8. Why does print write to a host-supplied sink?
  9. Why must table.sort be robust against an inconsistent comparator?

Next: Modules and Resolution.