mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 00:29:24 +00:00
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]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
45bbfa026d
commit
32bfd24154
+253
-42
@@ -2,10 +2,29 @@
|
||||
//!
|
||||
//! 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::sync::Arc;
|
||||
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).
|
||||
@@ -44,46 +63,10 @@ pub struct LogEntry {
|
||||
pub timestamp_millis: u64,
|
||||
}
|
||||
|
||||
/// Capabilities that can be granted to a WASM tool.
|
||||
///
|
||||
/// By default, tools have NO capabilities. Each must be explicitly granted.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Capabilities {
|
||||
/// If Some, tool can read from workspace at these paths.
|
||||
/// Empty vec means workspace access granted but no paths allowed yet.
|
||||
/// None means workspace access completely disabled.
|
||||
pub workspace_read: Option<WorkspaceCapability>,
|
||||
}
|
||||
|
||||
/// Workspace read capability configuration.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct WorkspaceCapability {
|
||||
/// Allowed path prefixes (e.g., ["context/", "daily/"]).
|
||||
/// Empty means all paths allowed (within safety constraints).
|
||||
pub allowed_prefixes: Vec<String>,
|
||||
/// Function to actually read from workspace.
|
||||
/// This is injected by the runtime to avoid coupling to workspace impl.
|
||||
pub reader: Option<Arc<dyn WorkspaceReader>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkspaceCapability {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WorkspaceCapability")
|
||||
.field("allowed_prefixes", &self.allowed_prefixes)
|
||||
.field("reader", &self.reader.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for reading from workspace (allows mocking in tests).
|
||||
pub trait WorkspaceReader: Send + Sync {
|
||||
fn read(&self, path: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
/// Host state maintained during WASM execution.
|
||||
///
|
||||
/// This is the "VMLogic" equivalent, it tracks all side effects and enforces limits.
|
||||
#[derive(Debug)]
|
||||
/// Extended in V2 to support HTTP requests, tool invocation, and secret checks.
|
||||
pub struct HostState {
|
||||
/// Collected log entries.
|
||||
logs: Vec<LogEntry>,
|
||||
@@ -93,6 +76,25 @@ pub struct HostState {
|
||||
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 {
|
||||
@@ -103,6 +105,22 @@ impl HostState {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +129,16 @@ impl HostState {
|
||||
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.
|
||||
@@ -204,6 +232,114 @@ impl HostState {
|
||||
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.
|
||||
@@ -243,12 +379,15 @@ fn validate_workspace_path(path: &str) -> Result<(), WasmError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tools::wasm::host::{
|
||||
Capabilities, HostState, LogLevel, MAX_LOG_ENTRIES, MAX_LOG_MESSAGE_BYTES,
|
||||
WorkspaceCapability, WorkspaceReader, validate_workspace_path,
|
||||
};
|
||||
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,
|
||||
}
|
||||
@@ -330,6 +469,7 @@ mod tests {
|
||||
allowed_prefixes: vec![],
|
||||
reader: Some(reader),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let state = HostState::new(capabilities);
|
||||
@@ -348,6 +488,7 @@ mod tests {
|
||||
allowed_prefixes: vec!["context/".to_string()],
|
||||
reader: Some(reader),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let state = HostState::new(capabilities);
|
||||
@@ -392,4 +533,74 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user