Terrarium logo

Rust · untrusted-code sandbox

Run code you
do not trust.

Terrarium is a from-scratch scripting sandbox that runs untrusted code without letting it hang you, exhaust your memory, blow your stack, or touch anything you did not hand it. Fuel bounds time, a byte budget bounds allocation, a depth cap bounds recursion, and capabilities gate every effect. For any input script, run returns a value or a typed trap, never a panic, a hang, or an escape. Type a script below, set the limits, choose what to grant, and watch the meters and the trap.

Open the sandbox View on GitHub
How to use this playground
Write a script, set the limits, choose which capabilities to grant, then run it in the sandbox.

Script

Granted capabilities

Result

Run a script to see the outcome.
Fuel used0 / 0
Memory charged0 / 0 bytes

Output (via print)

No output. Grant print and call it to see lines here.

Runs fully client-side, nothing leaves the page. A faithful JavaScript port of the Rust sandbox, with the same traps, the same gates, and the same containment behavior.

It contains anything you throw at it

The whole project is judged on one claim: for any input script, including adversarial and malformed ones, run returns in bounded time with a value or a typed trap. It never panics the host process, never hangs, never overflows the host stack, and never performs an effect outside the granted capabilities.

# tests/fuzz.rs throws 10,000 programs per run across five fixed seeds,
# with no capabilities granted, and asserts the three invariants
fuzz report: seeds=5 iterations=10000 host_panics=0 hangs=0 escapes=0 traps=9933 values=67

An infinite loop traps OutOfFuel. An allocation bomb traps OutOfMemory before it touches real memory. Unbounded recursion traps StackOverflow instead of a host segfault. An ungranted effect traps CapabilityDenied. Integer overflow wraps, division by zero is a trap, and a garbage source is a clean parse error. tests/containment.rs pins each hostile scenario to its exact trap.

How it differs

Real sandboxes solve the same problem in production, and they are large. Terrarium is the readable version, an interpreter whose whole reason to exist is the four bounds.

WebAssembly and wasmtime

A production sandbox with fuel metering to bound execution and linear-memory limits to bound allocation. Robust and fast, but it means compiling your code to Wasm and embedding a full runtime.

seccomp

Filters the system calls a process may make, bounding effects at the kernel boundary. Powerful for OS-level isolation, but it is Linux-specific and coarse, and it says nothing about time, memory, or recursion inside the process.

V8 isolates

Embed untrusted JavaScript with per-isolate heap limits. Battle tested, but you are carrying an entire JavaScript engine to get it.

Terrarium

A from-scratch interpreter, dependency-free, where fuel, a byte budget, a depth cap, and capabilities are the product rather than an afterthought. For any input script, run returns a value or a typed trap, never a panic, a hang, a host stack overflow, or an escape.

The bounds that contain it

Four limits, typed traps, and a determinism promise.

Each bound turns a classic way to take down a host into a named, recoverable trap. The fuzzer and the containment test pin every one of them.

time Fuel

Every evaluation step costs one unit of fuel. Run out and you get Trap::OutOfFuel, so an infinite loop stops in bounded time.

space Memory budget

Strings, arrays, maps, and call frames are charged against a byte budget before they are allocated. Exceed it and you get Trap::OutOfMemory before real memory is touched.

stack Depth cap

Recursion is counted. Too deep and you get Trap::StackOverflow rather than a host segfault.

authority Capabilities

A script has no ambient authority. Effects like print, input, now, and len are reachable only if the host granted that exact name; everything else is Trap::CapabilityDenied.

typed Traps, never panics

Every failure surfaces as a named trap, from DivByZero to Type to Parse. A host panic would count as an Internal trap, and the fuzzer holds that count at zero.

reproducible Determinism

Integer overflow wraps and is documented, division or modulo by zero is a trap, and garbage source is a clean parse error. Same input, same outcome, every run.

Use it

Library API

Build a Sandbox::new(Limits::default()), grant only the capabilities you want, and call run. You get back the result value or a typed Trap, plus whatever the host captured. Tune limits with Limits::default().with_fuel(N).

CLI

terrarium run <file> [--fuel N] [--mem BYTES] [--depth D] [--grant cap,cap] [-- args...]. Each run prints the value or the trap, with the fuel and memory it spent.

The language

Small on purpose: integers, booleans, strings, nil, let and assignment, arithmetic and short-circuit logic, if and while, functions with recursion, and arrays and maps. Builtins are reachable only through capabilities.

Tests and fuzzer

tests/fuzz.rs throws ten thousand programs per run across five fixed seeds; tests/containment.rs pins each hostile scenario to its exact trap.

# build and run the test suite, including the fuzzer and containment gate
cargo build
cargo test

# run a script under limits, granting only what it needs
terrarium run examples/normal.tr --grant print
terrarium run examples/loop.tr --fuel 100000
terrarium run examples/escape.tr