smolvm

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

1post · 30d
Nov 40 posts
  1. Beyond the Prompt: Building Safe, Local Execution Environments for Agentic Tooling with Rust and smolvm Originally published on tamiz.pro. Beyond the Prompt: Building Safe, Local Execution Environments for Agentic Tooling with Rust and smolvm Agentic systems are only as trustworthy as the sandbox they run in. When an LLM-driven agent can invoke arbitrary tools — shell commands, filesystem access, network calls — the blast radius of a prompt injection or hallucinated action equals the permissions of the host process. This tutorial walks through building a local, capability-secure execution environment using Rust and smolvm, a lightweight WebAssembly runtime designed for embedding. You'll get a working sandbox that enforces least-privilege at the syscall level, supports deterministic replay, and adds <2ms overhead per tool call. Table of Contents - [1. Threat Model & Design Goals](#1-threat-model--design-goals) - [2. Why WebAssembly + smolvm?](#2-why-webassembly--smolvm) - [3. Project Setup & Dependencies](#3-project-setup--dependencies) - [4. Capability-Based Host API Design](#4-capability-based-host-api-design) - [5. Building the Sandbox Core](#5-building-the-sandbox-core) - [6. Filesystem Capabilities with Virtual Paths](#6-filesystem-capabilities-with-virtual-paths) - [7. Network Capabilities & Egress Control](#7-network-capabilities--egress-control) - [8. Deterministic Replay & Audit Logging](#8-deterministic-replay--audit-logging) - [9. Integrating with an Agent Loop](#9-integrating-with-an-agent-loop) - [10. Production Hardening Checklist](#10-production-hardening-checklist) - [Frequently Asked Questions](#frequently-asked-questions) --- 1. Threat Model & Design Goals Before writing code, define what we're defending against: | Threat | Mitigation in This Design | |--------|---------------------------| | Prompt injection → arbitrary command execution | No shell access; only typed, capability-gated host functions | | Path traversal / sensitive file reads | Virtual filesystem with allow-listed directories | | Data exfiltration via network | Egress allow-list + request/response logging | | Resource exhaustion (CPU, memory, FDs) | smolvm fuel metering + rlimit enforcement | | Supply chain compromise in tool WASM | Signed modules, pinned versions, reproducible builds | | Agent hallucination causing cascade failure | Deterministic replay for post-mortem; idempotency keys | Design goals: - Capability-based security: No ambient authority. Every filesystem read, network call, or clock access requires an explicit capability token passed at instantiation. - Local-first: No cloud dependency. The sandbox runs in-process or in a dedicated sidecar process on the same machine. - Deterministic: Given same input + capabilities, execution is bit-for-bit reproducible — essential for debugging agent loops. - Low latency: Target <2ms cold-start, <500µs warm call overhead. - Observable: Structured audit logs (JSONL) for every capability invocation. --- 2. Why WebAssembly + smolvm? | Runtime | Cold Start | Memory Overhead | Syscall Interposition | Determinism | Embedding API | |---------|------------|-----------------|----------------------|-------------|---------------| | wasmtime | ~1-3ms | ~4MB | WASI preview1/2 | Partial | Excellent | | wasmer | ~2-5ms | ~6MB | WASI + custom | Partial | Good | | smolvm | <500µs | ~500KB | Custom host functions | Full (fuel-based) | Minimal, unsafe-free | | deno / bun | ~10-50ms | ~20MB | Full OS | No | Process-based | smolvm wins for in-process agent tooling because: - No JIT — pure interpreter, predictable latency, trivial to audit - Fuel metering built-in (instruction counting) → hard CPU limits - Host functions are plain Rust fn — no wasm-bindgen glue, no Component Model complexity - ~3k lines of runtime code you can read in one sitting - Compiles to a 200KB cdylib or static library Trade-off: No SIMD, no threads, no WASI preview2. For tool execution (JSON in, JSON out), that's fine. For heavy compute, offload to a separate wasmtime pool. --- 3. Project Setup & Dependencies cargo new agent-sandbox --edition 2021 cd agent-sandbox Cargo.toml [package] name = "agent-sandbox" version = "0.1.0" edition = "2021" [dependencies] smolvm = { git = "https://github.com/smol-rs/smolvm", rev = "main" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } uuid = { version = "1.0", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time", "fs"] } anyhow = "1.0" parking_lot = "0.12" [profile.release] lto = true codegen-units = 1 panic = "abort" strip = true > ponytail: smolvm isn't on crates.io yet. Pin the git rev in production. Vendor it via cargo vendor for air-gapped builds. --- 4. Capability-Based Host API Design The core principle: a capability is an unforgeable token (a u64 handle) that the host validates on every call. The guest WASM module never sees raw paths, FDs, or sockets — only capability handles granted at instantiation. 4.1 Capability Registry // src/capability.rs use std::collections::HashMap; use std::sync::Arc; use parking_lot::RwLock; use uuid::Uuid; use thiserror::Error; #[derive(Debug, Error)] pub enum CapabilityError { #[error("capability not found: {0}")] NotFound(u64), #[error("capability type mismatch: expected {expected}, got {actual}")] TypeMismatch { expected: &'static str, actual: &'static str }, #[error("permission denied: {0}")] Denied(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CapType { FsRead, FsWrite, NetConnect, NetListen, Clock, Random, } #[derive(Debug)] pub struct Capability { pub id: u64, pub cap_type: CapType, pub metadata: CapMetadata, } #[derive(Debug, Clone)] pub enum CapMetadata { FsRead { virtual_root: String, real_path: std::path::PathBuf }, FsWrite { virtual_root: String, real_path: std::path::PathBuf, max_bytes: u64 }, NetConnect { allowed_hosts: Vec<String>, allowed_ports: Vec<u16> }, NetListen { bind_addr: std::net::SocketAddr, max_connections: usize }, Clock, Random, } #[derive(Default)] pub struct CapabilityRegistry { inner: RwLock<HashMap<u64, Capability>>, next_id: RwLock<u64>, } impl CapabilityRegistry { pub fn new() -> Self { Self::default() } pub fn grant(&self, cap_type: CapType, metadata: CapMetadata) -> u64 { let mut next = self.next_id.write(); let id = *next; *next += 1; self.inner.write().insert(id, Capability { id, cap_type, metadata }); id } pub fn get(&self, id: u64) -> Result<&Capability, CapabilityError> { self.inner.read() .get(&id) .ok_or(CapabilityError::NotFound(id)) } pub fn revoke(&self, id: u64) -> bool { self.inner.write().remove(&id).is_some() } } 4.2 Host Function Signatures (Guest-Facing) Guest modules import these from the env namespace: // src/host_functions.rs use smolvm::{Instance, Val, ValType}; use crate::capability::{CapabilityRegistry, CapabilityError, CapType, CapMetadata}; use std::sync::Arc; pub struct HostContext { pub caps: Arc<CapabilityRegistry>, pub fuel_consumed: u64, } // Filesystem read: (cap_handle: u64, offset: u64, len: u32, ptr: u32) -> i32 (bytes read or -1) extern "C" fn fs_read( ctx: &mut HostContext, cap_handle: u64, offset: u64, len: u32, ptr: u32, ) -> i32 { let cap = match ctx.caps.get(cap_handle) { Ok(c) if c.cap_type == CapType::FsRead => c, Ok(_) => return -1, Err(_) => return -1, }; let CapMetadata::FsRead { real_path, .. } = &cap.metadata else { unreachable!() }; // Read from real_path at offset into guest memory at ptr // Implementation uses smolvm's memory access APIs // ... (see section 5 for full implementation) 0 // placeholder } // Network connect: (cap_handle: u64, host_ptr: u32, host_len: u32, port: u16) -> i32 (conn_handle or -1) extern "C" fn net_connect( ctx: &mut HostContext, cap_handle: u64, host_ptr: u32, host_len: u32, port: u16, ) -> i32 { // Validate against allowed_hosts/ports in capability metadata // Return new capability handle for the connected socket -1 } // Register all host functions with an Instance pub fn register_host_functions(instance: &mut Instance, ctx: HostContext) { instance.add_host_func("env", "fs_read", fs_read as *const (), ctx.clone()); instance.add_host_func("env", "fs_write", fs_write as *const (), ctx.clone()); instance.add_host_func("env", "net_connect", net_connect as *const (), ctx.clone()); instance.add_host_func("env", "net_send", net_send as *const (), ctx.clone()); instance.add_host_func("env", "net_recv", net_recv as *const (), ctx.clone()); instance.add_host_func("env", "clock_now", clock_now as *const (), ctx.clone()); instance.add_host_func("env", "random_bytes", random_bytes as *const (), ctx); } --- 5. Building the Sandbox Core The Sandbox struct owns the smolvm Instance, fuel budget, and capability registry. // src/sandbox.rs use smolvm::{Engine, Module, Instance, Memory, Val, ValType, Trap}; use std::sync::Arc; use std::time::{Duration, Instant}; use crate::capability::{CapabilityRegistry, CapabilityError, CapType, CapMetadata}; use crate::host_functions::{HostContext, register_host_functions}; use thiserror::Error; use tracing::{info, warn, instrument}; #[derive(Debug, Error)] pub enum SandboxError { #[error("module load failed: {0}")] ModuleLoad(String), #[error("instantiation failed: {0}")] Instantiation(String), #[error("execution trapped: {0}")] Trap(String), #[error("fuel exhausted ({0} instructions)")] FuelExhausted(u64), #[error("capability error: {0}")] Capability(#[from] CapabilityError), #[error("memory access error: {0}")] MemoryAccess(String), #[error("timeout after {0:?}")] Timeout(Duration), } pub struct Sandbox { engine: Engine, module: Module, instance: Instance, memory: Memory, caps: Arc<CapabilityRegistry>, fuel_budget: u64, deadline: Option<Instant>, } impl Sandbox { /// Create a new sandbox from a WASM binary. /// `fuel_budget` = max instructions (smolvm fuel units ≈ 1 instruction each). /// `timeout` = wall-clock deadline. #[instrument(skip(wasm_bytes))] pub fn new( wasm_bytes: &[u8], caps: Arc<CapabilityRegistry>, fuel_budget: u64, timeout: Option<Duration>, ) -> Result<Self, SandboxError> { let engine = Engine::default(); let module = Module::new(&engine, wasm_bytes) .map_err(|e| SandboxError::ModuleLoad(e.to_string()))?; let mut instance = Instance::new(&module) .map_err(|e| SandboxError::Instantiation(e.to_string()))?; let memory = instance.memory(0) .ok_or_else(|| SandboxError::Instantiation("no memory exported".into()))?; let ctx = HostContext { caps: caps.clone(), fuel_consumed: 0, }; register_host_functions(&mut instance, ctx); // Set initial fuel instance.set_fuel(fuel_budget) .map_err(|e| SandboxError::Instantiation(e.to_string()))?; Ok(Self { engine, module, instance, memory, caps, fuel_budget, deadline: timeout.map(|t| Instant::now() + t), }) } /// Call an exported function by name with JSON args, return JSON result. #[instrument(skip(self, args))] pub fn call(&mut self, func_name: &str, args: &serde_json::Value) -> Result<serde_json::Value, SandboxError> { // Check deadline if let Some(deadline) = self.deadline { if Instant::now() > deadline { return Err(SandboxError::Timeout(deadline - Instant::now())); } } // Serialize args to guest memory let args_json = serde_json::to_vec(args).map_err(|e| SandboxError::MemoryAccess(e.to_string()))?; let (args_ptr, args_len) = self.write_guest_memory(&args_json)?; // Find exported function let func = self.instance.func(func_name) .ok_or_else(|| SandboxError::Trap(format!("function '{func_name}' not exported")))?; // Call: (args_ptr, args_len) -> (result_ptr, result_len) let results = func.call(&mut self.instance, &[ Val::I32(args_ptr as i32), Val::I32(args_len as i32), ]).map_err(|e| SandboxError::Trap(e.to_string()))?; // Check fuel let fuel_used = self.fuel_budget - self.instance.fuel_consumed().unwrap_or(0); if fuel_used >= self.fuel_budget { return Err(SandboxError::FuelExhausted(self.fuel_budget)); } // Read result from guest memory let result_ptr = results[0].unwrap_i32() as u32; let result_len = results[1].unwrap_i32() as u32; let result_bytes = self.read_guest_memory(result_ptr, result_len)?; serde_json::from_slice(&result_bytes) .map_err(|e| SandboxError::MemoryAccess(e.to_string())) } fn write_guest_memory(&mut self, data: &[u8]) -> Result<(u32, u32), SandboxError> { // Allocate in guest via exported `alloc` function, then write let alloc = self.instance.func("alloc") .ok_or_else(|| SandboxError::Trap("alloc not exported".into()))?; let results = alloc.call(&mut self.instance, &[Val::I32(data.len() as i32)]) .map_err(|e| SandboxError::Trap(e.to_string()))?; let ptr = results[0].unwrap_i32() as u32; self.memory.write(&mut self.instance, ptr as usize, data) .map_err(|e| SandboxError::MemoryAccess(e.to_string()))?; Ok((ptr, data.len() as u32)) } fn read_guest_memory(&self, ptr: u32, len: u32) -> Result<Vec<u8>, SandboxError> { let mut buf = vec![0u8; len as usize]; self.memory.read(&self.instance, ptr as usize, &mut buf) .map_err(|e| SandboxError::MemoryAccess(e.to_string()))?; Ok(buf) } /// Get remaining fuel (for monitoring) pub fn remaining_fuel(&self) -> u64 { self.instance.fuel_consumed().unwrap_or(0) } } 5.1 Guest Module Interface (Rust → WASM) Tools compile to WASM with this ABI: // tool-sdk/src/lib.rs — shared by all tool authors use serde::{Deserialize, Serialize}; #[derive(Deserialize)] pub struct ToolInput { pub capability_handles: Vec<u64>, pub payload: serde_json::Value, } #[derive(Serialize)] pub struct ToolOutput { pub success: bool, pub result: Option<serde_json::Value>, pub error: Option<String>, pub fuel_used: u64, } // Exported by guest: // - alloc(len: i32) -> i32 // - dealloc(ptr: i32, len: i32) // - execute(args_ptr: i32, args_len: i32) -> (result_ptr: i32, result_len: i32) A minimal tool example: // tools/read_file/src/lib.rs use tool_sdk::{ToolInput, ToolOutput}; use smolvm::host::{fs_read, fs_write}; #[no_mangle] extern "C" fn execute(args_ptr: i32, args_len: i32) -> (i32, i32) { // Read input from host memory (via smolvm's memory API exposed to guest) // For brevity, assume helper functions `read_input` / `write_output` let input: ToolInput = read_input(args_ptr, args_len); let cap_handle = input.capability_handles.get(0).copied().unwrap_or(0); let path = input.payload.get("path").and_then(|v| v.as_str()).unwrap_or(""); // Read file in chunks via host capability let mut offset = 0u64; let mut content = Vec::new(); let mut buf = [0u8; 4096]; loop { let n = unsafe { fs_read(cap_handle, offset, buf.len() as u32, buf.as_mut_ptr() as u32) }; if n <= 0 { break; } content.extend_from_slice(&buf[..n as usize]); offset += n as u64; if n < buf.len() as i32 { break; } } let output = ToolOutput { success: true, result: Some(serde_json::json!({ "content": String::from_utf8_lossy(&content) })), error: None, fuel_used: 0, // host fills this }; write_output(&output) } Compile tools with: cargo build --target wasm32-unknown-unknown --release -Z build-std=std,panic_abort -Z build-std-features=panic_immediate_abort --- 6. Filesystem Capabilities with Virtual Paths Never expose real paths to the guest. Map virtual roots → real paths at capability grant time. // src/fs_capability.rs use std::path::{Path, PathBuf}; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use crate::capability::{CapabilityRegistry, CapType, CapMetadata, CapabilityError}; use crate::sandbox::SandboxError; use thiserror::Error; use tracing::warn; #[derive(Debug, Error)] pub enum FsError { #[error("path escapes virtual root: {0}")] PathEscape(String), #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("capability error: {0}")] Capability(#[from] CapabilityError), } pub struct FsCapability { pub virtual_root: String, // e.g. "/workspace" pub real_path: PathBuf, // e.g. "/home/user/project" pub read_only: bool, pub max_file_size: u64, } impl FsCapability { pub fn new(virtual_root: String, real_path: PathBuf, read_only: bool, max_file_size: u64) -> Self { Self { virtual_root, real_path, read_only, max_file_size } } /// Resolve a guest-provided virtual path to a real path, ensuring no escape. pub fn resolve(&self, virtual_path: &str) -> Result<PathBuf, FsError> { // Normalize: remove ./, resolve ../ but stay within virtual_root let virt = Path::new(self.virtual_root.trim_end_matches('/')); let requested = Path::new(virtual_path.trim_start_matches('/')); // Join and normalize let joined = virt.join(requested); let normalized = joined .components() .filter(|c| !matches!(c, std::path::Component::CurDir)) .collect::<PathBuf>(); // Ensure the result starts with virtual_root if !normalized.starts_with(virt) { return Err(FsError::PathEscape(format!( "attempted escape from {} via {}", self.virtual_root, virtual_path ))); } // Map to real path let relative = normalized.strip_prefix(virt).unwrap(); Ok(self.real_path.join(relative)) } pub fn read(&self, virtual_path: &str, offset: u64, buf: &mut [u8]) -> Result<usize, FsError> { let real = self.resolve(virtual_path)?; let mut file = File::open(real)?; file.seek(SeekFrom::Start(offset))?; let n = file.read(buf)?; Ok(n) } pub fn write(&self, virtual_path: &str, offset: u64, data: &[u8]) -> Result<usize, FsError> { if self.read_only { return Err(FsError::Capability(CapabilityError::Denied("read-only capability".into()))); } if (offset + data.len() as u64) > self.max_file_size { return Err(FsError::Capability(CapabilityError::Denied( format!("write exceeds max file size {}", self.max_file_size) ))); } let real = self.resolve(virtual_path)?; if let Some(parent) = real.parent() { std::fs::create_dir_all(parent)?; } let mut file = std::fs::OpenOptions::new() .create(true) .write(true) .open(real)?; file.seek(SeekFrom::Start(offset))?; let n = file.write(data)?; Ok(n) } /// Register this capability in the registry pub fn grant(self, registry: &CapabilityRegistry) -> u64 { let cap_type = if self.read_only { CapType::FsRead } else { CapType::FsWrite }; registry.grant(cap_type, CapMetadata::FsRead { virtual_root: self.virtual_root, real_path: self.real_path, }) } } Host function implementation (complete fs_read from section 4): // In host_functions.rs extern "C" fn fs_read( ctx: &mut HostContext, cap_handle: u64, offset: u64, len: u32, ptr: u32, ) -> i32 { let cap = match ctx.caps.get(cap_handle) { Ok(c) if c.cap_type == CapType::FsRead => c, _ => return -1, }; let CapMetadata::FsRead { real_path, virtual_root } = &cap.metadata else { unreachable!() }; // Read virtual path from guest memory (first 4 bytes = path_len, then path) let path_len = match ctx.read_guest_u32(ptr) { Ok(v) => v, Err(_) => return -1, }; let path_bytes = match ctx.read_guest_memory(ptr + 4, path_len) { Ok(v) => v, Err(_) => return -1, }; let virtual_path = match String::from_utf8(path_bytes) { Ok(v) => v, Err(_) => return -1, }; // Resolve and read let fs_cap = FsCapability::new(virtual_root.clone(), real_path.clone(), true, u64::MAX); let mut buf = vec![0u8; len as usize]; match fs_cap.read(&virtual_path, offset, &mut buf) { Ok(n) => { // Write result back to guest memory at ptr if ctx.write_guest_memory(ptr, &buf[..n]).is_err() { return -1; } n as i32 } Err(_) => -1, } } --- 7. Network Capabilities & Egress Control Network capabilities are allow-listed by host:port. No wildcard DNS resolution — the agent must declare exact endpoints. // src/net_capability.rs use std::net::{SocketAddr, ToSocketAddrs}; use std::collections::HashSet; use tokio::net::{TcpStream, TcpListener}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use crate::capability::{CapabilityRegistry, CapType, CapMetadata, CapabilityError}; use thiserror::Error; use tracing::{info, warn}; #[derive(Debug, Error)] pub enum NetError { #[error("host not in allow-list: {0}")] HostNotAllowed(String), #[error("port not in allow-list: {0}")] PortNotAllowed(u16), #[error("connection failed: {0}")] ConnectionFailed(String), #[error("capability error: {0}")] Capability(#[from] CapabilityError), } #[derive(Debug, Clone)] pub struct NetConnectCap { pub allowed_hosts: HashSet<String>, // exact hostnames or IPs pub allowed_ports: HashSet<u16>, pub max_concurrent: usize, pub active_connections: std::sync::Arc<std::sync::atomic::AtomicUsize>, } impl NetConnectCap { pub fn new(allowed_hosts: Vec<String>, allowed_ports: Vec<u16>, max_concurrent: usize) -> Self { Self { allowed_hosts: allowed_hosts.into_iter().collect(), allowed_ports: allowed_ports.into_iter().collect(), max_concurrent, active_connections: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), } } pub async fn connect(&self, host: &str, port: u16) -> Result<TcpStream, NetError> { if !self.allowed_hosts.contains(host) { return Err(NetError::HostNotAllowed(host.to_string())); } if !self.allowed_ports.contains(&port) { return Err(NetError::PortNotAllowed(port)); } let current = self.active_connections.load(std::sync::atomic::Ordering::Acquire); if current >= self.max_concurrent { return Err(NetError::ConnectionFailed("max concurrent connections reached".into())); } let addr = format!("{}:{}", host, port) .to_socket_addrs() .map_err(|e| NetError::ConnectionFailed(e.to_string()))? .next() .ok_or_else(|| NetError::ConnectionFailed("no addresses resolved".into()))?; self.active_connections.fetch_add(1, std::sync::atomic::Ordering::AcqRel); let stream = TcpStream::connect(addr).await .map_err(|e| { self.active_connections.fetch_sub(1, std::sync::atomic::Ordering::AcqRel); NetError::ConnectionFailed(e.to_string()) })?; Ok(stream) } pub fn release(&self) { self.active_connections.fetch_sub(1, std::sync::atomic::Ordering::AcqRel); } pub fn grant(self, registry: &CapabilityRegistry) -> u64 { registry.grant(CapType::NetConnect, CapMetadata::NetConnect { allowed_hosts: self.allowed_hosts.into_iter().collect(), allowed_ports: self.allowed_ports.into_iter().collect(), }) } } // Host function: net_connect returns a NEW capability handle for the connected socket extern "C" fn net_connect( ctx: &mut HostContext, cap_handle: u64, host_ptr: u32, host_len: u32, port: u16, ) -> i32 { // ... validate capability, read host from guest memory, call NetConnectCap::connect // On success, grant a new CapType::NetConnect handle for the *connected socket* // (metadata stores the TcpStream in a side table keyed by handle) -1 // placeholder } Egress logging — every connection attempt (success or failure) emits a structured log: { "timestamp": "2025-01-15T10:23:45.123Z", "event": "net_connect", "capability_handle": 42, "host": "api.github.com", "port": 443, "allowed": true, "result": "success", "latency_ms": 142 } --- 8. Deterministic Replay & Audit Logging Determinism requires: 1. Fixed fuel budget — same instructions always execute 2. No host non-determinism — clock, random, network are capabilities with recorded inputs 3. Structured audit log — every capability invocation logged with inputs/outputs 8.1 Audit Logger // src/audit.rs use serde::{Serialize, Deserialize}; use std::sync::Arc; use parking_lot::Mutex; use std::fs::OpenOptions; use std::io::{Write, BufWriter}; use chrono::Utc; use uuid::Uuid; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct AuditEntry { pub id: Uuid, pub timestamp: chrono::DateTime<Utc>, pub sandbox_id: Uuid, pub event_type: AuditEventType, pub capability_handle: Option<u64>, pub input: serde_json::Value, pub output: serde_json::Value, pub fuel_used: u64, pub duration_us: u64, pub success: bool, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(tag = "type")] pub enum AuditEventType { FsRead { virtual_path: String, bytes_read: usize }, FsWrite { virtual_path: String, bytes_written: usize }, NetConnect { host: String, port: u16 }, NetSend { bytes: usize }, NetRecv { bytes: usize }, ClockNow, RandomBytes { len: usize }, ToolCall { tool_name: String }, ToolReturn, } pub struct AuditLogger { writer: Mutex<BufWriter<std::fs::File>>, sandbox_id: Uuid, } impl AuditLogger { pub fn new(log_path: &std::path::Path, sandbox_id: Uuid) -> std::io::Result<Self> { let file = OpenOptions::new() .create(true) .append(true) .open(log_path)?; Ok(Self { writer: Mutex::new(BufWriter::new(file)), sandbox_id, }) } pub fn log(&self, entry: AuditEntry) { let mut writer = self.writer.lock(); let mut json = serde_json::to_vec(&entry).unwrap(); json.push(b'\n'); let _ = writer.write_all(&json); let _ = writer.flush(); } /// Convenience: log a capability invocation with timing pub fn log_capability< F, Fut, R, E >( &self, event_type: AuditEventType, cap_handle: u64, input: serde_json::Value, f: F, ) -> Result<R, E> where F: FnOnce() -> Result<R, E>, E: std::fmt::Debug, { let start = std::time::Instant::now(); let result = f(); let duration = start.elapsed(); let (output, success) = match &result { Ok(v) => (serde_json::to_value(v).unwrap_or_default(), true), Err(e) => (serde_json::json!({ "error": format!("{:?}", e) }), false), }; self.log(AuditEntry { id: Uuid::new_v4(), timestamp: Utc::now(), sandbox_id: self.sandbox_id, event_type, capability_handle: Some(cap_handle), input, output, fuel_used: 0, // filled by sandbox duration_us: duration.as_micros() as u64, success, }); result } } 8.2 Replay Engine Given an audit log, replay executes the same tool with recorded capability responses: // src/replay.rs use crate::audit::{AuditEntry, AuditEventType}; use crate::sandbox::{Sandbox, SandboxError}; use serde_json::Value; use std::collections::HashMap; use anyhow::Result; pub struct ReplayEngine { sandbox: Sandbox, capability_responses: HashMap<u64, Vec<Value>>, // cap_handle -> queue of recorded outputs } impl ReplayEngine { pub fn from_log(sandbox: Sandbox, log_path: &std::path::Path) -> Result<Self> { let mut responses = HashMap::new(); for line in std::fs::read_to_string(log_path)?.lines() { let entry: AuditEntry = serde_json::from_str(line)?; if let Some(handle) = entry.capability_handle { if entry.success { responses.entry(handle).or_default().push(entry.output); } } } // Reverse so we can pop from front for v in responses.values_mut() { v.reverse(); } Ok(Self { sandbox, capability_responses: responses }) } pub fn replay_call(&mut self, func: &str, args: &Value) -> Result<Value, SandboxError> { // Override host functions to return recorded responses instead of executing // This requires sandbox to support a "replay mode" — see implementation note below unimplemented!("requires sandbox host function hooks") } } > ponytail: Full replay requires hooking host functions at the smolvm level. For now, audit logs give you post-mortem debugging. True replay = ~200 lines more (swap HostContext for a ReplayContext that returns recorded values). --- 9. Integrating with an Agent Loop The sandbox is a tool in the agent's toolbox. Here's a minimal agent loop using rig or raw reqwest + your sandbox: // src/agent.rs use crate::sandbox::{Sandbox, SandboxError}; use crate::capability::{CapabilityRegistry, CapType, CapMetadata, FsCapability, NetConnectCap}; use crate::audit::AuditLogger; use serde_json::{json, Value}; use std::sync::Arc; use std::collections::HashMap; use tracing::{info, error}; use uuid::Uuid; pub struct AgentSandbox { sandbox: Sandbox, audit: AuditLogger, tools: HashMap<String, ToolDef>, } #[derive(Clone)] pub struct ToolDef { pub name: String, pub description: String, pub parameters: Value, // JSON Schema pub wasm_bytes: Vec<u8>, pub required_capabilities: Vec<CapType>, } impl AgentSandbox { pub fn new( tools: Vec<ToolDef>, fs_roots: Vec<FsCapability>, net_caps: Vec<NetConnectCap>, fuel_budget: u64, timeout: Option<std::time::Duration>, ) -> Result<Self, SandboxError> { let caps = Arc::new(CapabilityRegistry::new()); let sandbox_id = Uuid::new_v4(); // Grant capabilities for fs in fs_roots { fs.grant(&caps); } for net in net_caps { net.grant(&caps); } // For simplicity, use the first tool's WASM as the sandbox module // In production: dynamic module loading per tool, or a single "tool runner" module let wasm_bytes = tools.first().map(|t| t.wasm_bytes.clone()).unwrap_or_default(); let sandbox = Sandbox::new(&wasm_bytes, caps.clone(), fuel_budget, timeout)?; let audit = AuditLogger::new( std::path::Path::new(&format!("./audit-{}.jsonl", sandbox_id)), sandbox_id, )?; let tools_map = tools.into_iter().map(|t| (t.name.clone(), t)).collect(); Ok(Self { sandbox, audit, tools: tools_map }) } pub async fn execute_tool(&mut self, name: &str, args: Value) -> Result<Value, SandboxError> { let tool = self.tools.get(name).ok_or_else(|| SandboxError::Trap(format!("tool {} not found", name)))?; // Build capability handles for this tool let mut cap_handles = Vec::new(); for cap_type in &tool.required_capabilities { // In real impl: filter caps by type, maybe by tool-specific policy // For now, grant all matching caps } let input = json!({ "capability_handles": cap_handles, "payload": args }); let start = std::time::Instant::now(); let result = self.sandbox.call("execute", &input); let duration = start.elapsed(); self.audit.log(crate::audit::AuditEntry { id: Uuid::new_v4(), timestamp: chrono::Utc::now(), sandbox_id: self.audit.sandbox_id, event_type: crate::audit::AuditEventType::ToolCall { tool_name: name.into() }, capability_handle: None, input, output: result.as_ref().map(|v| v.clone()).unwrap_or_else(|e| json!({ "error": e.to_string() })), fuel_used: self.sandbox.fuel_budget - self.sandbox.remaining_fuel(), duration_us: duration.as_micros() as u64, success: result.is_ok(), }); result } } 9.1 Example: LLM → Tool Call → Sandbox // Example usage #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .json() .with_env_filter("info") .init(); // Load tool WASM let read_file_wasm = std::fs::read("tools/read_file.wasm")?; let http_get_wasm = std::fs::read("tools/http_get.wasm")?; let tools = vec![ ToolDef { name: "read_file".into(), description: "Read a file from the workspace".into(), parameters: json!({ "type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"] }), wasm_bytes: read_file_wasm, required_capabilities: vec![CapType::FsRead], }, ToolDef { name: "http_get".into(), description: "GET a URL from allowed hosts".into(), parameters: json!({ "type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"] }), wasm_bytes: http_get_wasm, required_capabilities: vec![CapType::NetConnect], }, ]; let fs_cap = FsCapability::new( "/workspace".into(), std::path::PathBuf::from("/home/user/project"), true, // read-only 10_000_000, // 10MB max file ); let net_cap = NetConnectCap::new( vec!["api.github.com".into(), "raw.githubusercontent.com".into()], vec![443], 5, ); let mut agent = AgentSandbox::new( tools, vec![fs_cap], vec![net_cap], 10_000_000, // 10M fuel ≈ 10ms CPU Some(std::time::Duration::from_secs(30)), )?; // Simulate LLM tool call let result = agent.execute_tool("read_file", json!({ "path": "Cargo.toml" })).await?; println!("Result: {}", serde_json::to_string_pretty(&result)?); Ok(()) } --- 10. Production Hardening Checklist | Layer | Measure | Implementation | |-------|---------|----------------| | Process | Run sandbox in dedicated process | tokio::process::Command + cap-std for ambient authority reduction | | Memory | rlimit_as, rlimit_data, rlimit_memlock | nix::sys::resource::setrlimit before Sandbox::new | | CPU | rlimit_cpu + smolvm fuel | Dual enforcement: fuel for instruction count, rlimit for wall time | | FDs | rlimit_nofile | Set to 16 (stdin/out/err + few caps) | | Network | Network namespace (Linux) | unshare(CLONE_NEWNET) in sidecar process | | Filesystem | pivot_root / chroot / landlock | Landlock LSM (Linux 5.13+) for unprivileged path confinement | | Supply Chain | Signed WASM modules | cosign verify at load time; embed public key in binary | | Observability | OpenTelemetry export | tracing-opentelemetry → Jaeger/OTel collector | | Secrets | No secrets in sandbox | Inject via capability metadata at grant time (not in WASM) | | Updates | Hot-reload without downtime | Arc<Module> swap; in-flight calls finish on old version | 10.1 Landlock Integration (Linux) // src/landlock.rs — optional, Linux-only #[cfg(target_os = "linux")] mod landlock { use landlock::{Ruleset, RulesetCreated, AccessFs, AccessNet}; use std::path::Path; pub fn restrict_fs(ruleset: &mut Ruleset, allowed_roots: &[&Path]) -> anyhow::Result<()> { for root in allowed_roots { ruleset.handle_access(AccessFs::from_all())? .add_path(root, AccessFs::READ_FILE | AccessFs::READ_DIR)?; } Ok(()) } pub fn restrict_net(ruleset: &mut Ruleset, allowed_ports: &[u16]) -> anyhow::Result<()> { // Landlock network support is limited; prefer network namespace Ok(()) } } Apply before creating the Sandbox: #[cfg(target_os = "linux")] let mut ruleset = landlock::Ruleset::default(); landlock::restrict_fs(&mut ruleset, &[ Path::new("/home/user/project"), ])?; let _enforced = ruleset.enforce()?; // Process + all children now confined --- Frequently Asked Questions Q: Why not use WASI preview2 / wasi:cli / wasi:http? WASI preview2 adds ~200KB and complex component model wiring. For local agent tooling where you control both host and guest, custom capability-based host functions are simpler, faster, and more auditable. Use WASI when you need portable modules that run on any WASI runtime. Q: How do I handle tools that need long-running connections (WebSockets, SSE)? Grant a NetListen capability for inbound, or a persistent NetConnect with a keep-alive socket capability. The socket capability handle owns the TcpStream in a host-side table. Guest calls net_send/net_recv with that handle. Fuel metering still applies — yield via async host functions if needed. Q: Can I run multiple tools in one sandbox instance? Yes — compile a tool router module that exports execute(tool_name, args) and internally dispatches to linked tool modules (via wasm-merge or dynamic linking). Single sandbox, single fuel budget, single audit stream. Q: What about Windows/macOS support? smolvm is pure Rust — works everywhere. Landlock is Linux-only; on macOS use sandbox-exec (Seatbelt), on Windows use Job Objects + AppContainer. The capability registry and host functions are platform-agnostic. Q: How do I debug a trapped tool? 1. Check audit log for the failing capability call 2. Replay with RUST_BACKTRACE=1 and a debug build of the tool WASM 3. smolvm traps include instruction pointer — map to source via wasm-debug / lldb with DWARF --- Next steps: Add a wasm-component-model adapter for polyglot tools (Go, TypeScript → WASM), integrate with rig-core for a full agent framework, and build a dashboard that visualizes capability flows from audit logs. The sandbox is ~800 lines of Rust. The security model is explicit, auditable, and local. Your agents can now use tools without borrowing your shell.

    1 reaction · 1 comment

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

Scan pending.

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days
smol-rs/smolvm mentions in the last 30 days