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
  • 2. Why WebAssembly + smolvm?
  • 3. Project Setup & Dependencies
  • 4. Capability-Based Host API Design
  • 5. Building the Sandbox Core
  • 6. Filesystem Capabilities with Virtual Paths
  • 7. Network Capabilities & Egress Control
  • 8. Deterministic Replay & Audit Logging
  • 9. Integrating with an Agent Loop
  • 10. Production Hardening Checklist
  • 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, dat...
Source:https://dev.to/tamizuddin/beyond-the-prompt-building-safe-local-execution-environments-for-agentic-tooling-with-rust-and-1bp8