Implementing WASM runtime

This commit is contained in:
Illia Polosukhin
2026-02-02 22:47:02 -08:00
parent 383fb21c07
commit aea3f47f8b
16 changed files with 2888 additions and 22 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ DATABASE_POOL_SIZE=10
# NEAR AI provides a unified interface to all models with user authentication
NEARAI_SESSION_TOKEN=sess_...
NEARAI_MODEL=claude-3-5-sonnet-20241022
NEARAI_BASE_URL=https://api.near.ai
NEARAI_BASE_URL=https://private.near.ai
# Channel Configuration
# CLI is always enabled
+1 -1
View File
@@ -165,7 +165,7 @@ DATABASE_URL=postgres://user:pass@localhost/near_agent
# NEAR AI (required)
NEARAI_SESSION_TOKEN=sess_...
NEARAI_MODEL=claude-3-5-sonnet-20241022
NEARAI_BASE_URL=https://api.near.ai
NEARAI_BASE_URL=https://private.near.ai
# Agent settings
AGENT_NAME=near-agent
Generated
+974 -6
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -64,6 +64,9 @@ secrecy = { version = "0.10", features = ["serde"] }
# The postgres feature provides ToSql/FromSql for postgres-types (shared by tokio-postgres)
pgvector = { version = "0.4", features = ["postgres"] }
# WASM sandbox for untrusted tool execution
wasmtime = { version = "28", features = ["component-model"] }
[dev-dependencies]
tokio-test = "0.4"
testcontainers-modules = { version = "0.11", features = ["postgres"] }
+84
View File
@@ -1,5 +1,6 @@
//! Configuration for the NEAR Agent.
use std::path::PathBuf;
use std::time::Duration;
use secrecy::{ExposeSecret, SecretString};
@@ -14,6 +15,7 @@ pub struct Config {
pub channels: ChannelsConfig,
pub agent: AgentConfig,
pub safety: SafetyConfig,
pub wasm: WasmConfig,
}
impl Config {
@@ -28,6 +30,7 @@ impl Config {
channels: ChannelsConfig::from_env()?,
agent: AgentConfig::from_env()?,
safety: SafetyConfig::from_env()?,
wasm: WasmConfig::from_env()?,
})
}
}
@@ -230,6 +233,87 @@ impl SafetyConfig {
}
}
/// WASM sandbox configuration.
#[derive(Debug, Clone)]
pub struct WasmConfig {
/// Whether WASM tool execution is enabled.
pub enabled: bool,
/// Default memory limit in bytes (default: 10 MB).
pub default_memory_limit: u64,
/// Default execution timeout in seconds (default: 60).
pub default_timeout_secs: u64,
/// Default fuel limit for CPU metering (default: 10M).
pub default_fuel_limit: u64,
/// Whether to cache compiled modules.
pub cache_compiled: bool,
/// Directory for compiled module cache.
pub cache_dir: Option<PathBuf>,
}
impl Default for WasmConfig {
fn default() -> Self {
Self {
enabled: true,
default_memory_limit: 10 * 1024 * 1024, // 10 MB
default_timeout_secs: 60,
default_fuel_limit: 10_000_000,
cache_compiled: true,
cache_dir: None,
}
}
}
impl WasmConfig {
fn from_env() -> Result<Self, ConfigError> {
Ok(Self {
enabled: optional_env("WASM_ENABLED")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "WASM_ENABLED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
default_memory_limit: parse_optional_env(
"WASM_DEFAULT_MEMORY_LIMIT",
10 * 1024 * 1024,
)?,
default_timeout_secs: parse_optional_env("WASM_DEFAULT_TIMEOUT_SECS", 60)?,
default_fuel_limit: parse_optional_env("WASM_DEFAULT_FUEL_LIMIT", 10_000_000)?,
cache_compiled: optional_env("WASM_CACHE_COMPILED")?
.map(|s| s.parse())
.transpose()
.map_err(|e| ConfigError::InvalidValue {
key: "WASM_CACHE_COMPILED".to_string(),
message: format!("must be 'true' or 'false': {e}"),
})?
.unwrap_or(true),
cache_dir: optional_env("WASM_CACHE_DIR")?.map(PathBuf::from),
})
}
/// Convert to WasmRuntimeConfig.
pub fn to_runtime_config(&self) -> crate::tools::wasm::WasmRuntimeConfig {
use crate::tools::wasm::{FuelConfig, ResourceLimits, WasmRuntimeConfig};
use std::time::Duration;
WasmRuntimeConfig {
default_limits: ResourceLimits {
memory_bytes: self.default_memory_limit,
fuel: self.default_fuel_limit,
timeout: Duration::from_secs(self.default_timeout_secs),
},
fuel_config: FuelConfig {
initial_fuel: self.default_fuel_limit,
enabled: true,
},
cache_compiled: self.cache_compiled,
cache_dir: self.cache_dir.clone(),
optimization_level: wasmtime::OptLevel::Speed,
}
}
}
// Helper functions
fn required_env(key: &str) -> Result<String, ConfigError> {
+1 -1
View File
@@ -91,7 +91,7 @@ pub struct StateTransition {
}
/// Context for a running job.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct JobContext {
/// Unique job ID.
pub job_id: Uuid,
+2 -1
View File
@@ -3,11 +3,12 @@
//! Tools are the agent's interface to the outside world. They can:
//! - Call external APIs
//! - Interact with the marketplace
//! - Execute sandboxed code
//! - Execute sandboxed code (via WASM sandbox)
//! - Delegate tasks to other services
pub mod builtin;
pub mod mcp;
pub mod wasm;
mod builder;
mod registry;
+65
View File
@@ -8,6 +8,9 @@ use tokio::sync::RwLock;
use crate::llm::ToolDefinition;
use crate::tools::builtin::{EchoTool, HttpTool, JsonTool, TimeTool};
use crate::tools::tool::Tool;
use crate::tools::wasm::{
Capabilities, ResourceLimits, WasmError, WasmToolRuntime, WasmToolWrapper,
};
/// Registry of available tools.
pub struct ToolRegistry {
@@ -105,6 +108,68 @@ impl ToolRegistry {
tracing::info!("Registered {} built-in tools", self.count());
}
/// Register a WASM tool from bytes.
///
/// This validates and compiles the WASM component, then registers it as a tool.
/// The tool will be executed in a sandboxed environment with the given capabilities.
///
/// # Example
///
/// ```ignore
/// let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::default())?);
/// let wasm_bytes = std::fs::read("my_tool.wasm")?;
///
/// registry.register_wasm(WasmToolRegistration {
/// name: "my_tool",
/// wasm_bytes: &wasm_bytes,
/// runtime: &runtime,
/// description: Some("My custom tool description"),
/// ..Default::default()
/// }).await?;
/// ```
pub async fn register_wasm(&self, reg: WasmToolRegistration<'_>) -> Result<(), WasmError> {
// Prepare the module (validates and compiles)
let prepared = reg
.runtime
.prepare(reg.name, reg.wasm_bytes, reg.limits)
.await?;
// Create the wrapper
let mut wrapper = WasmToolWrapper::new(Arc::clone(reg.runtime), prepared, reg.capabilities);
// Apply overrides if provided
if let Some(desc) = reg.description {
wrapper = wrapper.with_description(desc);
}
if let Some(s) = reg.schema {
wrapper = wrapper.with_schema(s);
}
// Register the tool
self.register(Arc::new(wrapper)).await;
tracing::info!(name = reg.name, "Registered WASM tool");
Ok(())
}
}
/// Configuration for registering a WASM tool.
pub struct WasmToolRegistration<'a> {
/// Unique name for the tool.
pub name: &'a str,
/// Raw WASM component bytes.
pub wasm_bytes: &'a [u8],
/// WASM runtime for compilation and execution.
pub runtime: &'a Arc<WasmToolRuntime>,
/// Security capabilities to grant the tool.
pub capabilities: Capabilities,
/// Optional resource limits (uses defaults if None).
pub limits: Option<ResourceLimits>,
/// Optional description override.
pub description: Option<&'a str>,
/// Optional parameter schema override.
pub schema: Option<serde_json::Value>,
}
impl Default for ToolRegistry {
+198
View File
@@ -0,0 +1,198 @@
//! WASM sandbox error types.
use std::fmt;
use thiserror::Error;
/// Errors that can occur during WASM tool execution.
#[derive(Debug, Error)]
pub enum WasmError {
/// Failed to create the Wasmtime engine.
#[error("Engine creation failed: {0}")]
EngineCreationFailed(String),
/// Failed to compile WASM bytes into a component.
#[error("Compilation failed: {0}")]
CompilationFailed(String),
/// WASM validation failed (malformed or invalid component).
#[error("Validation failed: {0}")]
ValidationFailed(String),
/// Failed to instantiate the component.
#[error("Instantiation failed: {0}")]
InstantiationFailed(String),
/// Component execution trapped (e.g., unreachable, memory access violation).
#[error("Execution trapped: {0}")]
Trapped(String),
/// Component panicked during execution.
#[error("Execution panicked: {0}")]
ExecutionPanicked(String),
/// Fuel limit exhausted during execution.
#[error("Fuel exhausted: execution exceeded {limit} fuel units")]
FuelExhausted {
/// The fuel limit that was exceeded.
limit: u64,
},
/// Memory limit exceeded during execution.
#[error("Memory limit exceeded: {used} bytes used, {limit} bytes allowed")]
MemoryExceeded {
/// Bytes used when limit was hit.
used: u64,
/// Maximum allowed bytes.
limit: u64,
},
/// Required export not found in component.
#[error("Missing export: {0}")]
MissingExport(String),
/// IO error (e.g., reading WASM file).
#[error("IO error: {0}")]
IoError(String),
/// Configuration error.
#[error("Configuration error: {0}")]
ConfigError(String),
/// Host function error.
#[error("Host error: {0}")]
HostError(String),
/// Execution timed out.
#[error("Execution timed out after {0:?}")]
Timeout(std::time::Duration),
/// Component returned an error response.
#[error("Tool error: {0}")]
ToolReturnedError(String),
/// Invalid JSON in tool response.
#[error("Invalid response JSON: {0}")]
InvalidResponseJson(String),
/// Path traversal attempt blocked.
#[error("Path traversal blocked: {0}")]
PathTraversalBlocked(String),
}
impl From<std::io::Error> for WasmError {
fn from(e: std::io::Error) -> Self {
WasmError::IoError(e.to_string())
}
}
impl From<WasmError> for crate::tools::ToolError {
fn from(e: WasmError) -> Self {
crate::tools::ToolError::Sandbox(e.to_string())
}
}
/// Details about a trap that occurred during execution.
#[derive(Debug, Clone)]
pub struct TrapInfo {
/// Human-readable trap message.
pub message: String,
/// Trap code if available.
pub code: Option<TrapCode>,
}
impl fmt::Display for TrapInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.code {
Some(code) => write!(f, "{}: {}", code, self.message),
None => write!(f, "{}", self.message),
}
}
}
/// Known trap codes from Wasmtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrapCode {
/// Out of bounds memory access.
MemoryOutOfBounds,
/// Out of bounds table access.
TableOutOfBounds,
/// Indirect call type mismatch.
IndirectCallToNull,
/// Signature mismatch on indirect call.
BadSignature,
/// Integer overflow.
IntegerOverflow,
/// Integer division by zero.
IntegerDivisionByZero,
/// Invalid conversion to integer.
BadConversionToInteger,
/// Unreachable instruction executed.
UnreachableCodeReached,
/// Call stack exhausted.
StackOverflow,
/// Out of fuel.
OutOfFuel,
/// Unknown trap code.
Unknown,
}
impl fmt::Display for TrapCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
TrapCode::MemoryOutOfBounds => "memory out of bounds",
TrapCode::TableOutOfBounds => "table out of bounds",
TrapCode::IndirectCallToNull => "indirect call to null",
TrapCode::BadSignature => "bad signature",
TrapCode::IntegerOverflow => "integer overflow",
TrapCode::IntegerDivisionByZero => "integer division by zero",
TrapCode::BadConversionToInteger => "bad conversion to integer",
TrapCode::UnreachableCodeReached => "unreachable code reached",
TrapCode::StackOverflow => "stack overflow",
TrapCode::OutOfFuel => "out of fuel",
TrapCode::Unknown => "unknown trap",
};
write!(f, "{}", s)
}
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::error::{TrapCode, TrapInfo, WasmError};
#[test]
fn test_error_display() {
let err = WasmError::FuelExhausted { limit: 1_000_000 };
assert!(err.to_string().contains("1000000"));
let err = WasmError::MemoryExceeded {
used: 20_000_000,
limit: 10_000_000,
};
assert!(err.to_string().contains("20000000"));
assert!(err.to_string().contains("10000000"));
}
#[test]
fn test_trap_info_display() {
let info = TrapInfo {
message: "access at offset 0x1000".to_string(),
code: Some(TrapCode::MemoryOutOfBounds),
};
let s = info.to_string();
assert!(s.contains("memory out of bounds"));
assert!(s.contains("access at offset"));
}
#[test]
fn test_conversion_to_tool_error() {
let wasm_err = WasmError::Trapped("test trap".to_string());
let tool_err: crate::tools::ToolError = wasm_err.into();
match tool_err {
crate::tools::ToolError::Sandbox(msg) => {
assert!(msg.contains("test trap"));
}
_ => panic!("Expected Sandbox variant"),
}
}
}
+395
View File
@@ -0,0 +1,395 @@
//! 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.
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
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,
}
/// 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)]
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,
}
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,
}
}
/// Create a minimal host state with no capabilities.
pub fn minimal() -> Self {
Self::new(Capabilities::default())
}
/// 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
}
}
/// 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 crate::tools::wasm::host::{
Capabilities, HostState, LogLevel, MAX_LOG_ENTRIES, MAX_LOG_MESSAGE_BYTES,
WorkspaceCapability, WorkspaceReader, validate_workspace_path,
};
use std::sync::Arc;
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),
}),
};
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),
}),
};
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());
}
}
+259
View File
@@ -0,0 +1,259 @@
//! Resource limits for WASM sandbox execution.
//!
//! Provides memory and fuel (CPU) limits following NEAR blockchain patterns.
use std::time::Duration;
use wasmtime::ResourceLimiter;
/// Default memory limit: 10 MB (conservative for untrusted code).
pub const DEFAULT_MEMORY_LIMIT: u64 = 10 * 1024 * 1024;
/// Default fuel limit: 10 million instructions.
pub const DEFAULT_FUEL_LIMIT: u64 = 10_000_000;
/// Default execution timeout: 60 seconds.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
/// Resource limits for a single WASM execution.
#[derive(Debug, Clone)]
pub struct ResourceLimits {
/// Maximum memory in bytes.
pub memory_bytes: u64,
/// Maximum fuel (instruction count).
pub fuel: u64,
/// Maximum wall-clock execution time.
pub timeout: Duration,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
memory_bytes: DEFAULT_MEMORY_LIMIT,
fuel: DEFAULT_FUEL_LIMIT,
timeout: DEFAULT_TIMEOUT,
}
}
}
impl ResourceLimits {
/// Create limits with custom memory.
pub fn with_memory(mut self, bytes: u64) -> Self {
self.memory_bytes = bytes;
self
}
/// Create limits with custom fuel.
pub fn with_fuel(mut self, fuel: u64) -> Self {
self.fuel = fuel;
self
}
/// Create limits with custom timeout.
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
/// Wasmtime ResourceLimiter implementation for enforcing memory limits.
///
/// This is attached to the Store to limit memory growth during execution.
#[derive(Debug)]
pub struct WasmResourceLimiter {
/// Maximum memory allowed.
memory_limit: u64,
/// Current memory usage (tracked across all memories).
memory_used: u64,
/// Maximum tables allowed.
max_tables: u32,
/// Current table count.
tables_created: u32,
/// Maximum instances allowed.
max_instances: u32,
/// Current instance count.
instances_created: u32,
}
impl WasmResourceLimiter {
/// Create a new limiter with the given memory limit.
pub fn new(memory_limit: u64) -> Self {
Self {
memory_limit,
memory_used: 0,
max_tables: 10,
tables_created: 0,
max_instances: 1,
instances_created: 0,
}
}
/// Get current memory usage.
pub fn memory_used(&self) -> u64 {
self.memory_used
}
/// Get the memory limit.
pub fn memory_limit(&self) -> u64 {
self.memory_limit
}
}
impl ResourceLimiter for WasmResourceLimiter {
fn memory_growing(
&mut self,
current: usize,
desired: usize,
_maximum: Option<usize>,
) -> anyhow::Result<bool> {
let desired_u64 = desired as u64;
if desired_u64 > self.memory_limit {
tracing::warn!(
current = current,
desired = desired,
limit = self.memory_limit,
"WASM memory growth denied: would exceed limit"
);
return Ok(false);
}
self.memory_used = desired_u64;
tracing::trace!(
current = current,
desired = desired,
limit = self.memory_limit,
"WASM memory growth allowed"
);
Ok(true)
}
fn table_growing(
&mut self,
current: usize,
desired: usize,
_maximum: Option<usize>,
) -> anyhow::Result<bool> {
// Allow reasonable table growth
if desired > 10_000 {
tracing::warn!(
current = current,
desired = desired,
"WASM table growth denied: too large"
);
return Ok(false);
}
Ok(true)
}
fn instances(&self) -> usize {
self.max_instances as usize
}
fn tables(&self) -> usize {
self.max_tables as usize
}
fn memories(&self) -> usize {
// Allow one memory per instance
self.max_instances as usize
}
}
/// Configuration for fuel metering.
#[derive(Debug, Clone)]
pub struct FuelConfig {
/// Initial fuel to provide.
pub initial_fuel: u64,
/// Whether to enable fuel consumption.
pub enabled: bool,
}
impl Default for FuelConfig {
fn default() -> Self {
Self {
initial_fuel: DEFAULT_FUEL_LIMIT,
enabled: true,
}
}
}
impl FuelConfig {
/// Create a disabled fuel config (no CPU limits).
pub fn disabled() -> Self {
Self {
initial_fuel: 0,
enabled: false,
}
}
/// Create a fuel config with a custom limit.
pub fn with_limit(fuel: u64) -> Self {
Self {
initial_fuel: fuel,
enabled: true,
}
}
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::limits::{
DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits,
WasmResourceLimiter,
};
use wasmtime::ResourceLimiter;
#[test]
fn test_default_limits() {
let limits = ResourceLimits::default();
assert_eq!(limits.memory_bytes, DEFAULT_MEMORY_LIMIT);
assert_eq!(limits.fuel, DEFAULT_FUEL_LIMIT);
assert_eq!(limits.timeout, DEFAULT_TIMEOUT);
}
#[test]
fn test_limits_builder() {
let limits = ResourceLimits::default()
.with_memory(5 * 1024 * 1024)
.with_fuel(1_000_000)
.with_timeout(std::time::Duration::from_secs(30));
assert_eq!(limits.memory_bytes, 5 * 1024 * 1024);
assert_eq!(limits.fuel, 1_000_000);
assert_eq!(limits.timeout, std::time::Duration::from_secs(30));
}
#[test]
fn test_resource_limiter_allows_growth_within_limit() {
let mut limiter = WasmResourceLimiter::new(10 * 1024 * 1024);
// Growth within limit should be allowed
let result = limiter.memory_growing(0, 1024 * 1024, None).unwrap();
assert!(result);
assert_eq!(limiter.memory_used(), 1024 * 1024);
}
#[test]
fn test_resource_limiter_denies_growth_beyond_limit() {
let mut limiter = WasmResourceLimiter::new(10 * 1024 * 1024);
// Growth beyond limit should be denied
let result = limiter.memory_growing(0, 20 * 1024 * 1024, None).unwrap();
assert!(!result);
}
#[test]
fn test_fuel_config() {
let config = FuelConfig::default();
assert!(config.enabled);
assert_eq!(config.initial_fuel, DEFAULT_FUEL_LIMIT);
let disabled = FuelConfig::disabled();
assert!(!disabled.enabled);
let custom = FuelConfig::with_limit(5_000_000);
assert!(custom.enabled);
assert_eq!(custom.initial_fuel, 5_000_000);
}
}
+83
View File
@@ -0,0 +1,83 @@
//! WASM sandbox for untrusted tool execution.
//!
//! This module provides Wasmtime-based sandboxed execution for tools,
//! following patterns from NEAR blockchain and modern WASM best practices:
//!
//! - **Compile once, instantiate fresh**: Tools are validated and compiled
//! at registration time. Each execution creates a fresh instance.
//!
//! - **Fuel metering**: CPU usage is limited via Wasmtime's fuel system.
//!
//! - **Memory limits**: Memory growth is bounded via ResourceLimiter.
//!
//! - **Minimal host API**: Only log, time, and optional workspace read.
//!
//! - **Capability-based security**: Features are opt-in via Capabilities.
//!
//! # Architecture
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────────────┐
//! │ Tool Registration │
//! │ WASM bytes → Validate → Compile (AOT) → PreparedModule (cached) │
//! └─────────────────────────────────────────────────────────────────────┘
//! │
//! ▼
//! ┌─────────────────────────────────────────────────────────────────────┐
//! │ Tool Execution │
//! │ JSON params → WasmToolWrapper → Fresh Instance → Execute → Result │
//! │ ↓ ↓ │
//! │ ResourceLimiter HostState │
//! │ (memory, fuel) (log, time, workspace) │
//! └─────────────────────────────────────────────────────────────────────┘
//! ```
//!
//! # Security Constraints
//!
//! | Threat | Mitigation |
//! |--------|------------|
//! | CPU exhaustion | Fuel metering |
//! | Memory exhaustion | ResourceLimiter, 10MB default |
//! | Infinite loops | Epoch interruption + tokio timeout |
//! | Filesystem access | No WASI FS, only host workspace_read |
//! | Network access | No network host functions |
//! | Log spam | Max 1000 entries, 4KB per message |
//! | Path traversal | Validate paths (no `..`, no `/` prefix) |
//! | Trap recovery | Discard instance, never reuse |
//! | Side channels | Fresh instance per execution |
//!
//! # Example
//!
//! ```ignore
//! use near_agent::tools::wasm::{WasmToolRuntime, WasmRuntimeConfig, WasmToolWrapper};
//! use near_agent::tools::wasm::host::Capabilities;
//! use std::sync::Arc;
//!
//! // Create runtime
//! let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::default())?);
//!
//! // Prepare a tool from WASM bytes
//! let wasm_bytes = std::fs::read("my_tool.wasm")?;
//! let prepared = runtime.prepare("my_tool", &wasm_bytes, None).await?;
//!
//! // Create wrapper with minimal capabilities
//! let tool = WasmToolWrapper::new(runtime, prepared, Capabilities::default());
//!
//! // Execute (implements Tool trait)
//! let output = tool.execute(serde_json::json!({"input": "test"}), &ctx).await?;
//! ```
mod error;
mod host;
mod limits;
mod runtime;
mod wrapper;
pub use error::{TrapCode, TrapInfo, WasmError};
pub use host::{Capabilities, HostState, LogEntry, LogLevel, WorkspaceCapability, WorkspaceReader};
pub use limits::{
DEFAULT_FUEL_LIMIT, DEFAULT_MEMORY_LIMIT, DEFAULT_TIMEOUT, FuelConfig, ResourceLimits,
WasmResourceLimiter,
};
pub use runtime::{PreparedModule, WasmRuntimeConfig, WasmToolRuntime};
pub use wrapper::WasmToolWrapper;
+310
View File
@@ -0,0 +1,310 @@
//! 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};
/// 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))
})?;
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);
}
}
+403
View File
@@ -0,0 +1,403 @@
//! WASM tool wrapper implementing the Tool trait.
//!
//! Each execution creates a fresh instance (NEAR pattern) to ensure
//! isolation and deterministic behavior.
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use wasmtime::Store;
use wasmtime::component::{Component, Linker, Val};
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
use crate::tools::wasm::error::WasmError;
use crate::tools::wasm::host::{Capabilities, HostState, LogLevel};
use crate::tools::wasm::limits::{ResourceLimits, WasmResourceLimiter};
use crate::tools::wasm::runtime::{PreparedModule, WasmToolRuntime};
/// Store data for WASM execution.
///
/// Contains both the resource limiter and host state.
struct StoreData {
limiter: WasmResourceLimiter,
host_state: HostState,
}
impl StoreData {
fn new(memory_limit: u64, capabilities: Capabilities) -> Self {
Self {
limiter: WasmResourceLimiter::new(memory_limit),
host_state: HostState::new(capabilities),
}
}
}
/// A Tool implementation backed by a WASM component.
///
/// Each call to `execute` creates a fresh instance for isolation.
pub struct WasmToolWrapper {
/// Runtime for engine access.
runtime: Arc<WasmToolRuntime>,
/// Prepared module with compiled component.
prepared: Arc<PreparedModule>,
/// Capabilities to grant to this tool.
capabilities: Capabilities,
/// Cached description (from PreparedModule or override).
description: String,
/// Cached schema (from PreparedModule or override).
schema: serde_json::Value,
}
impl WasmToolWrapper {
/// Create a new WASM tool wrapper.
pub fn new(
runtime: Arc<WasmToolRuntime>,
prepared: Arc<PreparedModule>,
capabilities: Capabilities,
) -> Self {
Self {
description: prepared.description.clone(),
schema: prepared.schema.clone(),
runtime,
prepared,
capabilities,
}
}
/// Override the tool description.
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
/// Override the parameter schema.
pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
self.schema = schema;
self
}
/// Get the resource limits for this tool.
pub fn limits(&self) -> &ResourceLimits {
&self.prepared.limits
}
/// Execute the WASM tool synchronously (called from spawn_blocking).
fn execute_sync(
&self,
params: serde_json::Value,
context_json: Option<String>,
) -> Result<(String, Vec<crate::tools::wasm::host::LogEntry>), WasmError> {
let engine = self.runtime.engine();
let limits = &self.prepared.limits;
// Create store with fresh state (NEAR pattern: fresh instance per call)
let store_data = StoreData::new(limits.memory_bytes, self.capabilities.clone());
let mut store = Store::new(engine, store_data);
// Configure fuel if enabled
if self.runtime.config().fuel_config.enabled {
store
.set_fuel(limits.fuel)
.map_err(|e| WasmError::ConfigError(format!("Failed to set fuel: {}", e)))?;
}
// Configure epoch deadline for timeout backup
store.epoch_deadline_trap();
store.set_epoch_deadline(1);
// Set up resource limiter
store.limiter(|data| &mut data.limiter);
// Compile the component (uses cached bytes)
let component = Component::new(engine, self.prepared.component_bytes())
.map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
// Create linker and add host functions
let mut linker = Linker::new(engine);
self.add_host_functions(&mut linker)?;
// Instantiate the component
let instance = linker
.instantiate(&mut store, &component)
.map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
// Get the execute function
let execute_func = instance
.get_func(&mut store, "execute")
.ok_or_else(|| WasmError::MissingExport("execute".to_string()))?;
// Prepare request
let params_json = serde_json::to_string(&params)
.map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?;
// Build request record
// Note: The exact calling convention depends on how WIT records are lowered.
// With component model, we'd use typed bindings from wit-bindgen.
// For now, we use the lower-level Val API.
let request_params = Val::String(params_json);
let request_context = match context_json {
Some(ctx) => Val::Option(Some(Box::new(Val::String(ctx)))),
None => Val::Option(None),
};
// Create request record (params, context)
let request = Val::Record(vec![
("params".to_string(), request_params),
("context".to_string(), request_context),
]);
// Call the function
let mut results = vec![Val::Bool(false)]; // Placeholder for response
execute_func
.call(&mut store, &[request], &mut results)
.map_err(|e| {
// Check for specific trap types
let error_str = e.to_string();
if error_str.contains("out of fuel") {
WasmError::FuelExhausted { limit: limits.fuel }
} else if error_str.contains("unreachable") {
WasmError::Trapped("unreachable code executed".to_string())
} else {
WasmError::Trapped(error_str)
}
})?;
// Post-call completion (cleanup)
execute_func
.post_return(&mut store)
.map_err(|e| WasmError::Trapped(format!("post_return failed: {}", e)))?;
// Extract response
let response = &results[0];
let (result_str, error_str) = extract_response(response)?;
// Get logs from host state
let logs = store.data_mut().host_state.take_logs();
// Check for tool-level error
if let Some(err) = error_str {
return Err(WasmError::ToolReturnedError(err));
}
// Return result (or empty string if none)
Ok((result_str.unwrap_or_default(), logs))
}
/// Add host functions to the linker.
fn add_host_functions(&self, linker: &mut Linker<StoreData>) -> Result<(), WasmError> {
// Note: With WIT bindgen, these would be generated automatically.
// For now, we manually define the host functions.
//
// Component model func_wrap signature: F: Fn(StoreContextMut<T>, Params) -> Result<Return>
// where Params is a tuple of the function arguments.
// host.log(level: log-level, message: string)
linker
.root()
.func_wrap(
"log",
|mut ctx: wasmtime::StoreContextMut<'_, StoreData>,
(level, message): (i32, String)| {
let log_level = match level {
0 => LogLevel::Trace,
1 => LogLevel::Debug,
2 => LogLevel::Info,
3 => LogLevel::Warn,
4 => LogLevel::Error,
_ => LogLevel::Info,
};
// Ignore errors from logging (rate limiting)
let _ = ctx.data_mut().host_state.log(log_level, message);
Ok(())
},
)
.map_err(|e| WasmError::ConfigError(format!("Failed to add log function: {}", e)))?;
// host.now-millis() -> u64
linker
.root()
.func_wrap(
"now-millis",
|ctx: wasmtime::StoreContextMut<'_, StoreData>, (): ()| -> anyhow::Result<(u64,)> {
Ok((ctx.data().host_state.now_millis(),))
},
)
.map_err(|e| {
WasmError::ConfigError(format!("Failed to add now-millis function: {}", e))
})?;
// host.workspace-read(path: string) -> option<string>
linker
.root()
.func_wrap(
"workspace-read",
|ctx: wasmtime::StoreContextMut<'_, StoreData>,
(path,): (String,)|
-> anyhow::Result<(Option<String>,)> {
let result = ctx.data().host_state.workspace_read(&path).ok().flatten();
Ok((result,))
},
)
.map_err(|e| {
WasmError::ConfigError(format!("Failed to add workspace-read function: {}", e))
})?;
Ok(())
}
}
/// Extract result and error from a WIT response record.
fn extract_response(response: &Val) -> Result<(Option<String>, Option<String>), WasmError> {
match response {
Val::Record(fields) => {
let mut result = None;
let mut error = None;
for (name, val) in fields {
match name.as_str() {
"result" => {
if let Val::Option(Some(inner)) = val {
if let Val::String(s) = inner.as_ref() {
result = Some(s.to_string());
}
}
}
"error" => {
if let Val::Option(Some(inner)) = val {
if let Val::String(s) = inner.as_ref() {
error = Some(s.to_string());
}
}
}
_ => {}
}
}
Ok((result, error))
}
_ => Err(WasmError::InvalidResponseJson(
"Expected record response".to_string(),
)),
}
}
#[async_trait]
impl Tool for WasmToolWrapper {
fn name(&self) -> &str {
&self.prepared.name
}
fn description(&self) -> &str {
&self.description
}
fn parameters_schema(&self) -> serde_json::Value {
self.schema.clone()
}
async fn execute(
&self,
params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = Instant::now();
let timeout = self.prepared.limits.timeout;
// Serialize context for WASM
let context_json = serde_json::to_string(ctx).ok();
// Clone what we need for the blocking task
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let description = self.description.clone();
let schema = self.schema.clone();
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
let wrapper = WasmToolWrapper {
runtime,
prepared,
capabilities,
description,
schema,
};
tokio::task::spawn_blocking(move || wrapper.execute_sync(params, context_json))
.await
.map_err(|e| WasmError::ExecutionPanicked(e.to_string()))?
})
.await;
let duration = start.elapsed();
match result {
Ok(Ok((result_json, logs))) => {
// Emit collected logs
for log in logs {
match log.level {
LogLevel::Trace => tracing::trace!(target: "wasm_tool", "{}", log.message),
LogLevel::Debug => tracing::debug!(target: "wasm_tool", "{}", log.message),
LogLevel::Info => tracing::info!(target: "wasm_tool", "{}", log.message),
LogLevel::Warn => tracing::warn!(target: "wasm_tool", "{}", log.message),
LogLevel::Error => tracing::error!(target: "wasm_tool", "{}", log.message),
}
}
// Parse result JSON
let result: serde_json::Value = serde_json::from_str(&result_json)
.unwrap_or(serde_json::Value::String(result_json));
Ok(ToolOutput::success(result, duration))
}
Ok(Err(wasm_err)) => Err(wasm_err.into()),
Err(_) => Err(WasmError::Timeout(timeout).into()),
}
}
fn requires_sanitization(&self) -> bool {
// WASM tools always require sanitization - they're untrusted by definition
true
}
fn estimated_duration(&self, _params: &serde_json::Value) -> Option<Duration> {
// Use the timeout as a conservative estimate
Some(self.prepared.limits.timeout)
}
}
impl std::fmt::Debug for WasmToolWrapper {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WasmToolWrapper")
.field("name", &self.prepared.name)
.field("description", &self.description)
.field("limits", &self.prepared.limits)
.finish()
}
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::host::Capabilities;
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
use std::sync::Arc;
#[test]
fn test_wrapper_creation() {
// This test verifies the runtime can be created
// Actual execution tests require a valid WASM component
let config = WasmRuntimeConfig::for_testing();
let runtime = Arc::new(WasmToolRuntime::new(config).unwrap());
// Runtime was created successfully
assert!(runtime.config().fuel_config.enabled);
}
#[test]
fn test_capabilities_default() {
let caps = Capabilities::default();
assert!(caps.workspace_read.is_none());
}
}
+27 -12
View File
@@ -8,8 +8,8 @@ use std::sync::Arc;
use near_agent::workspace::{MockEmbeddings, SearchConfig, Workspace, paths};
fn get_pool() -> deadpool_postgres::Pool {
let database_url =
std::env::var("DATABASE_URL").unwrap_or_else(|_| "postgres://localhost/near_agent_test".to_string());
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://localhost/near_agent_test".to_string());
let config: tokio_postgres::Config = database_url.parse().expect("Invalid DATABASE_URL");
@@ -194,7 +194,10 @@ async fn test_workspace_daily_log() {
.expect("Failed to append daily log");
// Read today's log
let log = workspace.today_log().await.expect("Failed to get today log");
let log = workspace
.today_log()
.await
.expect("Failed to get today log");
assert!(log.content.contains("feature X"));
// Should have timestamp prefix like [HH:MM:SS]
assert!(log.content.contains("["));
@@ -272,11 +275,17 @@ async fn test_workspace_hybrid_search_with_mock_embeddings() {
// Write documents
workspace
.write("memory.md", "The user prefers dark mode and vim keybindings.")
.write(
"memory.md",
"The user prefers dark mode and vim keybindings.",
)
.await
.expect("write failed");
workspace
.write("prefs.md", "Settings: theme=dark, editor=vim, font=monospace")
.write(
"prefs.md",
"Settings: theme=dark, editor=vim, font=monospace",
)
.await
.expect("write failed");
@@ -335,16 +344,22 @@ async fn test_workspace_system_prompt() {
.write(paths::SOUL, "Be kind and thorough.")
.await
.unwrap();
workspace
.write(paths::USER, "Name: Alice")
.await
.unwrap();
workspace.write(paths::USER, "Name: Alice").await.unwrap();
// Get system prompt
let prompt = workspace.system_prompt().await.expect("system_prompt failed");
let prompt = workspace
.system_prompt()
.await
.expect("system_prompt failed");
assert!(prompt.contains("helpful assistant"), "Should include AGENTS.md");
assert!(prompt.contains("kind and thorough"), "Should include SOUL.md");
assert!(
prompt.contains("helpful assistant"),
"Should include AGENTS.md"
);
assert!(
prompt.contains("kind and thorough"),
"Should include SOUL.md"
);
assert!(prompt.contains("Alice"), "Should include USER.md");
cleanup_user(&pool, user_id).await;
+82
View File
@@ -0,0 +1,82 @@
// WASM Tool Sandbox Interface
//
// Defines the contract between sandboxed tools and the host runtime.
// Tools export the `tool` interface; the host provides the `host` interface.
package near:agent;
/// Host-provided capabilities for sandboxed tools.
///
/// These are the only ways a sandboxed tool can interact with the outside world.
/// The set is intentionally minimal to reduce attack surface.
interface host {
/// Log levels for structured logging.
enum log-level {
trace,
debug,
info,
warn,
error,
}
/// Emit a log message.
///
/// Messages are collected and emitted after execution completes.
/// Rate-limited to 1000 entries per execution, 4KB per message.
log: func(level: log-level, message: string);
/// Get the current timestamp in milliseconds since Unix epoch.
now-millis: func() -> u64;
/// Read a file from the workspace (if capability granted).
///
/// Path must be relative (no leading /) and cannot contain "..".
/// Returns None if the file doesn't exist or capability not granted.
workspace-read: func(path: string) -> option<string>;
}
/// Tool interface that sandboxed tools must implement.
interface tool {
/// Request payload for tool execution.
record request {
/// JSON-encoded parameters matching the tool's schema.
params: string,
/// Optional JSON-encoded job context for stateful operations.
context: option<string>,
}
/// Response from tool execution.
record response {
/// JSON-encoded result on success.
result: option<string>,
/// Error message on failure.
error: option<string>,
}
/// Execute the tool with the given request.
///
/// This is the main entry point. The tool should:
/// 1. Parse params as JSON according to its schema
/// 2. Perform the operation
/// 3. Return a response with either result or error set
execute: func(req: request) -> response;
/// Get the JSON Schema for this tool's parameters.
///
/// Must return a valid JSON Schema object describing the expected
/// structure of the `params` field in requests.
schema: func() -> string;
/// Get a human-readable description of what this tool does.
///
/// Used by the LLM to understand when to invoke the tool.
description: func() -> string;
}
/// World definition for sandboxed tools.
///
/// Tools import host capabilities and export the tool interface.
world sandboxed-tool {
import host;
export tool;
}