Skip to content

feyninc/littrs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

76 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Littrs Logo

Littrs

Keep your LLM's πŸ’© code where it belongs β€” in a sandbox.

Crates.io PyPI version License CI GitHub stars


LLMs are better at writing Python than crafting JSON tool calls. But running LLM-generated code means either spinning up containers, paying for sandboxing services, or gambling with exec(). Littrs takes a different approach: a Python sandbox that embeds directly into your Rust or Python application as a library. No containers, no network calls, no infrastructure β€” just pip install or cargo add and go.

You register functions as tools, hand the sandbox some LLM-generated code, and get back a result. The sandbox compiles Python to bytecode and runs it on a stack-based VM with zero ambient capabilities β€” no filesystem, no network, no env vars. The only way sandboxed code can reach the outside world is through tools you explicitly provide.

  • Tool registration β€” @sandbox.tool in Python, #[tool] in Rust. Inject variables with sandbox["x"] = val, run code with sandbox(code)
  • Resource limits β€” cap bytecode instructions and recursion depth per call, enforced at the VM level and uncatchable by try/except
  • Stdout capture β€” print() output collected and returned separately from the result
  • Auto-generated tool docs β€” describe() produces Python-style signatures and docstrings, ready to paste into a system prompt
  • Built-in modules β€” json, math, and typing available out of the box with Sandbox(builtins=True) / Sandbox::with_builtins(). Register custom modules with .module()
  • File mounting β€” mount host files into the sandbox with read-only or read-write access. Sandbox code uses open() to read/write; writes persist back to the host. sandbox.files() lets you inspect current writable file contents
  • WASM isolation β€” optional stronger sandboxing via an embedded wasmtime guest module with memory and fuel limits
  • Fast startup β€” no interpreter boot, no runtime to load. Create a sandbox, register tools, run code

Littrs implements enough Python for an LLM to call tools, process results, handle errors, and return values. It does not support third-party packages, classes, closures, async/await, finally, or match β€” see the ROADMAP for what's planned and the full list of supported features.

Installation

Rust

cargo add littrs

Python

uv pip install littrs

Usage

Littrs can be called from Rust or Python. See the ROADMAP for planned features.

Python

from littrs import Sandbox

sandbox = Sandbox()

@sandbox.tool
def get_weather(city: str, units: str = "celsius") -> dict:
    """Get current weather for a city."""
    return {"city": city, "temp": 22, "units": units}

result = sandbox("get_weather('London')")
# result == {"city": "London", "temp": 22, "units": "celsius"}

The @sandbox.tool decorator registers your function with its full signature β€” the LLM code calls it like a normal Python function. The sandbox is also callable: sandbox(code) is shorthand for sandbox.run(code).

Variables persist across calls, and you can inject values directly:

sandbox["user_id"] = 42
sandbox("name = get_weather('London')['city']")
sandbox("name")  # "London"

Resource Limits

sandbox.limit(max_instructions=10_000, max_recursion_depth=50)

try:
    sandbox.run("while True: pass")
except RuntimeError as e:
    print(e)  # "Instruction limit exceeded (limit: 10000)"

Resource limit errors are uncatchable β€” try/except in the sandbox code cannot suppress them.

Capturing Print Output

capture() returns both the result and everything that was print()-ed:

result, printed = sandbox.capture("""
for i in range(5):
    print(i)
"done"
""")
# result  == "done"
# printed == ["0", "1", "2", "3", "4"]

Tool Documentation for LLM Prompts

describe() auto-generates Python-style signatures and docstrings from registered tools, ready to embed in a system prompt:

print(sandbox.describe())
# def get_weather(city: str, units: str = 'celsius') -> dict:
#     """Get current weather for a city."""

Low-level Registration

If you need to bypass the decorator (e.g. registering a function that takes raw positional args):

def fetch_data(args):
    return {"id": args[0], "name": "Example"}

sandbox.register("fetch_data", fetch_data)

