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, ne

Posted on Oct 21 • Originally published at tamiz.pro
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.
Before writing code, define what we're defending against:
smolvm wins for in-process agent tooling because:
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.
cargo new agent-sandbox --edition 2021 cd agent-sandbox Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode ponytail: smolvm isn't on crates.io yet. Pin the git rev in production. Vendor it via cargo vendor for air-gapped builds.
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.
// 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 () } } Enter fullscreen mode Exit fullscreen mode 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 ); } Enter fullscreen mode Exit fullscreen mode 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 ) } } Enter fullscreen mode Exit fullscreen mode 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) Enter fullscreen mode Exit fullscreen mode 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 ) } Enter fullscreen mode Exit fullscreen mode Compile tools with:
cargo build --target wasm32-unknown-unknown --release -Z build-std = std,panic_abort -Z build-std-features = panic_immediate_abort Enter fullscreen mode Exit fullscreen mode 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, }) } } Enter fullscreen mode Exit fullscreen mode 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, } } Enter fullscreen mode Exit fullscreen mode 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 } Enter fullscreen mode Exit fullscreen mode 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 } Enter fullscreen mode Exit fullscreen mode 8. Deterministic Replay & Audit Logging Determinism requires:
// 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 } } Enter fullscreen mode Exit fullscreen mode 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" ) } } Enter fullscreen mode Exit fullscreen mode 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).
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 } } Enter fullscreen mode Exit fullscreen mode 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 (()) } Enter fullscreen mode Exit fullscreen mode 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 (()) } } Enter fullscreen mode Exit fullscreen mode 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 Enter fullscreen mode Exit fullscreen mode 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.
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.
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.
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.
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.