mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 16:10:09 +00:00
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]>
607 lines
19 KiB
Rust
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"));
|
|
}
|
|
}
|