//! Host functions for WASM sandbox. //! //! Implements a minimal, security-focused host API following VMLogic patterns //! from NEAR blockchain. The principle is: deny by default, grant minimal capabilities. //! //! # Extended API (V2) //! //! In addition to the basic log/time/workspace functions, the host now provides: //! //! - **http_request**: Make HTTP requests to allowlisted endpoints with credential injection //! - **tool_invoke**: Call other tools via aliases //! - **secret_exists**: Check if a secret exists (never read values) //! //! # Security Architecture //! //! ```text //! WASM Tool ──▶ Host Function ──▶ Allowlist ──▶ Credential ──▶ Execute //! (untrusted) (boundary) Validator Injector Request //! │ //! ▼ //! ◀────── Leak Detector ◀────── Response //! (sanitized, no secrets) //! ``` use std::time::{SystemTime, UNIX_EPOCH}; use crate::tools::wasm::capabilities::Capabilities; use crate::tools::wasm::error::WasmError; /// Maximum log entries per execution (prevents log spam attacks). const MAX_LOG_ENTRIES: usize = 1000; /// Maximum bytes per log message. const MAX_LOG_MESSAGE_BYTES: usize = 4096; /// Log levels matching the WIT interface. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LogLevel { Trace, Debug, Info, Warn, Error, } impl std::fmt::Display for LogLevel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { LogLevel::Trace => write!(f, "TRACE"), LogLevel::Debug => write!(f, "DEBUG"), LogLevel::Info => write!(f, "INFO"), LogLevel::Warn => write!(f, "WARN"), LogLevel::Error => write!(f, "ERROR"), } } } /// A single log entry from WASM execution. #[derive(Debug, Clone)] pub struct LogEntry { pub level: LogLevel, pub message: String, pub timestamp_millis: u64, } /// Host state maintained during WASM execution. /// /// This is the "VMLogic" equivalent, it tracks all side effects and enforces limits. /// Extended in V2 to support HTTP requests, tool invocation, and secret checks. pub struct HostState { /// Collected log entries. logs: Vec, /// Whether logging is still allowed (false after MAX_LOG_ENTRIES). logging_enabled: bool, /// Granted capabilities. capabilities: Capabilities, /// Count of log entries dropped due to rate limiting. logs_dropped: usize, /// User ID for secret/credential lookups. user_id: Option, /// HTTP request count for rate limiting within this execution. http_request_count: u32, /// Tool invoke count for rate limiting within this execution. tool_invoke_count: u32, } impl std::fmt::Debug for HostState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("HostState") .field("logs_count", &self.logs.len()) .field("logging_enabled", &self.logging_enabled) .field("logs_dropped", &self.logs_dropped) .field("user_id", &self.user_id) .field("http_request_count", &self.http_request_count) .field("tool_invoke_count", &self.tool_invoke_count) .finish() } } impl HostState { /// Create a new host state with the given capabilities. pub fn new(capabilities: Capabilities) -> Self { Self { logs: Vec::new(), logging_enabled: true, capabilities, logs_dropped: 0, user_id: None, http_request_count: 0, tool_invoke_count: 0, } } /// Create a new host state with user context. pub fn new_with_user(capabilities: Capabilities, user_id: impl Into) -> Self { Self { logs: Vec::new(), logging_enabled: true, capabilities, logs_dropped: 0, user_id: Some(user_id.into()), http_request_count: 0, tool_invoke_count: 0, } } /// Create a minimal host state with no capabilities. pub fn minimal() -> Self { Self::new(Capabilities::default()) } /// Get the user ID if set. pub fn user_id(&self) -> Option<&str> { self.user_id.as_deref() } /// Get the capabilities. pub fn capabilities(&self) -> &Capabilities { &self.capabilities } /// Log a message from WASM. /// /// Returns Ok(()) if logged, Err if rate limited or too long. pub fn log(&mut self, level: LogLevel, message: String) -> Result<(), WasmError> { if !self.logging_enabled { self.logs_dropped += 1; return Ok(()); // Silently drop, don't fail execution } if self.logs.len() >= MAX_LOG_ENTRIES { self.logging_enabled = false; self.logs_dropped += 1; tracing::warn!( "WASM log limit reached ({} entries), further logs dropped", MAX_LOG_ENTRIES ); return Ok(()); } // Truncate overly long messages let message = if message.len() > MAX_LOG_MESSAGE_BYTES { let mut truncated = message[..MAX_LOG_MESSAGE_BYTES].to_string(); truncated.push_str("... (truncated)"); truncated } else { message }; let timestamp_millis = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); self.logs.push(LogEntry { level, message, timestamp_millis, }); Ok(()) } /// Get current timestamp in milliseconds. pub fn now_millis(&self) -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } /// Read from workspace if capability granted. pub fn workspace_read(&self, path: &str) -> Result, WasmError> { // Check if workspace capability is granted let capability = match &self.capabilities.workspace_read { Some(cap) => cap, None => return Ok(None), // No capability, return None }; // Validate path (security critical) validate_workspace_path(path)?; // Check allowed prefixes if any are specified if !capability.allowed_prefixes.is_empty() { let allowed = capability .allowed_prefixes .iter() .any(|prefix| path.starts_with(prefix)); if !allowed { tracing::debug!( path = path, allowed = ?capability.allowed_prefixes, "WASM workspace read denied: path not in allowed prefixes" ); return Ok(None); } } // Actually read from workspace match &capability.reader { Some(reader) => Ok(reader.read(path)), None => Ok(None), // No reader configured } } /// Get collected logs after execution. pub fn take_logs(&mut self) -> Vec { std::mem::take(&mut self.logs) } /// Get number of logs dropped due to rate limiting. pub fn logs_dropped(&self) -> usize { self.logs_dropped } /// Check if a secret exists (does not expose value). /// /// Returns false if: /// - Secrets capability not granted /// - Secret name not in allowed list /// - User ID not set pub fn secret_exists(&self, name: &str) -> bool { let capability = match &self.capabilities.secrets { Some(cap) => cap, None => return false, }; // Check if name is allowed capability.is_allowed(name) } /// Check if HTTP capability is available for a given URL and method. /// /// Returns an error message if not allowed. pub fn check_http_allowed(&self, url: &str, method: &str) -> Result<(), String> { let capability = self .capabilities .http .as_ref() .ok_or_else(|| "HTTP capability not granted".to_string())?; // Use the allowlist validator use crate::tools::wasm::allowlist::AllowlistValidator; let validator = AllowlistValidator::new(capability.allowlist.clone()); let result = validator.validate(url, method); if result.is_allowed() { Ok(()) } else { Err(format!("HTTP request not allowed: {:?}", result)) } } /// Check if tool invocation is allowed for an alias. /// /// Returns the real tool name if allowed, error otherwise. pub fn check_tool_invoke_allowed(&self, alias: &str) -> Result { let capability = self .capabilities .tool_invoke .as_ref() .ok_or_else(|| "Tool invocation capability not granted".to_string())?; capability .resolve_alias(alias) .map(|s| s.to_string()) .ok_or_else(|| format!("Unknown tool alias: {}", alias)) } /// Increment HTTP request counter and check rate limit. /// /// Returns error if rate limit exceeded. pub fn record_http_request(&mut self) -> Result<(), String> { // Verify HTTP capability exists let _capability = self .capabilities .http .as_ref() .ok_or_else(|| "HTTP capability not granted".to_string())?; self.http_request_count += 1; // Simple per-execution rate limit (additional to global rate limiter) // This prevents a single execution from making too many requests const MAX_REQUESTS_PER_EXECUTION: u32 = 50; if self.http_request_count > MAX_REQUESTS_PER_EXECUTION { return Err(format!( "Too many HTTP requests in single execution (max {})", MAX_REQUESTS_PER_EXECUTION )); } Ok(()) } /// Increment tool invoke counter and check rate limit. /// /// Returns error if rate limit exceeded. pub fn record_tool_invoke(&mut self) -> Result<(), String> { self.tool_invoke_count += 1; const MAX_INVOKES_PER_EXECUTION: u32 = 20; if self.tool_invoke_count > MAX_INVOKES_PER_EXECUTION { return Err(format!( "Too many tool invocations in single execution (max {})", MAX_INVOKES_PER_EXECUTION )); } Ok(()) } /// Get HTTP request count for this execution. pub fn http_request_count(&self) -> u32 { self.http_request_count } /// Get tool invoke count for this execution. pub fn tool_invoke_count(&self) -> u32 { self.tool_invoke_count } } /// Validate a workspace path for security. /// /// Blocks path traversal attacks and absolute paths. fn validate_workspace_path(path: &str) -> Result<(), WasmError> { // Block absolute paths if path.starts_with('/') { return Err(WasmError::PathTraversalBlocked( "absolute paths not allowed".to_string(), )); } // Block path traversal if path.contains("..") { return Err(WasmError::PathTraversalBlocked( "parent directory references not allowed".to_string(), )); } // Block null bytes if path.contains('\0') { return Err(WasmError::PathTraversalBlocked( "null bytes not allowed".to_string(), )); } // Block Windows-style absolute paths (just in case) if path.len() >= 2 && path.chars().nth(1) == Some(':') { return Err(WasmError::PathTraversalBlocked( "Windows-style paths not allowed".to_string(), )); } Ok(()) } #[cfg(test)] mod tests { use std::sync::Arc; use crate::tools::wasm::capabilities::{ Capabilities, SecretsCapability, WorkspaceCapability, WorkspaceReader, }; use crate::tools::wasm::host::{ HostState, LogLevel, MAX_LOG_ENTRIES, MAX_LOG_MESSAGE_BYTES, validate_workspace_path, }; struct MockReader { content: String, } impl WorkspaceReader for MockReader { fn read(&self, _path: &str) -> Option { Some(self.content.clone()) } } #[test] fn test_logging_basic() { let mut state = HostState::minimal(); state .log(LogLevel::Info, "test message".to_string()) .unwrap(); let logs = state.take_logs(); assert_eq!(logs.len(), 1); assert_eq!(logs[0].level, LogLevel::Info); assert_eq!(logs[0].message, "test message"); } #[test] fn test_logging_rate_limit() { let mut state = HostState::minimal(); // Fill up to limit for i in 0..MAX_LOG_ENTRIES { state .log(LogLevel::Debug, format!("message {}", i)) .unwrap(); } // This should be dropped silently state .log(LogLevel::Info, "should be dropped".to_string()) .unwrap(); assert_eq!(state.take_logs().len(), MAX_LOG_ENTRIES); assert_eq!(state.logs_dropped(), 1); } #[test] fn test_logging_truncation() { let mut state = HostState::minimal(); let long_message = "x".repeat(MAX_LOG_MESSAGE_BYTES + 1000); state.log(LogLevel::Info, long_message).unwrap(); let logs = state.take_logs(); assert!(logs[0].message.len() <= MAX_LOG_MESSAGE_BYTES + 20); // +20 for truncation suffix assert!(logs[0].message.ends_with("... (truncated)")); } #[test] fn test_now_millis() { let state = HostState::minimal(); let now = state.now_millis(); // Should be a reasonable timestamp (after 2020) assert!(now > 1577836800000); // Jan 1, 2020 } #[test] fn test_workspace_read_no_capability() { let state = HostState::minimal(); let result = state.workspace_read("context/test.md").unwrap(); assert!(result.is_none()); } #[test] fn test_workspace_read_with_capability() { let reader = Arc::new(MockReader { content: "test content".to_string(), }); let capabilities = Capabilities { workspace_read: Some(WorkspaceCapability { allowed_prefixes: vec![], reader: Some(reader), }), ..Default::default() }; let state = HostState::new(capabilities); let result = state.workspace_read("context/test.md").unwrap(); assert_eq!(result, Some("test content".to_string())); } #[test] fn test_workspace_read_prefix_restriction() { let reader = Arc::new(MockReader { content: "test content".to_string(), }); let capabilities = Capabilities { workspace_read: Some(WorkspaceCapability { allowed_prefixes: vec!["context/".to_string()], reader: Some(reader), }), ..Default::default() }; let state = HostState::new(capabilities); // Allowed prefix let result = state.workspace_read("context/test.md").unwrap(); assert!(result.is_some()); // Disallowed prefix let result = state.workspace_read("secrets/api_key.txt").unwrap(); assert!(result.is_none()); } #[test] fn test_path_validation_blocks_traversal() { assert!(validate_workspace_path("../etc/passwd").is_err()); assert!(validate_workspace_path("context/../secrets").is_err()); assert!(validate_workspace_path("context/test/../../secrets").is_err()); } #[test] fn test_path_validation_blocks_absolute() { assert!(validate_workspace_path("/etc/passwd").is_err()); assert!(validate_workspace_path("/context/test.md").is_err()); } #[test] fn test_path_validation_blocks_null_bytes() { assert!(validate_workspace_path("context/test\0.md").is_err()); } #[test] fn test_path_validation_blocks_windows_paths() { assert!(validate_workspace_path("C:\\Windows\\System32").is_err()); assert!(validate_workspace_path("D:secrets").is_err()); } #[test] fn test_path_validation_allows_valid_paths() { assert!(validate_workspace_path("context/test.md").is_ok()); assert!(validate_workspace_path("daily/2024-01-15.md").is_ok()); assert!(validate_workspace_path("projects/alpha/notes.md").is_ok()); assert!(validate_workspace_path("MEMORY.md").is_ok()); } #[test] fn test_secret_exists_no_capability() { let state = HostState::minimal(); assert!(!state.secret_exists("any_secret")); } #[test] fn test_secret_exists_with_capability() { let capabilities = Capabilities { secrets: Some(SecretsCapability { allowed_names: vec!["openai_*".to_string(), "exact_name".to_string()], }), ..Default::default() }; let state = HostState::new(capabilities); // Glob match assert!(state.secret_exists("openai_key")); assert!(state.secret_exists("openai_org")); // Exact match assert!(state.secret_exists("exact_name")); // Not allowed assert!(!state.secret_exists("stripe_key")); } #[test] fn test_http_request_rate_limit() { // Create state with HTTP capability enabled let capabilities = Capabilities { http: Some(crate::tools::wasm::capabilities::HttpCapability::default()), ..Default::default() }; let mut state = HostState::new(capabilities); // Should allow up to 50 requests for _ in 0..50 { assert!(state.record_http_request().is_ok()); } // 51st should fail assert!(state.record_http_request().is_err()); } #[test] fn test_tool_invoke_rate_limit() { // Create state with tool invoke capability enabled let capabilities = Capabilities { tool_invoke: Some(crate::tools::wasm::capabilities::ToolInvokeCapability::default()), ..Default::default() }; let mut state = HostState::new(capabilities); // Should allow up to 20 invocations for _ in 0..20 { assert!(state.record_tool_invoke().is_ok()); } // 21st should fail assert!(state.record_tool_invoke().is_err()); } #[test] fn test_new_with_user() { let state = HostState::new_with_user(Capabilities::default(), "user123"); assert_eq!(state.user_id(), Some("user123")); } }