airsl
Embeddable Lua runtime with a host standard library, written in Rust on top of mlua.
airsl runs sandboxed Lua and gives those scripts the capabilities a shell or Python script would
otherwise reach for — JSON, filesystem access, subprocesses, real regular expressions — through host
modules implemented in Rust. Everything a script can do arrives under a single airsstack global,
so the host decides the surface rather than the Lua standard library deciding it.
use ;
let engine = builder.policy.build?;
let script = from_source?;
assert_eq!;
# Ok::
Building
Linux and macOS only. airsstack.proc resolves executables by unix mode bits, which have no
Windows equivalent, so the crate fails the build off unix with a message saying so rather than a
cascade of resolution errors out of std.
A C compiler is required. mlua's vendored feature compiles Lua 5.4 from the C sources
shipped by the lua-src crate and links it statically, so there is nothing to install and no
pkg-config involved — but cc must be present.
Lua 5.4 rather than 5.1 or LuaJIT: only 5.3 and later distinguish integers from floats in the VM.
On 5.1 a JSON 3 and a JSON 3.0 are the same value, which breaks byte-stable JSON output.
What scripts can see
| Module | Purpose | Needs a grant |
|---|---|---|
airsstack.json |
encode, encode_pretty, decode — keys always sort |
no |
airsstack.path |
join, split, normalise, relativise — pure string arithmetic | no |
airsstack.fs |
read, write, walk, stat, atomic and exclusive writes | read roots, write roots |
airsstack.env |
read and overlay environment variables | name allowlist |
airsstack.proc |
run a program from an argv array; no shell, ever | executable allowlist |
airsstack.regex |
real regular expressions, one-shot or compiled | no |
airsstack.hash |
SHA-256 and SHA-1, over strings or files | only hash_file |
airsstack.time |
timestamps, formatting and parsing, in UTC | no |
airsstack.glob |
glob matching, and walking a tree by pattern | only walk |
airsstack.stdio |
the process's own standard streams | no |
airsstack.hook |
the agent-hook payload and output contract | no |
airsstack.ext |
event registration (on) and capability introspection (granted) for a registered extension |
no |
Every module is installed under every preset. One the policy has granted nothing is present and refuses each call — the authority is in the grant, not in whether the table is there.
Policy
A policy answers three independent questions: which of Lua's own libraries a script sees, what the host modules it reaches may touch, and how much it may consume. Three presets cover the cases worth naming.
| Preset | Language surface | Grants | Ceilings |
|---|---|---|---|
Policy::trusted() |
everything except debug, including io, os, package |
unrestricted | none |
Policy::confined() (default) |
string, table, math, coroutine, pure os |
declared only | 64 MiB, 100M instructions |
Policy::pure() |
string, table, math |
declared only | 16 MiB, 10M instructions |
Below trusted, a script does not get io, debug, package, the chunk loaders (load,
loadstring, dofile, loadfile), or the os functions that reach outside the process
(execute, exit, getenv, remove, rename, tmpname, setlocale). pure additionally drops
os and coroutine entirely.
os.setlocale is withheld for a subtler reason than the rest: Lua compares strings with strcoll,
so a script that changes the locale changes the sort order of every subsequent table.sort.
Adjust any preset with a wither:
use ;
let policy = confined
.with_limits;
Nothing is granted by default below trusted. A host says what a script may reach:
let policy = confined.with_grants;
A grant is checked inside the Rust function before the operation it guards, never in Lua. fs
canonicalises the deepest existing part of a path and accepts only ordinary names below it, so a
symlink inside a granted root that points outside is caught.
Ceilings
The memory ceiling turns an allocation past the cap into a catchable error rather than an OOM that
takes the host process with it. The instruction ceiling is the only defence against a script that
never terminates; no capability decision helps against while true do end, because it reaches
nothing.
Both surface as their own error variants, so a script stopped for consuming the host's resources is distinguishable from one that merely failed:
use ExhaustedLimit;
# use ;
# let engine = builder.policy.build?;
# let script = from_source?;
if let Err = engine.eval
# Ok::
The classification is structural — the engine's own instruction counter, and the VM error chain — never the message text, so a script cannot disguise its own failure as a resource breach.
Two things worth knowing. The memory ceiling caps the whole state rather than each script, so an engine that has run several scripts carries whatever garbage they left until the collector runs. And the instruction ceiling is enforced to within a check interval rather than exactly.
require
A script loaded from a file may require its siblings; a script built from source may not, because
it has no directory. Targets cannot contain a path separator or a .. component, so an escape
cannot be spelled, and the resolved path is canonicalised and checked for containment, which catches
a symlink pointing out of the root. Cycles raise an error rather than recursing.
Under trusted Lua's own require is left in place. Under pure there is none at all.
Failure policy
FailurePolicy makes explicit what is otherwise a convention. A script run as an editor or agent
hook must not turn its own failure into a non-zero exit, because the caller reads that as a signal
rather than a diagnostic. FailurePolicy::FailOpen says so in the type system;
FailurePolicy::Report is the default for anything a person invoked directly.
Extending it
Implement HostModule and add it to a ModuleSet. The module becomes a subtable of the engine's
root table alongside the built-ins, and the host crate never has to modify airsl to contribute
one. mlua is re-exported as airsl::mlua, so a contributor stays on the version the engine was
built with.
install receives an InstallContext carrying the policy the engine was built with, so a module
that guards an operation reads its authority from the same object airsl doctor reports — rather
than from a copy that could disagree with it.
An engine's root table defaults to airsstack and can be named per engine, so a module contributed
by a third party need not land in a namespace named after somebody else's system.
Engine is Send + Sync, so it can be shared between threads. Evaluations on one engine are
serialised, so each gets its own arguments, its own require root and the whole instruction
budget — a shared engine is a way to avoid rebuilding a state, not a way to get parallelism, since
one Lua state cannot execute in parallel anyway.
Documentation
docs/ is organised on Diátaxis:
- Tutorial — from installing the binary to a working agent hook.
- How-to — recipes for a specific job, from Lua and from Rust.
- Architecture, Sandbox,
Host stdlib, Extensions — the explanation layer.
Extensions covers manifests, capability negotiation, and
ExtensionHost— the host loads a directory of third-party Lua extensions into running, negotiated engines and dispatches events to them, and theairsl extCLI (doctor,fire) is built on the same loader. - Reference is the rustdoc:
cargo doc -p airsl --no-deps --open.
Each document says which parts are shipped and which are design.
Releases
CHANGELOG.md — one timeline covering
this crate and airsl-cli, which are numbered independently. Releases are tagged per crate:
airsl-v0.1.3, airsl-cli-v0.1.2.