stanchion GitHub

Isolation

Whether plugins share one Lua state or get one each, and what a sandbox policy can bound.

Three modes, chosen at construction.

// Shared: every plugin runs in the state you hand over.
let registry = Registry::new(Lua::new());

let policy = Sandbox::restricted()
    .memory_limit(16 * 1024 * 1024)
    .instruction_limit(5_000_000);

// Per-plugin: every plugin gets its own state under a policy.
let registry = Registry::isolated(Lua::new(), policy.clone());

// Per-group: plugins wired together by `[dependencies]` share a state.
let registry = Registry::grouped(Lua::new(), policy);
SharedPer-groupPer-plugin
Memory / instruction limitsimpossibleper groupper plugin
Standard library selectionone policy, set by youper registry, via Sandboxper registry, via Sandbox
[dependencies] exportsinjectedinjected within a groupunavailable — values cannot cross states
Blast radius of a bad pluginshared globals and allocatorits group’s globals and allocatorcontained to its own state
Costone stateone state per groupone state per plugin

Under shared isolation each chunk is evaluated with its own environment table whose __index is the real globals: a plugin reads globals normally but its writes stay local, so one plugin cannot redefine string.format for the others. That is namespace hygiene, not a security boundary — rawset(_G, ...) still reaches the shared state, and nothing bounds CPU or memory.

Under per-plugin isolation the boundary is real. Memory and instruction limits are properties of a Lua, which is exactly why they cannot be applied inside a shared state. The price is that Lua values cannot cross states, so a [dependencies] entry that would inject exports fails with CrossStateDependency (see dependency chains).

Per-group: limits without giving up dependencies

Registry::grouped pays that price only where it is unavoidable. A Lua value cannot cross states, so the registry puts the plugins that need to exchange values in the same one: each connected component of the dependency graph gets a state, and a plugin depending on nothing gets one to itself.

base ←── middle ←── top        island
└──────── one state ──────┘    └─ own ─┘

Connectivity is undirected, so a diamond — left and right both on shared, top on both — is one group rather than two that later discover they must merge. The partition is computed over the whole graph before any state is built.

Plugin::group() reports which state a plugin ended up in; two plugins reporting the same Group share one. The group is the accounting unit: members share a heap, a memory limit and one instruction budget, so Budget::used reads the same for all of them.

What this does not do is make a dependency free. Group members share globals and an allocator, exactly as plugins do under shared isolation — a runaway loop in one exhausts the group’s allowance for all of them. Declaring a dependency is declaring that you accept that. What you get is that the blast radius is the group rather than the process, and that groups are as separated from each other as plugins are under per-plugin isolation.

Each plugin’s directory is prepended to its state’s package.path, so a plugin can require its own files without knowing where it was installed. That require is per-plugin: submodules load into the same environment as the plugin’s own chunk, so they see its granted capabilities, and each plugin gets its own module cache rather than sharing package.loaded.

Sandbox policy

Sandbox::restricted() is the default: string, table, math, coroutine and package — no io, os or debug — with dofile, loadfile and package.loadlib removed on top. package is loaded so require works for rocks and plugin-local modules; loadlib is removed because it would load any shared object on disk.

Sandbox::permissive() adds io and os and drops the deny list. Use it for first-party plugins, not for code you did not write.

Note that mlua’s StdLib::ALL_SAFE means memory-safe, not sandboxed — it still includes io and os. Neither preset is built on it.

Sandbox::restricted()
    .libs(StdLib::STRING | StdLib::TABLE)   // exactly these
    .deny(["os.execute", "os.exit"])        // replace the deny list
    .memory_limit(16 * 1024 * 1024)         // bytes, VM-enforced
    .instruction_limit(5_000_000)           // per call, not per lifetime

The instruction limit resets at every call boundary — each dispatch and each constructor gets the full allowance — so a plugin is bounded per call rather than slowly starving over its lifetime. It rides on Lua’s debug hook, so under luau (which has no instruction counter) the limit counts interrupt callbacks instead.

Reaching an isolated plugin

A freshly created state has nothing of yours in it. Host functions get there through capabilities, declared in with_setup and granted per plugin by policy — so what a plugin can reach stays a decision rather than a side effect of being loaded.


← Documentation index