Imports & Built-in Modules

Create a sandbox with builtins=True to enable json, math, and typing modules:

sandbox = Sandbox(builtins=True)

result = sandbox("""
import json
data = json.loads('{"name": "Alice", "score": 95}')
data["score"]
""")
# result == 95

from ... import works too:

sandbox("""
from math import sqrt, pi
sqrt(pi)
""")

Register custom modules with .module():

sandbox.module("config", {"version": "1.0", "debug": False})
sandbox("import config; config.version")  # "1.0"

File Mounting

Mount host files into the sandbox so LLM-generated code can read input and write output without full filesystem access:

sandbox.mount("data.json", "./data/input.json")                    # read-only (default)
sandbox.mount("output.txt", "./output/result.txt", writable=True)  # read-write

result = sandbox("""
f = open("data.json")
data = f.read()
f.close()

f = open("output.txt", "w")
f.write("processed: " + data)
f.close()
""")

# Inspect written files from the host
sandbox.files()  # {"output.txt": "processed: ..."}

Unmounted paths raise FileNotFoundError; writing to read-only mounts raises PermissionError. Both are catchable with try/except inside the sandbox.

WASM Sandbox (Stronger Isolation)

For stronger isolation, Littrs can run the interpreter inside a WebAssembly guest module with memory isolation and fuel-based computation limits:

from littrs import WasmSandbox, WasmSandboxConfig

config = WasmSandboxConfig().with_fuel(1_000_000).with_max_memory(32 * 1024 * 1024)
sandbox = WasmSandbox(config)

result = sandbox.run("sum(range(100))")
assert result == 4950

Rust

The #[tool] macro is the easiest way to register tools. Write a normal function with doc comments, and the macro generates everything needed for registration and LLM documentation:

use littrs::Sandbox;
use littrs_macros::tool;

/// Get current weather for a city.
///
/// Args:
///     city: The city name
///     units: Temperature units (C or F)
#[tool]
fn get_weather(city: String, units: Option<String>) -> String {
    format!("{}: 22Β°{}", city, units.unwrap_or("C".into()))
}

let mut sandbox = Sandbox::new();
sandbox.add(get_weather::Tool);

