smol-rs

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars0
mentions1
01234Oct 5Oct 19Nov 3
UTC days · repositories smol-rs owns0 repositories

Package downloads

Cumulative recorded downloads

crates.io—
01234Oct 5Oct 19Nov 3No observations in this range
Oct 5crates.io: 0/3 packages observed

Across 3 known packages in repositories smol-rs owns. Cumulative totals include recorded downloads before the selected range. Missing observations leave gaps; totals may be incomplete.

Top repositories

smol-rs’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside smol-rs’s account where smol-rs opened or reviewed pull requests in the past 365 days.

Who noticed smol-rs

Everyone who starred or forked smol-rs’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

1post
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

Achievements

Awards earned by the repositories smol-rs owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.