A WebAssembly sandbox for running untrusted Python safely — memory and CPU limits, no filesystem or network access by default, and async host callbacks for the access you do want to allow.
Embed it from Python, JavaScript, or Rust. Runs full CPython 3.14 (not a limited subset), so the real standard library and most pure-Python packages just work. Try the demo or read the docs.
Used in production at Grafana.
eryx (noun): A genus of sand boas (Erycinae) — non-venomous snakes that live in sand. Perfect for "Python running inside a sandbox."
await get_time())asyncio.gather()sys.settraceExecutionHandleEryx embeds CPython 3.14 compiled to WebAssembly (WASI). The WASI-compiled CPython and standard library come from the componentize-py project by the Bytecode Alliance.
pip install pyeryx
The package is published as
pyeryxbut imported aseryx.
import eryx
# Zero-config: ships with a pre-initialized CPython runtime, so this is fast (~1-5ms)
sandbox = eryx.Sandbox()
result = sandbox.execute('''
print("Hello from the sandbox!")
print(f"2 + 2 = {2 + 2}")
''')
print(result.stdout)
See the PyPI package and crates/eryx-python for the full Python API (sessions, callbacks, package loading, resource limits).
npm install @bsull/eryx
cargo add eryx --features embedded
use eryx::Sandbox;
#[tokio::main]
async fn main() -> Result<(), eryx::Error> {
// Sandbox::embedded() provides zero-config setup (requires `embedded` feature)
let sandbox = Sandbox::embedded().build()?;
let result = sandbox.execute(r#"
print("Hello from Python!")
import sys
print("This goes to stderr", file=sys.stderr)
"#).await?;
println!("stdout: {}", result.stdout);
println!("stderr: {}", result.stderr);
Ok(())
}
Use the #[callback] macro for strongly-typed callbacks with automatic schema generation:
use eryx::{callback, CallbackError, Sandbox};
use serde_json::{json, Value};
/// Returns the current Unix timestamp
#[callback]
async fn get_time() -> Result<Value, CallbackError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(json!(now))
}
/// Echoes back the message
#[callback]
async fn echo(message: String) -> Result<Value, CallbackError> {
Ok(json!({ "echoed": message }))
}
#[tokio::main]
async fn main() -> Result<(), eryx::Error> {
let sandbox = Sandbox::embedded()
.with_callback(get_time)
.with_callback(echo)
.build()?;
let result = sandbox.execute(r#"
# Callbacks are available as direct async functions
timestamp = await get_time()
print(f"Current time: {timestamp}")
response = await echo(message="Hello!")
print(f"Echo: {response}")
"#).await?;
println!("{}", result.stdout);
Ok(())
}
For runtime-defined callbacks (plugin systems, dynamic APIs), implement the Callback trait directly.
See the runtime_callbacks example.
For high-throughput scenarios where you need to execute many Python scripts concurrently, use SandboxPool to maintain a pool of warm sandbox instances:
use eryx::{Sandbox, SandboxPool, PoolConfig};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), eryx::Error> {
// Create a pool with custom configuration
let config = PoolConfig {
max_size: 10, // Maximum concurrent sandboxes
min_idle: 2, // Pre-warm 2 instances
idle_timeout: Duration::from_secs(300), // Evict after 5 min idle
acquire_timeout: Duration::from_secs(30), // Wait up to 30s for sandbox
..Default::default()
};
let pool = SandboxPool::new(Sandbox::embedded(), config).await?;
// Acquire a sandbox from the pool
let sandbox = pool.acquire().await?;
// Use the sandbox normally
let result = sandbox.execute("print('Hello from pool!')").await?;
println!("{}", result.stdout);
// Sandbox automatically returns to pool when dropped
drop(sandbox);
// Check pool statistics
let stats = pool.stats();
println!("Acquisitions: {}, Creations: {}",
stats.total_acquisitions, stats.total_creations);
Ok(())
}
For custom sandbox configurations with callbacks:
use eryx::{Sandbox, SandboxPool, PoolConfig};
let pool = SandboxPool::with_builder(
|| {
Sandbox::embedded()
.with_callback(MyCallback)
.build()
},
PoolConfig::default(),
).await?;
Key features:
min_idle sandboxes upfront for immediate availabilitymax_size limits concurrent sandbox usage via semaphoreidle_timeout are automatically evictedtry_acquire() to get a sandbox without waitingFor REPL-style usage where state persists between executions:
use eryx::{Sandbox, session::InProcessSession};
#[tokio::main]
async fn main() -> Result<(), eryx::Error> {
let sandbox = Sandbox::embedded().build()?;
let mut session = InProcessSession::new(&sandbox).await?;
// First execution defines a variable
session.execute("x = 42").await?;
// Second execution can access it
let result = session.execute("print(x * 2)").await?;
println!("{}", result.stdout); // "84"
// Snapshot and restore state
let snapshot = session.snapshot_state().await?;
session.clear_state().await?;
session.restore_state(&snapshot).await?;
Ok(())
}
Cancel long-running or infinite executions using ExecutionHandle:
use std::time::Duration;
use eryx::{Sandbox, Error};
#[tokio::main]
async fn main() -> Result<(), Error> {
let sandbox = Sandbox::embedded().build()?;
// Start a cancellable execution
let handle = sandbox.execute_cancellable("while True: pass");
// Cancel from another task after a delay
let token = handle.cancellation_token();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(1)).await;
token.cancel();
});
// Wait for result
match handle.wait().await {
Ok(result) => println!("Completed: {}", result.stdout),
Err(Error::Cancelled) => println!("Execution was cancelled"),
Err(e) => println!("Error: {e}"),
}
Ok(())
}
The cancellation uses Wasmtime's epoch-based interruption for prompt termination.
Enable TCP and TLS networking with host-controlled policies using NetConfig:
use std::time::Duration;
use eryx::{Sandbox, NetConfig};
#[tokio::main]
async fn main() -> Result<(), eryx::Error> {
let net_config = NetConfig::default()
.allow_host("api.example.com")
.allow_host("*.trusted.org")
.with_connect_timeout(Duration::from_secs(10))
.with_max_connections(5);
let sandbox = Sandbox::embedded()
.with_network(net_config)
.build()?;
let result = sandbox.execute(r#"
import urllib.request
response = urllib.request.urlopen("https://api.example.com/data")
print(response.read().decode())
"#).await?;
println!("{}", result.stdout);
Ok(())
}
By default, networking is disabled. When enabled via with_network():
allowed_hosts patterns with wildcards (e.g., *.example.com)| Feature | Description | Trade-offs |
|---|---|---|
embedded |
Zero-config sandboxes: embeds pre-compiled Wasm runtime + Python stdlib | +32MB binary size; enables unsafe code paths |
preinit |
Pre-initialization support for ~25x faster sandbox creation | Adds eryx-runtime dep; requires build step |
native-extensions |
Native Python extension support (e.g., numpy) via late-linking | Implies preinit; experimental |
Package support (with_package() for .whl and .tar.gz files) is always available — no feature flag required.
The preinit feature provides ~25x faster sandbox creation by capturing Python's initialized memory state at build time. This works with or without native extensions — you can pre-import stdlib modules like json, asyncio, re, etc.
| Metric | Without Pre-init | With Pre-init | Speedup |
|---|---|---|---|
| Sandbox creation | ~450ms | ~18ms | 25x faster |
// Fastest startup, zero configuration (recommended for most users)
// Features: embedded
let sandbox = Sandbox::embedded().build()?;
// With pre-initialization for faster sandbox creation
// Features: embedded, preinit
// Pre-import common stdlib modules during build for ~25x speedup
let preinit_bytes = eryx::preinit::pre_initialize(
&stdlib_path, None, &["json", "asyncio", "re"], &[]
).await?;
// With package support for third-party libraries
// Features: embedded (packages always available)
let sandbox = Sandbox::embedded()
.with_package("requests-2.31.0-py3-none-any.whl")?
.build()?;
// With native extensions (numpy, etc.)
// Features: embedded, native-extensions
let sandbox = Sandbox::embedded()
.with_package("numpy-wasi.tar.gz")?
.build()?;
| Metric | Normal Wasm | Pre-compiled | Speedup |
|---|---|---|---|
| Sandbox creation | ~650ms | ~16ms | 41x faster |
| Per-execution overhead | ~1.8ms | ~1.6ms | 14% faster |
| Session (5 executions) | ~70ms | ~3ms | 23x faster |
This project uses mise for tooling and task management.
mise install
mise run setup # Build Wasm + precompile (one-time)
# Development
mise run check # Run cargo check
mise run build # Build all crates
mise run test # Run tests with embedded Wasm
mise run test-all # Run tests with all features
mise run lint # Run clippy lints
mise run fmt # Format code
mise run fmt-check # Check code formatting
# Wasm
mise run build-eryx-runtime # Build the Python Wasm component
mise run build-all # Build Wasm + Rust crates
mise run precompile-eryx-runtime # Pre-compile to native code
# CI & Quality
mise run ci # Run all CI checks (fmt-check, lint, test)
mise run msrv # Check compilation on minimum supported Rust version
# Documentation
mise run doc # Generate documentation
mise run doc-open # Generate and open documentation
# Benchmarks
mise run bench # Run benchmarks
mise run bench-save # Run benchmarks and save baseline
# Examples
mise run examples # Run all examples
cargo nextest run --workspace # Run tests
cargo nextest run --workspace --features embedded # Fast tests
cargo clippy --workspace --all-targets --all-features # Run lints
cargo fmt --all # Format code
cargo doc --workspace --no-deps --open # Generate docs
cargo bench --package eryx # Run benchmarks
This project has multiple cache layers (cargo, mise, embedded runtime, WASM artifacts). If you experience unexpected behavior after changing code:
```bash