let result = sandbox.run(r#"get_weather("London")"#).unwrap();

The #[tool] macro handles type conversion from PyValue automatically. sandbox.add() registers the tool with its full metadata.

Variables persist across run() calls:

sandbox.run("x = 10").unwrap();
sandbox.run("y = 20").unwrap();
let result = sandbox.run("x + y").unwrap();
assert_eq!(result, PyValue::Int(30));

Resource Limits

use littrs::{Sandbox, Limits};

let mut sandbox = Sandbox::new();
sandbox.limit(Limits {
    max_instructions: Some(10_000),
    max_recursion_depth: Some(50),
});

let err = sandbox.run("while True: pass").unwrap_err();
assert!(err.to_string().contains("Instruction limit"));

Resource limit errors are uncatchable β€” try/except in the sandbox code cannot suppress them. This is by design: the host must always be able to regain control.

Tool Documentation

describe() auto-generates Python-style docs for all registered tools, suitable for embedding in an LLM system prompt:

let docs = sandbox.describe();
// def get_weather(city: str, units?: str) -> str:
//     """Get current weather for a city."""

Capturing Print Output

let mut sandbox = Sandbox::new();
let output = sandbox.capture(r#"
for i in range(5):
    print(i)
"done"
"#).unwrap();

assert_eq!(output.output, vec!["0", "1", "2", "3", "4"]);
assert_eq!(output.value, PyValue::Str("done".to_string()));

Low-level Registration

For cases where the #[tool] macro isn't suitable, you can register closures directly:

use littrs::{Sandbox, PyValue};

let mut sandbox = Sandbox::new();

sandbox.register_fn("fetch_data", |args| {
    let id = args[0].as_int().unwrap_or(0);
    PyValue::Dict(vec![
        (PyValue::Str("id".to_string()), PyValue::Int(id)),
        (PyValue::Str("name".to_string()), PyValue::Str("Example".to_string())),
    ])
});

Imports & Built-in Modules

Use Sandbox::with_builtins() to enable json, math, and typing modules:

use littrs::{Sandbox, PyValue};

let mut sandbox = Sandbox::with_builtins();

let result = sandbox.run(r#"
import json
data = json.loads('{"name": "Alice", "score": 95}')
data["score"]
"#).unwrap();
assert_eq!(result, PyValue::Int(95));

Register custom modules with .module():

use littrs::{Sandbox, PyValue};

let mut sandbox = Sandbox::new();
sandbox.module("config", |m| {
    m.constant("version", PyValue::Str("1.0".into()));
    m.function("get_flag", |_args| PyValue::Bool(true));
});

let result = sandbox.run("import config; config.version").unwrap();
assert_eq!(result, PyValue::Str("1.0".into()));

File Mounting

Mount host files into the sandbox for controlled file I/O:

use littrs::{Sandbox, PyValue};

let mut sandbox = Sandbox::new();
sandbox.mount("data.json", "./data/input.json", false);      // read-only
sandbox.mount("output.txt", "./output/result.txt", true);     // read-write

sandbox.run(r#"
f = open("data.json")
content = f.read()
f.close()

f = open("output.txt", "w")
f.write("processed")
f.close()
"#).unwrap();

let files = sandbox.files();  // {"output.txt": "processed"}

Unmounted paths raise FileNotFoundError; writing to read-only mounts raises PermissionError. Both are catchable inside the sandbox with try/except.

Alternatives

Littrs is designed for one specific use case: running code written by AI agents safely and cheaply. It trades language completeness for simplicity, speed, embeddability, and zero infrastructure requirements.

Tech Security Start latency Embeddable Resource limits Tool registration WASM isolation Setup
Littrs strict (no ambient access) ~1ms Rust, Python instruction + recursion caps built-in built-in cargo add / pip install
Docker good (container isolation) ~200ms no (separate process) cgroups roll your own no daemon + images
Pyodide poor (JS sandbox leaks) ~2800ms JS only hard to enforce roll your own host-level only WASM runtime + 12MB
Monty strict <0.1ms Rust, Python, JS memory + time + allocations built-in no pip install
Sandboxing services strict (managed) ~1000ms no (API call) service-managed API-based service-managed API keys + network
exec() / subprocess none ~0.1ms Python only none none no none

Comparison table adapted from Monty.

Why Littrs over Docker/services? Zero infrastructure. No daemon, no containers, no network calls, no API keys. Just a library you import. Ideal for edge deployments, embedded systems, or anywhere you can't run Docker.

Why Littrs over exec()? Security. exec() gives LLM-generated code full access to your filesystem, network, and environment. Littrs gives it access to nothing except the tools you explicitly register.

Why Littrs over Pyodide? Startup speed and server-side safety. Pyodide takes seconds to cold-start and wasn't designed for server-side isolation β€” Python code can escape into the JS runtime.

Why Littrs over Monty? Developer experience. Littrs provides a cleaner API β€” @sandbox.tool to register a function, sandbox(code) to run it, sandbox["x"] = val to inject variables. No boilerplate, no separate input/output declarations, no configuration objects. It also includes built-in WASM isolation for stronger sandboxing when you need it.

Citation

If you use Littrs in your research, please cite it as:

@software{littrs,
  title = {Littrs: A Minimal, Secure Python Sandbox for AI Agents},
  author = {Chonkie Inc.},
  url = {https://github.com/chonkie-inc/littrs},
  license = {Apache-2.0},
  year = {2025-2026}
}

About

πŸ–οΈπŸ›‘οΈ Keep your LLM's πŸ’© code where it belongs β€” in a sandbox.

Topics

Resources

License

Stars

6 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors