Something went wrong. Try again.
Flatpak-style launcher for sandboxed WebAssembly tools. Install, verify, and run .wasm binaries with explicit capability grants. Single binary, local-first, zero telemetry. wasmbox.orbiter.website
wasm rust wasmbox
Something went wrong. Try again.
9.0 kB · 296 lines
Rust
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297//! Wasmtime wrapper for executing sandboxed Wasm tools.
use std::path::Path;
use thiserror::Error;use wasmbox_shared::Capability;use wasmtime::Engine;use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};use wasmtime_wasi_http::body::HyperOutgoingBody;use wasmtime_wasi_http::types::{ default_send_request, HostFutureIncomingResponse, OutgoingRequestConfig,};use wasmtime_wasi_http::{HttpResult, WasiHttpCtx, WasiHttpView};
#[derive(Debug, Error)]pub enum RuntimeError { #[error("wasmtime error: {0}")] Wasmtime(#[from] wasmtime::Error),
#[error("wasm file not found: {0}")] FileNotFound(String),
#[error("io error: {0}")] Io(#[from] std::io::Error),
/// Tool called proc_exit with a non-zero code. Not a crash — forward to host. #[error("tool exited with non-zero status")] ToolExited,}
/// Configuration for running a Wasm tool.pub struct RunConfig { pub wasm_path: std::path::PathBuf, pub capabilities: Vec<Capability>, pub fuel_limit: Option<u64>, pub args: Vec<String>,}
struct HostState { ctx: WasiCtx, http_ctx: WasiHttpCtx, table: wasmtime::component::ResourceTable, allowed_hosts: Vec<String>,}
impl WasiView for HostState { fn ctx(&mut self) -> WasiCtxView<'_> { WasiCtxView { ctx: &mut self.ctx, table: &mut self.table, } }}
impl WasiHttpView for HostState { fn ctx(&mut self) -> &mut WasiHttpCtx { &mut self.http_ctx }
fn table(&mut self) -> &mut wasmtime::component::ResourceTable { &mut self.table }
fn send_request( &mut self, request: hyper::Request<HyperOutgoingBody>, config: OutgoingRequestConfig, ) -> HttpResult<HostFutureIncomingResponse> { // Enforce host allowlist from Network capability if self.allowed_hosts.is_empty() { return Err(wasmtime_wasi_http::HttpError::trap( wasmtime::Error::msg("network access denied: no Network capability granted"), )); }
if let Some(authority) = request.uri().authority() { let host = authority.host(); let allowed = self.allowed_hosts.iter().any(|h| { // Match "host:port" or just "host" h == authority.as_str() || h == host }); if !allowed { return Err(wasmtime_wasi_http::HttpError::trap(wasmtime::Error::msg( format!( "network access denied: host '{}' not in allowed list", authority ), ))); } }
Ok(default_send_request(request, config)) }}
/// Execute a Wasm component tool with the given configuration.pub fn run_tool(config: &RunConfig) -> Result<(), RuntimeError> { if !config.wasm_path.exists() { return Err(RuntimeError::FileNotFound( config.wasm_path.display().to_string(), )); }
let mut engine_config = wasmtime::Config::new(); engine_config.wasm_component_model(true);
if config.fuel_limit.is_some() { engine_config.consume_fuel(true); }
let engine = Engine::new(&engine_config)?; let wasm_bytes = std::fs::read(&config.wasm_path)?; let component = wasmtime::component::Component::new(&engine, &wasm_bytes)?;
let (wasi_ctx, allowed_hosts) = build_wasi_context(&config.capabilities, &config.args)?;
let host_state = HostState { ctx: wasi_ctx, http_ctx: WasiHttpCtx::new(), table: wasmtime::component::ResourceTable::new(), allowed_hosts, };
let mut store = wasmtime::Store::new(&engine, host_state);
if let Some(fuel) = config.fuel_limit { store.set_fuel(fuel)?; }
let mut linker = wasmtime::component::Linker::new(&engine);
// Full WASI CLI interfaces (stdin, stdout, filesystem, env, etc.) wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?; // Add wasi:http on top for tools that need outbound HTTP wasmtime_wasi_http::add_only_http_to_linker_sync(&mut linker)?;
let command = wasmtime_wasi::p2::bindings::sync::Command::instantiate( &mut store, &component, &linker, )?;
let result = command.wasi_cli_run().call_run(&mut store);
match result { Ok(Ok(())) => Ok(()), Ok(Err(())) => Err(RuntimeError::ToolExited), Err(e) => { // proc_exit(non_zero) surfaces as an I32Exit trap, not as Err(()). // Treat it the same as a clean non-zero return. if let Some(exit) = e.downcast_ref::<wasmtime_wasi::I32Exit>() { if exit.0 == 0 { Ok(()) } else { Err(RuntimeError::ToolExited) } } else { Err(RuntimeError::Wasmtime(e)) } } }}
fn build_wasi_context( capabilities: &[Capability], args: &[String],) -> Result<(WasiCtx, Vec<String>), RuntimeError> { let mut builder = WasiCtxBuilder::new(); let mut allowed_hosts: Vec<String> = Vec::new();
// WASI expects argv[0] to be the program name, followed by actual arguments. // We always set args so the tool gets a proper argv even if no extra args given. let mut full_args: Vec<&str> = vec!["tool"]; full_args.extend(args.iter().map(|s| s.as_str())); builder.args(&full_args);
for cap in capabilities { match cap { Capability::Stdin => { builder.inherit_stdin(); } Capability::Stdout => { builder.inherit_stdout(); builder.inherit_stderr(); } Capability::Env(vars) => { for var in vars { if let Ok(val) = std::env::var(var) { builder.env(var, &val); } } } Capability::Filesystem(grant) => { configure_filesystem(&mut builder, &grant.path, grant.read, grant.write)?; } Capability::Network(hosts) => { allowed_hosts.extend(hosts.clone()); } Capability::Clipboard => { // Clipboard is a host-specific capability, not part of WASI. // Would need a custom host function. } } }
Ok((builder.build(), allowed_hosts))}
fn configure_filesystem( builder: &mut WasiCtxBuilder, path: &str, read: bool, write: bool,) -> Result<(), RuntimeError> { let expanded = expand_path(path); let host_path = Path::new(&expanded);
if !host_path.exists() { return Err(RuntimeError::Io(std::io::Error::new( std::io::ErrorKind::NotFound, format!("preopened path not found: {path}"), ))); }
let dir_perms = match (read, write) { (true, true) => DirPerms::all(), (true, false) => DirPerms::READ, (false, true) => DirPerms::MUTATE, (false, false) => DirPerms::empty(), };
let file_perms = match (read, write) { (true, true) => FilePerms::all(), (true, false) => FilePerms::READ, (false, true) => FilePerms::WRITE, (false, false) => FilePerms::empty(), };
builder.preopened_dir(host_path, path, dir_perms, file_perms)?;
Ok(())}
fn expand_path(path: &str) -> String { if let Some(rest) = path.strip_prefix("~/") && let Some(home) = dirs::home_dir() { return home.join(rest).to_string_lossy().to_string(); } path.to_string()}
#[cfg(test)]mod tests { use super::*;
#[test] fn missing_wasm_file_returns_error() { let config = RunConfig { wasm_path: std::path::PathBuf::from("/nonexistent/tool.wasm"), capabilities: vec![], fuel_limit: None, args: vec![], }; assert!(run_tool(&config).is_err()); }
#[test] fn expand_tilde_path() { let expanded = expand_path("~/Documents"); assert!(!expanded.starts_with('~')); assert!(expanded.contains("Documents")); }
#[test] fn expand_absolute_path_unchanged() { let expanded = expand_path("/tmp/data"); assert_eq!(expanded, "/tmp/data"); }
#[test] fn allowed_hosts_from_network_capability() { let caps = vec![ Capability::Stdin, Capability::Network(vec![ "api.example.com".to_string(), "cdn.example.com".to_string(), ]), ]; let (_ctx, hosts) = build_wasi_context(&caps, &[]).unwrap(); assert_eq!(hosts, vec!["api.example.com", "cdn.example.com"]); }
#[test] fn no_network_capability_means_empty_hosts() { let caps = vec![Capability::Stdin, Capability::Stdout]; let (_ctx, hosts) = build_wasi_context(&caps, &[]).unwrap(); assert!(hosts.is_empty()); }}