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:
Illia Polosukhin
2026-02-02 23:22:52 -08:00
co-authored by Claude Opus 4.5
parent 45bbfa026d
commit 32bfd24154
21 changed files with 5115 additions and 66 deletions
+423
View File
@@ -0,0 +1,423 @@
//! Extended capabilities for WASM sandbox.
//!
//! Defines the capability system that controls what a WASM tool can do.
//! All capabilities are opt-in; tools have NO access by default.
//!
//! # Capability Types
//!
//! - **Workspace**: Read files from the agent's workspace
//! - **HTTP**: Make HTTP requests to allowlisted endpoints
//! - **ToolInvoke**: Call other tools via aliases
//! - **Secrets**: Check if secrets exist (never read values)
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::secrets::CredentialMapping;
/// All capabilities that can be granted to a WASM tool.
///
/// By default, all capabilities are `None` (disabled).
/// Each must be explicitly granted.
#[derive(Debug, Clone, Default)]
pub struct Capabilities {
/// Read files from workspace.
pub workspace_read: Option<WorkspaceCapability>,
/// Make HTTP requests.
pub http: Option<HttpCapability>,
/// Invoke other tools.
pub tool_invoke: Option<ToolInvokeCapability>,
/// Check if secrets exist.
pub secrets: Option<SecretsCapability>,
}
impl Capabilities {
/// Create capabilities with no permissions.
pub fn none() -> Self {
Self::default()
}
/// Enable workspace read with the given allowed prefixes.
pub fn with_workspace_read(mut self, prefixes: Vec<String>) -> Self {
self.workspace_read = Some(WorkspaceCapability {
allowed_prefixes: prefixes,
reader: None,
});
self
}
/// Enable HTTP requests with the given configuration.
pub fn with_http(mut self, http: HttpCapability) -> Self {
self.http = Some(http);
self
}
/// Enable tool invocation with the given aliases.
pub fn with_tool_invoke(mut self, aliases: HashMap<String, String>) -> Self {
self.tool_invoke = Some(ToolInvokeCapability {
aliases,
rate_limit: RateLimitConfig::default(),
});
self
}
/// Enable secret existence checks.
pub fn with_secrets(mut self, allowed: Vec<String>) -> Self {
self.secrets = Some(SecretsCapability {
allowed_names: allowed,
});
self
}
}
/// 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>;
}
/// HTTP request capability configuration.
#[derive(Debug, Clone)]
pub struct HttpCapability {
/// Allowed endpoint patterns.
pub allowlist: Vec<EndpointPattern>,
/// Credential mappings (secret name -> injection location).
pub credentials: HashMap<String, CredentialMapping>,
/// Rate limiting configuration.
pub rate_limit: RateLimitConfig,
/// Maximum request body size in bytes.
pub max_request_bytes: usize,
/// Maximum response body size in bytes.
pub max_response_bytes: usize,
/// Request timeout.
pub timeout: Duration,
}
impl Default for HttpCapability {
fn default() -> Self {
Self {
allowlist: Vec::new(),
credentials: HashMap::new(),
rate_limit: RateLimitConfig::default(),
max_request_bytes: 1024 * 1024, // 1 MB
max_response_bytes: 10 * 1024 * 1024, // 10 MB
timeout: Duration::from_secs(30),
}
}
}
impl HttpCapability {
/// Create a new HTTP capability with an allowlist.
pub fn new(allowlist: Vec<EndpointPattern>) -> Self {
Self {
allowlist,
..Default::default()
}
}
/// Add a credential mapping.
pub fn with_credential(mut self, name: impl Into<String>, mapping: CredentialMapping) -> Self {
self.credentials.insert(name.into(), mapping);
self
}
/// Set rate limiting.
pub fn with_rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
self.rate_limit = rate_limit;
self
}
/// Set request timeout.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
/// Set max request body size.
pub fn with_max_request_bytes(mut self, bytes: usize) -> Self {
self.max_request_bytes = bytes;
self
}
/// Set max response body size.
pub fn with_max_response_bytes(mut self, bytes: usize) -> Self {
self.max_response_bytes = bytes;
self
}
}
/// Pattern for matching allowed HTTP endpoints.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointPattern {
/// Hostname pattern (e.g., "api.example.com", "*.example.com").
pub host: String,
/// Path prefix (e.g., "/v1/", "/api/").
pub path_prefix: Option<String>,
/// Allowed HTTP methods (empty = all methods allowed).
pub methods: Vec<String>,
}
impl EndpointPattern {
/// Create a pattern for a specific host.
pub fn host(host: impl Into<String>) -> Self {
Self {
host: host.into(),
path_prefix: None,
methods: Vec::new(),
}
}
/// Add a path prefix constraint.
pub fn with_path_prefix(mut self, prefix: impl Into<String>) -> Self {
self.path_prefix = Some(prefix.into());
self
}
/// Restrict to specific HTTP methods.
pub fn with_methods(mut self, methods: Vec<String>) -> Self {
self.methods = methods;
self
}
/// Check if this pattern matches a URL and method.
pub fn matches(&self, url_host: &str, url_path: &str, method: &str) -> bool {
// Check host
if !self.host_matches(url_host) {
return false;
}
// Check path prefix
if let Some(ref prefix) = self.path_prefix {
if !url_path.starts_with(prefix) {
return false;
}
}
// Check method
if !self.methods.is_empty() {
let method_upper = method.to_uppercase();
if !self
.methods
.iter()
.any(|m| m.to_uppercase() == method_upper)
{
return false;
}
}
true
}
/// Check if host pattern matches (public for allowlist validation).
pub fn host_matches(&self, url_host: &str) -> bool {
if self.host == url_host {
return true;
}
// Support wildcard: *.example.com matches sub.example.com
if let Some(suffix) = self.host.strip_prefix("*.") {
if url_host.ends_with(suffix) && url_host.len() > suffix.len() {
// Ensure there's a dot before the suffix (or it's the whole thing)
let prefix = &url_host[..url_host.len() - suffix.len()];
if prefix.ends_with('.') || prefix.is_empty() {
return true;
}
}
}
false
}
}
/// Tool invocation capability.
#[derive(Debug, Clone, Default)]
pub struct ToolInvokeCapability {
/// Mapping from alias to real tool name.
/// WASM calls tools by alias, never by real name.
pub aliases: HashMap<String, String>,
/// Rate limiting for tool calls.
pub rate_limit: RateLimitConfig,
}
impl ToolInvokeCapability {
/// Create with a set of aliases.
pub fn new(aliases: HashMap<String, String>) -> Self {
Self {
aliases,
rate_limit: RateLimitConfig::default(),
}
}
/// Resolve an alias to a real tool name.
pub fn resolve_alias(&self, alias: &str) -> Option<&str> {
self.aliases.get(alias).map(|s| s.as_str())
}
}
/// Secrets capability (existence check only).
#[derive(Debug, Clone, Default)]
pub struct SecretsCapability {
/// Secret names this tool can check existence of.
/// Supports glob: "openai_*" matches "openai_key", "openai_org".
pub allowed_names: Vec<String>,
}
impl SecretsCapability {
/// Check if a secret name is allowed.
pub fn is_allowed(&self, name: &str) -> bool {
for pattern in &self.allowed_names {
if pattern == name {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
if name.starts_with(prefix) {
return true;
}
}
}
false
}
}
/// Rate limiting configuration.
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
/// Maximum requests per minute.
pub requests_per_minute: u32,
/// Maximum requests per hour.
pub requests_per_hour: u32,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
requests_per_minute: 60,
requests_per_hour: 1000,
}
}
}
impl RateLimitConfig {
/// Create a restrictive rate limit.
pub fn restrictive() -> Self {
Self {
requests_per_minute: 10,
requests_per_hour: 100,
}
}
/// Create a permissive rate limit.
pub fn permissive() -> Self {
Self {
requests_per_minute: 120,
requests_per_hour: 5000,
}
}
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::capabilities::{Capabilities, EndpointPattern, SecretsCapability};
#[test]
fn test_capabilities_default_is_none() {
let caps = Capabilities::default();
assert!(caps.workspace_read.is_none());
assert!(caps.http.is_none());
assert!(caps.tool_invoke.is_none());
assert!(caps.secrets.is_none());
}
#[test]
fn test_endpoint_pattern_exact_host() {
let pattern = EndpointPattern::host("api.example.com");
assert!(pattern.matches("api.example.com", "/", "GET"));
assert!(!pattern.matches("other.example.com", "/", "GET"));
}
#[test]
fn test_endpoint_pattern_wildcard_host() {
let pattern = EndpointPattern::host("*.example.com");
assert!(pattern.matches("api.example.com", "/", "GET"));
assert!(pattern.matches("sub.api.example.com", "/", "GET"));
assert!(!pattern.matches("example.com", "/", "GET"));
assert!(!pattern.matches("notexample.com", "/", "GET"));
}
#[test]
fn test_endpoint_pattern_path_prefix() {
let pattern = EndpointPattern::host("api.example.com").with_path_prefix("/v1/");
assert!(pattern.matches("api.example.com", "/v1/users", "GET"));
assert!(pattern.matches("api.example.com", "/v1/", "GET"));
assert!(!pattern.matches("api.example.com", "/v2/users", "GET"));
assert!(!pattern.matches("api.example.com", "/", "GET"));
}
#[test]
fn test_endpoint_pattern_methods() {
let pattern = EndpointPattern::host("api.example.com")
.with_methods(vec!["GET".to_string(), "POST".to_string()]);
assert!(pattern.matches("api.example.com", "/", "GET"));
assert!(pattern.matches("api.example.com", "/", "get")); // case insensitive
assert!(pattern.matches("api.example.com", "/", "POST"));
assert!(!pattern.matches("api.example.com", "/", "DELETE"));
}
#[test]
fn test_secrets_capability_exact_match() {
let cap = SecretsCapability {
allowed_names: vec!["openai_key".to_string()],
};
assert!(cap.is_allowed("openai_key"));
assert!(!cap.is_allowed("anthropic_key"));
}
#[test]
fn test_secrets_capability_glob() {
let cap = SecretsCapability {
allowed_names: vec!["openai_*".to_string()],
};
assert!(cap.is_allowed("openai_key"));
assert!(cap.is_allowed("openai_org"));
assert!(!cap.is_allowed("anthropic_key"));
}
#[test]
fn test_capabilities_builder() {
let caps = Capabilities::none()
.with_workspace_read(vec!["context/".to_string()])
.with_secrets(vec!["test_*".to_string()]);
assert!(caps.workspace_read.is_some());
assert!(caps.secrets.is_some());
assert!(caps.http.is_none());
}
}