Files
optimclaw/src/tools/wasm/runtime.rs
T
33ef0a6ea5 fix: security hardening across all layers (#35)
* fix: comprehensive security hardening across all layers

Critical:
- Replace --dangerously-skip-permissions with explicit tool allowlist
  via settings.json (Claude Code bridge)
- Constant-time token comparison (subtle crate) in web auth and
  orchestrator auth to prevent timing attacks

High:
- Revoke tokens and clean up handles on container creation failure
- Drop SETUID/SETGID capabilities from containers (keep only CHOWN)
- Disable redirect following in HTTP tool and WASM wrapper (SSRF)
- Reject URL userinfo (@) in WASM allowlist parser (host confusion)
- Fix binary body bypassing leak detection (from_utf8 -> from_utf8_lossy)
- Protect identity files from LLM overwrites (prompt injection defense)
- Prevent tool shadowing: built-in tools cannot be replaced dynamically
- User-scoped job APIs: list/detail/cancel/restart/prompt/events/files
- CORS restricted to localhost origins, WebSocket origin validation
- Sandbox shell fail-closed: no silent fallback to unsandboxed execution
- Scrub secrets from log broadcaster before SSE broadcast
- XSS sanitization on rendered markdown in web UI
- WASM epoch ticker thread so timeout deadlines actually fire

Medium:
- Cap state transition history at 200 entries
- SSE/WebSocket connection limit (100 max)
- Request body size limit (1MB)
- Response body size limit enforcement in WASM HTTP
- UTF-8 safe string truncation (routine engine, shell tool)
- Fix PolicyAction::Sanitize to actually run the sanitizer
- TOCTOU fix in scheduler and context manager (hold write lock)
- Project file serving moved behind auth
- Path traversal guard on project_id
- Session file permissions set to 0600 on unix
- AtomicUsize for routine running_count (panic-safe)
- Completion detection hardened against false positives and tool injection
- Tool output no longer drives job completion (only LLM response)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address security review findings across all layers

- Fix path traversal sandbox bypass via lexical normalization (file.rs)
- Fix SSRF via DNS rebinding with pre-request hostname resolution (http.rs)
- Add token budget enforcement on LLM calls (reasoning.rs, state.rs)
- Fix cross-user chat history leak with ownership verification (store.rs, server.rs)
- Add sliding-window rate limiter on gateway chat endpoint (server.rs)
- Harden extension install: HTTPS-only, 50MB cap, WASM magic validation (manager.rs)
- Add destructive command blocklist that overrides shell auto-approval (shell.rs)
- Add 5MB response body size cap to HTTP tool (http.rs)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor: deduplicate shared helpers and remove dead code

Extract floor_char_boundary and llm_signals_completion into src/util.rs,
unifying diverging phrase lists from agent/worker.rs and worker/runtime.rs.
Remove dead RespondResult::usage(), duplicate PROTECTED_IDENTITY_FILES
constant, double LeakDetector scanning in WebLogLayer, and invalid
0.0.0.0 origin from WebSocket allow list.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review findings and CI test failures

- Fix record_failed_approve: .truncate(true) wiped the attempts file
  before reading, so failed pairing attempts never accumulated and
  rate limiting never triggered.
- Guard wizard WASM test: skip gracefully when channel build artifacts
  are absent (CI doesn't compile wasm32-wasip2 targets).
- Fix DNS rebinding check: use port 0 instead of hardcoded 443, since
  the port is irrelevant for hostname resolution.
- Remove hardcoded CORS port 3001: the dynamic addr.port() entries
  already cover the actual server port.
- Require WebSocket Origin header: reject connections that omit it
  entirely, since browsers always send Origin for WS upgrades and a
  missing header indicates a non-browser client bypassing the check.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address second round of PR review findings

- store.rs: reintroduce file locking around read-modify-write in
  record_failed_approve (concurrent callers could clobber each other).
- sse.rs: replace load+check+fetch_add with atomic fetch_update in both
  subscribe_raw() and subscribe() to prevent overshooting max_connections.
- ws.rs: decrement WS tracker before early return when subscribe_raw()
  returns None (connection limit reached), fixing a counter leak.
- server.rs: parse WS Origin host exactly instead of prefix matching,
  preventing bypass via crafted origins like http://localhost.evil.com.
- workspace_integration.rs: skip tests gracefully when Postgres is
  unreachable instead of panicking (fixes 10 CI failures).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add Origin header to WS integration tests

The Origin header requirement added in a3b0190 broke the WS gateway
integration tests. Test clients now send Origin: http://127.0.0.1:{port}
to match the server's localhost validation.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-13 05:25:20 +00:00

334 lines
11 KiB
Rust

//! WASM tool runtime for managing compiled components.
//!
//! Follows the principle: compile once at registration, instantiate fresh per execution.
//! This matches NEAR blockchain patterns for deterministic, isolated execution.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use wasmtime::{Config, Engine, OptLevel};
use crate::tools::wasm::error::WasmError;
use crate::tools::wasm::limits::{FuelConfig, ResourceLimits};
/// Default epoch tick interval. Each tick increments the engine's epoch counter,
/// which causes any store with an expired epoch deadline to trap.
pub const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(500);
/// Configuration for the WASM runtime.
#[derive(Debug, Clone)]
pub struct WasmRuntimeConfig {
/// Default resource limits for tools.
pub default_limits: ResourceLimits,
/// Fuel configuration.
pub fuel_config: FuelConfig,
/// Whether to cache compiled modules.
pub cache_compiled: bool,
/// Directory for compiled module cache.
pub cache_dir: Option<PathBuf>,
/// Cranelift optimization level.
pub optimization_level: OptLevel,
}
impl Default for WasmRuntimeConfig {
fn default() -> Self {
Self {
default_limits: ResourceLimits::default(),
fuel_config: FuelConfig::default(),
cache_compiled: true,
cache_dir: None,
optimization_level: OptLevel::Speed,
}
}
}
impl WasmRuntimeConfig {
/// Create a minimal config for testing.
pub fn for_testing() -> Self {
Self {
default_limits: ResourceLimits::default()
.with_memory(1024 * 1024) // 1 MB
.with_fuel(100_000)
.with_timeout(Duration::from_secs(5)),
fuel_config: FuelConfig::with_limit(100_000),
cache_compiled: false,
cache_dir: None,
optimization_level: OptLevel::None, // Faster compilation for tests
}
}
}
/// A compiled WASM component ready for instantiation.
///
/// Contains the pre-compiled component plus cached metadata extracted
/// from the component during preparation.
#[derive(Debug)]
pub struct PreparedModule {
/// Tool name.
pub name: String,
/// Tool description (cached from component).
pub description: String,
/// Parameter schema JSON (cached from component).
pub schema: serde_json::Value,
/// Compiled component bytes (can be serialized for caching).
component_bytes: Vec<u8>,
/// Resource limits for this tool.
pub limits: ResourceLimits,
}
impl PreparedModule {
/// Get the compiled component bytes.
pub fn component_bytes(&self) -> &[u8] {
&self.component_bytes
}
}
/// WASM tool runtime.
///
/// Manages the Wasmtime engine and a cache of prepared modules.
pub struct WasmToolRuntime {
/// Wasmtime engine with configured settings.
engine: Engine,
/// Runtime configuration.
config: WasmRuntimeConfig,
/// Cache of prepared modules by name.
modules: RwLock<HashMap<String, Arc<PreparedModule>>>,
}
impl WasmToolRuntime {
/// Create a new runtime with the given configuration.
pub fn new(config: WasmRuntimeConfig) -> Result<Self, WasmError> {
let mut wasmtime_config = Config::new();
// Enable fuel consumption for CPU limiting
if config.fuel_config.enabled {
wasmtime_config.consume_fuel(true);
}
// Enable epoch interruption as a backup timeout mechanism
wasmtime_config.epoch_interruption(true);
// Enable component model (WASI Preview 2)
wasmtime_config.wasm_component_model(true);
// Disable threads (simplifies security model)
wasmtime_config.wasm_threads(false);
// Set optimization level
wasmtime_config.cranelift_opt_level(config.optimization_level);
// Disable debug info in production for smaller modules
wasmtime_config.debug_info(false);
let engine = Engine::new(&wasmtime_config).map_err(|e| {
WasmError::EngineCreationFailed(format!("Failed to create Wasmtime engine: {}", e))
})?;
// Spawn a background thread that periodically increments the engine's
// epoch counter. Without this, epoch_deadline_trap() never fires and
// WASM modules can spin indefinitely even with a deadline set.
let ticker_engine = engine.clone();
std::thread::Builder::new()
.name("wasm-epoch-ticker".into())
.spawn(move || {
loop {
std::thread::sleep(EPOCH_TICK_INTERVAL);
ticker_engine.increment_epoch();
}
})
.map_err(|e| {
WasmError::EngineCreationFailed(format!(
"Failed to spawn epoch ticker thread: {}",
e
))
})?;
Ok(Self {
engine,
config,
modules: RwLock::new(HashMap::new()),
})
}
/// Get the Wasmtime engine.
pub fn engine(&self) -> &Engine {
&self.engine
}
/// Get the runtime configuration.
pub fn config(&self) -> &WasmRuntimeConfig {
&self.config
}
/// Prepare a WASM component for execution.
///
/// This validates and compiles the component, extracting metadata.
/// The compiled component is cached for fast instantiation.
pub async fn prepare(
&self,
name: &str,
wasm_bytes: &[u8],
limits: Option<ResourceLimits>,
) -> Result<Arc<PreparedModule>, WasmError> {
// Check if already prepared
if let Some(module) = self.modules.read().await.get(name) {
return Ok(Arc::clone(module));
}
let name = name.to_string();
let wasm_bytes = wasm_bytes.to_vec();
let engine = self.engine.clone();
let default_limits = self.config.default_limits.clone();
// Compile in blocking task (Wasmtime compilation is synchronous)
let prepared = tokio::task::spawn_blocking(move || {
// Validate and compile the component
let component = wasmtime::component::Component::new(&engine, &wasm_bytes)
.map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
// We need to instantiate briefly to extract metadata.
// In a full implementation, we'd use WIT bindgen to get typed access.
// For now, we extract what we can from the component.
let description = extract_tool_description(&engine, &component)?;
let schema = extract_tool_schema(&engine, &component)?;
Ok::<_, WasmError>(PreparedModule {
name: name.clone(),
description,
schema,
component_bytes: wasm_bytes,
limits: limits.unwrap_or(default_limits),
})
})
.await
.map_err(|e| WasmError::ExecutionPanicked(format!("Preparation task panicked: {}", e)))??;
let prepared = Arc::new(prepared);
// Cache the prepared module
if self.config.cache_compiled {
self.modules
.write()
.await
.insert(prepared.name.clone(), Arc::clone(&prepared));
}
tracing::info!(
name = %prepared.name,
"Prepared WASM tool for execution"
);
Ok(prepared)
}
/// Get a prepared module by name.
pub async fn get(&self, name: &str) -> Option<Arc<PreparedModule>> {
self.modules.read().await.get(name).cloned()
}
/// Remove a prepared module from the cache.
pub async fn remove(&self, name: &str) -> Option<Arc<PreparedModule>> {
self.modules.write().await.remove(name)
}
/// List all prepared module names.
pub async fn list(&self) -> Vec<String> {
self.modules.read().await.keys().cloned().collect()
}
/// Clear all cached modules.
pub async fn clear(&self) {
self.modules.write().await.clear();
}
}
/// Extract tool description from a compiled component.
///
/// In a full implementation, this would use WIT bindgen to call the description() export.
/// For now, we return a placeholder since we can't easily introspect without more setup.
fn extract_tool_description(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<String, WasmError> {
// TODO: Use WIT bindgen to properly extract description
// This requires instantiating with a linker, which needs host functions.
// For now, tools should have their description set externally.
Ok("WASM sandboxed tool".to_string())
}
/// Extract tool schema from a compiled component.
///
/// In a full implementation, this would use WIT bindgen to call the schema() export.
fn extract_tool_schema(
_engine: &Engine,
_component: &wasmtime::component::Component,
) -> Result<serde_json::Value, WasmError> {
// TODO: Use WIT bindgen to properly extract schema
// For now, return a minimal schema that accepts any object.
Ok(serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": true
}))
}
impl std::fmt::Debug for WasmToolRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WasmToolRuntime")
.field("config", &self.config)
.field("modules", &"<RwLock<HashMap>>")
.finish()
}
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::limits::ResourceLimits;
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
#[test]
fn test_runtime_config_default() {
let config = WasmRuntimeConfig::default();
assert!(config.cache_compiled);
assert!(config.fuel_config.enabled);
}
#[test]
fn test_runtime_config_for_testing() {
let config = WasmRuntimeConfig::for_testing();
assert!(!config.cache_compiled);
assert_eq!(config.default_limits.memory_bytes, 1024 * 1024);
}
#[test]
fn test_runtime_creation() {
let config = WasmRuntimeConfig::for_testing();
let runtime = WasmToolRuntime::new(config).unwrap();
// Engine was created successfully, which validates the config
assert!(runtime.config().fuel_config.enabled);
}
#[tokio::test]
async fn test_module_cache_operations() {
let config = WasmRuntimeConfig::for_testing();
let runtime = WasmToolRuntime::new(config).unwrap();
// Initially empty
assert!(runtime.list().await.is_empty());
assert!(runtime.get("test").await.is_none());
}
#[test]
fn test_prepared_module_limits() {
let limits = ResourceLimits::default()
.with_memory(5 * 1024 * 1024)
.with_fuel(500_000);
assert_eq!(limits.memory_bytes, 5 * 1024 * 1024);
assert_eq!(limits.fuel, 500_000);
}
}