airsl-cli
The airsl binary: runs Lua scripts on the airsl runtime.
Install
Linux and macOS only. airsstack.proc resolves executables by unix mode bits, which have no
Windows equivalent, so the runtime refuses to build off unix rather than pretending. Supporting
Windows is a decision about what "executable" means there, not a portability patch.
A C compiler is required. mlua's vendored feature builds Lua 5.4 from the C sources shipped
by lua-src and links it statically, so there is no system Lua and no pkg-config — but cc must
be present before cargo install will get anywhere.
To track unreleased main instead of the last release, cargo install --git https://github.com/airsstack/airsl --locked airsl-cli. Working inside a clone,
cargo install --path crates/airsl-cli --force builds from the checked-out sources.
doctor prints the runtime version and the policy a script would actually run under:
airsl 0.1.0
lua: Lua 5.4
language: restricted
root table: airsstack
grants: none
memory: 67108864 bytes
instructions: 100000000 instructions
modules: json, path, fs, env, proc, regex, hash, time, glob, stdio, hook
Pass --policy to describe a different preset rather than the default.
Usage
airsl run [--policy <trusted|confined|pure>]
[--allow-read <DIR>] [--allow-write <DIR>]
[--allow-env <NAME>] [--allow-exec <PROGRAM>]
[--memory-limit <BYTES|none>]
[--instruction-limit <COUNT|none>]
[--fail-open]
<script.lua> [args…]
airsl test [--policy <trusted|confined|pure>] [--allow-…] [<path>]
airsl check [<path>]
airsl doctor [--policy <trusted|confined|pure>]
airsl ext doctor <DIR> [--allow-…] [--memory-limit <BYTES|none>] [--instruction-limit <COUNT|none>]
[--event <NAME>] [--var <NAME=VALUE>]
airsl ext fire <DIR> <EVENT> [--allow-…] [--memory-limit <BYTES|none>] [--instruction-limit <COUNT|none>]
[--event <NAME>] [--var <NAME=VALUE>]
Arguments after the script path reach it in the global arg table — arg[1] where a shell script
read $1, and arg[0] for the script's own name. They are passed through untouched, including ones
beginning with -.
--policy
What the script may reach, and what it may spend.
| Preset | Language surface | Ceilings | require |
|---|---|---|---|
trusted |
everything except debug — io, os, package included |
none | Lua's own, unconfined |
confined (default) |
string, table, math, utf8, coroutine, pure os |
64 MiB, 100M instructions | confined to the script's directory |
pure |
string, table, math, utf8 |
16 MiB, 10M instructions | none |
trusted is for first-party scripts only: one can read and write arbitrary files and spawn
processes without going through a host module, so none of the containment the host modules provide
applies to it.
A script under confined may require its siblings, including files in subdirectories
(require("lib.index")). It cannot name anything outside its own directory: a target may not
contain a path separator or a .. component, and the resolved path is checked for containment, so a
symlink pointing out of the directory is refused too.
Grant flags
Nothing is granted below --policy trusted. A script that reads a file, reads an environment
variable or runs a program says so on the command line, so the authority is visible to whoever reads
the invocation:
| Flag | Grants | Repeatable |
|---|---|---|
--allow-read DIR |
reading under DIR |
yes |
--allow-write DIR |
writing under DIR — not implicitly readable |
yes |
--allow-env NAME |
reading and setting the variable NAME |
yes |
--allow-exec PROGRAM |
running PROGRAM, matched on the name as written |
yes |
A refusal names what was granted, because the usual cause is a root one directory too deep:
airsl: fs.read denied: `/etc/hostname` is outside the granted read roots: /home/me/journal
Under --policy trusted these flags are ignored: that preset waives containment entirely, so a
declared list would narrow nothing and would make airsl doctor report something meaningless.
airsl test
Runs the Lua test files under a directory, with the same policy and grant flags as airsl run.
A test file is named *_test.lua or test_*.lua and returns a table whose named function values
are the tests. A test passes by returning and fails by raising, so Lua's own assert is the whole
assertion surface:
-- index_test.lua
return
Each file gets a fresh engine, so one file cannot leave globals behind for the next. Finding no test files at all exits non-zero — "no tests" and "all tests passed" must not read the same to CI.
airsl check
Compiles every .lua file under a directory without running a line of any of it.
It exists because airsl test covers only what a test file loads, and a hook's entry point is
usually loaded by nothing — the tests exercise the modules underneath it. A syntax error in a
driver therefore survives a green test run, and --fail-open then swallows it when the hook fires:
CI says nothing, the session says nothing, and the hook has quietly stopped working. Measured on one
such script suite before this existed, a missing end in the dispatcher left 244 tests passing and
the hook exiting 0.
It takes no policy and no grants, because nothing is executed and parsing never consults the
globals table — a chunk compiles or does not compile identically under every preset. Finding no
Lua files at all exits non-zero, for the same reason airsl test does.
What it does not catch is everything past the parser: a misspelled field, a nil arithmetic, a
module that raises the moment it is required. Those compile, and they are a test's job.
airsl ext
ext doctor shows what a ceiling would grant, reduce and deny for one extension's manifest,
without running a line of its entry script:
extension: word-count 0.1.0 (api 1, entry main.lua)
events: none
ceiling:
language: restricted
grants: none
memory: 67108864 bytes
instructions: 100000000 instructions
negotiated:
language: restricted
grants: none
memory: 8388608 bytes
instructions: 1000000 instructions
requested:
module regex granted
decision: approve
ceiling: is the host's real bound — confined plus whatever --allow-…/--memory-limit/
--instruction-limit widened it to — unmodified by anything the manifest asked for. negotiated:
is the policy this extension would actually run under, which differs wherever the manifest's own
[limits] asked for a tighter ceiling than the host offers, as it does here (this manifest asks for
8 MiB and 1M instructions, well under the host's 64 MiB / 100M default). events: is the sorted,
deduplicated set the host declared with --event — the names ext fire is allowed to dispatch —
and reads none when the flag was never passed; ext doctor never dispatches anything, so the line
is informational here.
A denied or reduced request is tagged in place, and the decision line names why:
extension: broken 0.1.0 (api 1, entry main.lua)
events: none
ceiling:
language: restricted
grants: none
memory: 67108864 bytes
instructions: 100000000 instructions
negotiated:
language: restricted
grants: none
memory: 67108864 bytes
instructions: 100000000 instructions
requested:
fs.read / denied (outside the granted read roots: none)
decision: deny — fs.read `/`: outside the granted read roots: none
ext fire loads the extension for real and dispatches one event, with the payload on stdin and the
result on stdout as byte-stable JSON (null when nothing handled the event):
|
{"longest":"quick","words":4}
Both subcommands take the same grant flags as airsl run — --allow-read, --allow-write,
--allow-env, --allow-exec (see "Grant flags" above) — plus --memory-limit and
--instruction-limit (see "--memory-limit and --instruction-limit" below), --event NAME to
declare an event beyond the one being fired or inspected (repeatable), and --var NAME=VALUE to
supply a manifest variable (repeatable). There is no --policy: ext negotiates against confined
plus whatever the flags widen — trusted waives containment entirely, which is not a ceiling an
extension manifest can be held against.
| Command | Exit 0 | Exit 1 |
|---|---|---|
ext doctor |
any decision, including a deny | the manifest cannot be read or parsed, or the ceiling itself is invalid |
ext fire |
the event dispatched and a result (or null) was written |
any error — invalid JSON on stdin, a denied manifest, a handler error |
ext doctor never builds an engine; ext fire goes through ExtensionHost exactly the way a host
program would, so what it does is by construction what embedding this crate does. A resource-limit
breach is always reported on stderr, whether or not ext fire otherwise succeeds — there is no
--fail-open for this subcommand, so every failure, breach included, is reported and exits 1.
--memory-limit and --instruction-limit
Override whatever the preset supplied. Pass none to lift a ceiling the preset imposed, or a count
to impose one it did not:
The instruction ceiling is the only thing that stops a script that never terminates — no policy
decision helps against while true do end, because it reaches nothing. It costs roughly a quarter of
the evaluation path, since the check runs inside the VM, which is why lifting it is available.
The memory ceiling caps the whole Lua state rather than each script, and the instruction ceiling is enforced to within a check interval rather than exactly.
--fail-open
Discards errors and exits 0.
This exists for scripts run as editor or agent hooks, where a non-zero exit is read as a signal
rather than a diagnostic. A PreToolUse hook that exits 2 blocks the tool call that triggered it,
and the matcher for such hooks commonly covers Read — so a script that merely failed would block
every file read in the session.
The flag lives on the command line rather than inside the script because a syntax error happens before any in-script setting could take effect, and that is precisely the case the behaviour exists for.
Set AIRSL_DEBUG=1 to see the error on stderr anyway. The exit code stays 0.
One exception, deliberately. A script stopped for exhausting a memory or instruction ceiling is
always reported on stderr, AIRSL_DEBUG or not. The exit code still stays 0 — the fail-open
contract does not bend — but a hook consuming the host's memory or looping until it is killed is a
fact about the machine rather than a diagnostic the script chose to emit, and staying silent about
it makes a misbehaving hook impossible to find.
Calling it from a hook
The binary is not ambient the way sh and python3 are: until it has been installed, it is not
there. A launcher that checks first keeps a missing runtime silent rather than broken:
#!/bin/sh
DIR= ||
[ ||
||
||
Every path exits 0, and exec is deliberately not used — it would replace the shell and hand the
child's exit status straight back to the caller.
Documentation
The reference layer is the library's rustdoc at docs.rs/airsl — this crate
publishes none of its own, because every public item lives in airsl and the binary is a thin shell
over it.
Everything else is in crates/airsl/docs/,
organised on Diátaxis:
- Tutorial — from installing this binary to a working agent hook, hitting the sandbox once on purpose along the way.
- How-to — recipes for a specific job, from Lua and from Rust.
- Sandbox — what a grant is, where enforcement lives, and what the resource ceilings can and cannot promise.
- Host standard library
— every module a script sees under the
airsstackglobal. - Architecture — the three layers and why it is shaped this way.
- Extensions —
the manifest format, ceilings and negotiation
ext doctor/ext fireare built on.
Each document marks which parts ship and which are design.
Releases
CHANGELOG.md — one timeline covering
this binary and the airsl library it carries, which are numbered independently. Releases are
tagged per crate: airsl-cli-v0.1.2, airsl-v0.1.3.
This binary's behaviour changes when the library beneath it does, so an entry naming an airsl
version is describing this crate too.