This document describes Lex's security model: what it is designed to defend against, what it isn't, and how to deploy it safely.
If you've found a vulnerability, see Reporting below.
Lex's threat model centers on untrusted code emitted by an LLM agent (or a human you don't fully trust) running inside a process you control.
Specifically, the type checker rejects, at compile time, any code that:
- Performs an effect not in its declared signature.
fn f() -> Strcannot callio.read,net.get, or any other effectful operation. The signature is the contract. - Calls a function with effects you haven't allow-listed.
With
--allow-effects net, type-checking succeeds but a runtime policy gate rejects calls into[fs_read]/[fs_write]/[io]functions before they execute. - Touches a file path or hostname outside your scope.
--allow-fs-read /tmpand--allow-net-host api.example.combound the targets of[fs_read]and[net]operations, not just the kinds. - Launders effects through higher-order code.
A closure's effect set is part of its type. Passing
fn() -> [io] Strto a parameter typedfn() -> Stris a type error — unification fails on the empty-vs.-[io]mismatch. Stdlib HOFs (list.map,list.fold, ...) use effect-row variables so an effectful closure's effects propagate to the caller instead of being absorbed silently.
These guarantees are mechanical: they hold for any well-typed
program, including ones the author didn't intend to be safe. We've
verified this on 251+ tests including dedicated soundness tests
in crates/lex-types/tests/.
There's also an adversarial benchmark — lex-lang/benches/ —
comparing agent-tool against a naive Python sandbox under
LLM-emitted attack payloads.
Effect signatures bound what kind of access happens. They do not bound:
- Memory exhaustion.
--max-stepscaps opcode dispatches (good for infinite loops); it does not cap memory allocations. A program declaredpurecan still allocate a list of 10^9 ints and OOM the host. - Stack overflow. The parser caps recursion at
MAX_DEPTH=96and the VM caps call-frame depth atMAX_CALL_DEPTH=1024— both refuse cleanly with structured errors instead of unwinding the host. Tail calls reuse frames, so productive tail-recursive code is unaffected. (Native-stack work the host performs outside a VM call — e.g. handling arbitrarily nested JSON inlex parse— is not bounded; for adversarial input layer container memory caps.) - CPU time.
--max-stepsbounds dispatched ops; the per-op cost varies. Tight on its own is not a hard wall-clock bound. - Logic bugs that don't cross effect boundaries. A
[net]function calling the wrongnet.postURL is still[net].--allow-net-hostnarrows it;lex specproves what the spec says (not what you meant). - Side channels. Timing, cache, power, EM. Out of scope.
- A compromised host. If the OS is owned, the type checker is bypassed by definition.
- Network adversaries on the wire.
--allow-net-hostis allow-list scoping, not authentication. Use TLS (net.serve_tls, HTTPS innet.get/net.post) for transport security. - Spec correctness.
lex specchecks specs you write; it doesn't generate them. A spec that doesn't cover an input is not a Lex bug.
Treat Lex's effect system as the innermost ring of defense in depth, not as a complete sandbox.
[proc] (subprocess spawn, proc.spawn(cmd, args)) is the
escape hatch in Lex's effect system. A function with [proc]
in its signature can do anything the spawned binary can do —
which, for a general-purpose tool like git or bash, is
everything. Once [proc] is in scope, the effect system stops
making strong claims about what the function does.
What [proc] does still gate:
- Effect declaration is mandatory. A function that calls
proc.spawnwithout[proc]in its signature is rejected at type-check. - Binary basename allow-list via
--allow-proc=git,gh,cargo. An empty list means "any binary" (escape hatch — only for trusted code); a non-empty list rejects pre-spawn anything whose basename isn't on the list. - Per-arg length capped at 64 KiB and arg-count capped at 1024 — runtime DoS guards.
- Type-checked argv shape:
args :: List[Str]. No shell-interpolation; the runtime callsCommand::new(cmd).args(args)which passes argv directly toexecve, no/bin/sh -cin the middle.
What [proc] does not gate:
- Argument injection. A binary that itself accepts an
--exec=...or-e ...flag (thinkbash -c,git -c alias.x=!sh,sed e ...) can be subverted by attacker-controlled args. The allow-list says "you may rungit"; it does not say "with these specific subcommands." - Environment variables. The spawned process inherits the
parent's env. A binary that reads
LD_PRELOAD,PATH, orGIT_*vars can be redirected by manipulating those. - Working directory. Inherits the parent's
cwd. A binary that doesgit statusruns against whatever repo your process is in. - Network, filesystem, signals. Whatever the spawned binary does, the OS does. The Lex effect system doesn't follow execution into the child.
- Always pass an explicit
--allow-proclist. Empty-list "escape hatch" mode is for one-off scripts you wrote, not for anything that runs LLM-emitted bodies. - Allow-list narrowly.
--allow-proc git,ghis much narrower than--allow-proc bash. Avoid shells, interpreters, and any binary with a--exec-style flag. - Validate argv at the Lex layer before passing it to
proc.spawn. If an attacker can inject into the args, the binary can do whatever its argv accepts. - Layer with OS-level isolation. Run the parent process in
a container (
docker run --memory=...), under gVisor, or withnsjail/bubblewrap.[proc]punches a hole in Lex's effect system; the container is what catches what falls through. - Audit spawned-binary surfaces.
lex audit --calls proc.spawnfinds every call site; pair with code review for the args. - Treat
[proc]as "I'm writing infrastructure, not running an agent." Agent-emitted code that needs to call subprocesses should go through a Lex-side adapter that validates the cmd + args, not get[proc]directly.
We took the trade because [proc] unlocks real workflows
(orchestrating other CLI agents, running tests, talking to
git) that the existing effect set can't serve. The honesty
above is the price.
For untrusted code paths (especially lex agent-tool and
lex run on agent-emitted input):
- Run inside a memory-limited container.
docker run --memory=512m --pids-limit=128 ...,systemd-run --property=MemoryMax=512M ..., or equivalent. This is the only reliable bound on memory and process count. - Wrap with a wall-clock timeout.
timeout 30s lex run ....--max-stepsis complementary; neither replaces the other. - Pin
--max-steps. The default is generous. For agent-emitted code, drop it to whatever covers your normal workload (10^5–10^6 typical). - Prefer fine-grained scopes over kind-level allow-lists.
--allow-fs-read /tmp/inputsis much narrower than--allow-effects fs_read. Same for--allow-net-host. - Layer with OS-level sandboxing for adversarial input.
gVisor, Firecracker, WebAssembly (
wasmtime), nsjail. Lex narrows the attack surface; OS isolation contains residual risk (kernel exploits, side channels, resource exhaustion). - Enable tracing for forensic replay.
lex run --tracerecords every effect call;lex replayreproduces the run with overrides. Useful both for debugging and post-incident review.
We treat the following as security bugs and fix them with priority:
- Type-checker false-negatives that admit code violating its declared effects (e.g. an effect leaks through a feature combination)
- Runtime-policy bypasses (e.g.
--allow-fs-read /aallowing reads under/b) - Capability gate bugs (effect declared but not enforced at runtime)
- Sandboxing escapes from
agent-tool/lex runinto the host
- Denial of service via legitimate resource consumption (see above)
- Imperfect specs (
lex specproves what you write) - Errors echoing user-supplied paths or hostnames back to the user (this is the input speaking, not a leak)
- Long-running spec-checker calls (Z3 is not always fast)
If you believe you've found a security vulnerability, please do not open a public GitHub issue. Instead:
- Email the maintainers (see
Cargo.tomlauthors), or - Open a GitHub Security Advisory
We aim to triage within a week. We don't currently run a bug-bounty program.
Lex is pre-1.0. Breaking changes that close a soundness gap may
ship in any minor release; we'll call them out in CHANGELOG.md
and the release notes.