Files
optimclaw/src/tools/wasm/host.rs
T
Illia PolosukhinandClaude Opus 4.5 32bfd24154 Add WASM sandbox secure API extension
Extends the WASM sandbox with HTTP API capabilities, secrets management,
tool aliasing, and leak detection. Key security principle: WASM never
sees credentials, injection happens at host boundary.

New modules:
- secrets: AES-256-GCM encrypted storage with HKDF key derivation
- leak_detector: Aho-Corasick + regex pattern matching for secret exfiltration
- capabilities: Extended capability system (HTTP, ToolInvoke, Secrets)
- allowlist: HTTP endpoint validation with glob patterns
- credential_injector: Host-boundary credential injection
- rate_limiter: Sliding window per-tool rate limiting
- storage: WASM binary storage with BLAKE3 integrity verification

Leak detection happens at two points:
1. Before HTTP request (prevents exfiltration via URL/headers/body)
2. After response (prevents exposure in outputs returned to WASM)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-02 23:22:52 -08:00

607 lines
19 KiB
Rust

//! 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<LogEntry>,
/// 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<String>,
/// 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<String>) -> 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<Option<String>, 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<LogEntry> {
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<String, String> {
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<String> {
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"));
}
}