feat(runtime): add configurable wasm security runtime and tooling
This commit is contained in:
@@ -53,6 +53,10 @@ impl DockerRuntime {
|
||||
}
|
||||
|
||||
impl RuntimeAdapter for DockerRuntime {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"docker"
|
||||
}
|
||||
|
||||
+18
-2
@@ -1,10 +1,12 @@
|
||||
pub mod docker;
|
||||
pub mod native;
|
||||
pub mod traits;
|
||||
pub mod wasm;
|
||||
|
||||
pub use docker::DockerRuntime;
|
||||
pub use native::NativeRuntime;
|
||||
pub use traits::RuntimeAdapter;
|
||||
pub use wasm::{WasmCapabilities, WasmExecutionResult, WasmRuntime};
|
||||
|
||||
use crate::config::RuntimeConfig;
|
||||
|
||||
@@ -13,13 +15,16 @@ pub fn create_runtime(config: &RuntimeConfig) -> anyhow::Result<Box<dyn RuntimeA
|
||||
match config.kind.as_str() {
|
||||
"native" => Ok(Box::new(NativeRuntime::new())),
|
||||
"docker" => Ok(Box::new(DockerRuntime::new(config.docker.clone()))),
|
||||
"wasm" => Ok(Box::new(WasmRuntime::new(config.wasm.clone()))),
|
||||
"cloudflare" => anyhow::bail!(
|
||||
"runtime.kind='cloudflare' is not implemented yet. Use runtime.kind='native' for now."
|
||||
),
|
||||
other if other.trim().is_empty() => {
|
||||
anyhow::bail!("runtime.kind cannot be empty. Supported values: native, docker")
|
||||
anyhow::bail!("runtime.kind cannot be empty. Supported values: native, docker, wasm")
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("Unknown runtime kind '{other}'. Supported values: native, docker, wasm")
|
||||
}
|
||||
other => anyhow::bail!("Unknown runtime kind '{other}'. Supported values: native, docker"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +54,17 @@ mod tests {
|
||||
assert!(rt.has_shell_access());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_wasm() {
|
||||
let cfg = RuntimeConfig {
|
||||
kind: "wasm".into(),
|
||||
..RuntimeConfig::default()
|
||||
};
|
||||
let rt = create_runtime(&cfg).unwrap();
|
||||
assert_eq!(rt.name(), "wasm");
|
||||
assert!(!rt.has_shell_access());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_cloudflare_errors() {
|
||||
let cfg = RuntimeConfig {
|
||||
|
||||
@@ -11,6 +11,10 @@ impl NativeRuntime {
|
||||
}
|
||||
|
||||
impl RuntimeAdapter for NativeRuntime {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"native"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::any::Any;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Runtime adapter that abstracts platform differences for the agent.
|
||||
@@ -12,6 +13,9 @@ use std::path::{Path, PathBuf};
|
||||
/// Implementations must be `Send + Sync` because the adapter is shared
|
||||
/// across async tasks on the Tokio runtime.
|
||||
pub trait RuntimeAdapter: Send + Sync {
|
||||
/// Downcast support for runtime-specific capabilities.
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
|
||||
/// Return the human-readable name of this runtime environment.
|
||||
///
|
||||
/// Used in logs and diagnostics (e.g., `"native"`, `"docker"`,
|
||||
@@ -77,6 +81,10 @@ mod tests {
|
||||
struct DummyRuntime;
|
||||
|
||||
impl RuntimeAdapter for DummyRuntime {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"dummy-runtime"
|
||||
}
|
||||
|
||||
+522
-35
@@ -12,9 +12,10 @@
|
||||
//! The default ZeroClaw binary excludes it to maintain the 4.6 MB size target.
|
||||
|
||||
use super::traits::RuntimeAdapter;
|
||||
use crate::config::WasmRuntimeConfig;
|
||||
use crate::config::{WasmCapabilityEscalationMode, WasmRuntimeConfig};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// WASM sandbox runtime — executes tool modules in an isolated interpreter.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -52,6 +53,9 @@ pub struct WasmCapabilities {
|
||||
}
|
||||
|
||||
impl WasmRuntime {
|
||||
const MAX_MEMORY_MB: u64 = 4096;
|
||||
const MAX_FUEL_LIMIT: u64 = 10_000_000_000;
|
||||
|
||||
/// Create a new WASM runtime with the given configuration.
|
||||
pub fn new(config: WasmRuntimeConfig) -> Self {
|
||||
Self {
|
||||
@@ -75,22 +79,47 @@ impl WasmRuntime {
|
||||
|
||||
/// Validate the WASM config for common misconfigurations.
|
||||
pub fn validate_config(&self) -> Result<()> {
|
||||
if self.config.fuel_limit == 0 {
|
||||
bail!("runtime.wasm.fuel_limit must be > 0");
|
||||
}
|
||||
if self.config.fuel_limit > Self::MAX_FUEL_LIMIT {
|
||||
bail!(
|
||||
"runtime.wasm.fuel_limit of {} exceeds safety ceiling of {}",
|
||||
self.config.fuel_limit,
|
||||
Self::MAX_FUEL_LIMIT
|
||||
);
|
||||
}
|
||||
if self.config.memory_limit_mb == 0 {
|
||||
bail!("runtime.wasm.memory_limit_mb must be > 0");
|
||||
}
|
||||
if self.config.memory_limit_mb > 4096 {
|
||||
if self.config.memory_limit_mb > Self::MAX_MEMORY_MB {
|
||||
bail!(
|
||||
"runtime.wasm.memory_limit_mb of {} exceeds the 4 GB safety limit for 32-bit WASM",
|
||||
self.config.memory_limit_mb
|
||||
);
|
||||
}
|
||||
if self.config.max_module_size_mb == 0 {
|
||||
bail!("runtime.wasm.max_module_size_mb must be > 0");
|
||||
}
|
||||
if self.config.tools_dir.is_empty() {
|
||||
bail!("runtime.wasm.tools_dir cannot be empty");
|
||||
}
|
||||
// Verify tools directory doesn't escape workspace
|
||||
if self.config.tools_dir.contains("..") {
|
||||
bail!("runtime.wasm.tools_dir must not contain '..' path traversal");
|
||||
if self.config.security.require_workspace_relative_tools_dir {
|
||||
let tools_dir_path = Path::new(&self.config.tools_dir);
|
||||
if tools_dir_path.is_absolute() {
|
||||
bail!("runtime.wasm.tools_dir must be a workspace-relative path");
|
||||
}
|
||||
if tools_dir_path
|
||||
.components()
|
||||
.any(|c| matches!(c, Component::ParentDir))
|
||||
{
|
||||
bail!("runtime.wasm.tools_dir must not contain '..' path traversal");
|
||||
}
|
||||
}
|
||||
let _ = self.normalize_hosts_with_policy(
|
||||
self.config.allowed_hosts.iter().map(String::as_str),
|
||||
"runtime.wasm.allowed_hosts",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -113,7 +142,7 @@ impl WasmRuntime {
|
||||
/// Get the effective fuel limit for an invocation.
|
||||
pub fn effective_fuel(&self, caps: &WasmCapabilities) -> u64 {
|
||||
if caps.fuel_override > 0 {
|
||||
caps.fuel_override
|
||||
caps.fuel_override.min(self.config.fuel_limit)
|
||||
} else {
|
||||
self.config.fuel_limit
|
||||
}
|
||||
@@ -122,13 +151,213 @@ impl WasmRuntime {
|
||||
/// Get the effective memory limit in bytes.
|
||||
pub fn effective_memory_bytes(&self, caps: &WasmCapabilities) -> u64 {
|
||||
let mb = if caps.memory_override_mb > 0 {
|
||||
caps.memory_override_mb
|
||||
caps.memory_override_mb.min(self.config.memory_limit_mb)
|
||||
} else {
|
||||
self.config.memory_limit_mb
|
||||
};
|
||||
mb.saturating_mul(1024 * 1024)
|
||||
}
|
||||
|
||||
fn validate_module_name(module_name: &str) -> Result<()> {
|
||||
if module_name.is_empty() {
|
||||
bail!("WASM module name cannot be empty");
|
||||
}
|
||||
if module_name.len() > 128 {
|
||||
bail!("WASM module name is too long (max 128 chars): {module_name}");
|
||||
}
|
||||
if !module_name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
{
|
||||
bail!(
|
||||
"WASM module name '{module_name}' contains invalid characters; \
|
||||
allowed set is [A-Za-z0-9_-]"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_host(host: &str) -> Result<String> {
|
||||
let normalized = host.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() {
|
||||
bail!("runtime.wasm.allowed_hosts contains an empty entry");
|
||||
}
|
||||
if normalized == "*" || normalized.contains('*') {
|
||||
bail!(
|
||||
"runtime.wasm.allowed_hosts entry '{host}' is invalid; wildcard hosts are not allowed"
|
||||
);
|
||||
}
|
||||
if normalized.contains("://")
|
||||
|| normalized.contains('/')
|
||||
|| normalized.contains('?')
|
||||
|| normalized.contains('#')
|
||||
{
|
||||
bail!(
|
||||
"runtime.wasm.allowed_hosts entry '{host}' must be host[:port] only (no scheme/path/query)"
|
||||
);
|
||||
}
|
||||
if normalized.starts_with('.') || normalized.ends_with('.') {
|
||||
bail!("runtime.wasm.allowed_hosts entry '{host}' must not start/end with '.'");
|
||||
}
|
||||
if normalized.starts_with('-') || normalized.ends_with('-') {
|
||||
bail!("runtime.wasm.allowed_hosts entry '{host}' must not start/end with '-'");
|
||||
}
|
||||
if !normalized
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == ':')
|
||||
{
|
||||
bail!("runtime.wasm.allowed_hosts entry '{host}' contains invalid characters");
|
||||
}
|
||||
|
||||
if let Some((host_part, port_part)) = normalized.rsplit_once(':') {
|
||||
// Support host:port form while rejecting malformed host: segments.
|
||||
if host_part.is_empty()
|
||||
|| port_part.is_empty()
|
||||
|| !port_part.chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
bail!("runtime.wasm.allowed_hosts entry '{host}' has invalid port format");
|
||||
}
|
||||
if host_part.contains(':') {
|
||||
bail!("runtime.wasm.allowed_hosts entry '{host}' has too many ':' separators");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_hosts_with_policy<'a, I>(&self, hosts: I, source: &str) -> Result<BTreeSet<String>>
|
||||
where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
let mut normalized = BTreeSet::new();
|
||||
for host in hosts {
|
||||
match Self::normalize_host(host) {
|
||||
Ok(value) => {
|
||||
normalized.insert(value);
|
||||
}
|
||||
Err(err) if self.config.security.strict_host_validation => return Err(err),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
host,
|
||||
source,
|
||||
error = %err,
|
||||
"Ignoring invalid WASM host entry because runtime.wasm.security.strict_host_validation=false"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn validate_capabilities(&self, caps: &WasmCapabilities) -> Result<WasmCapabilities> {
|
||||
let default_hosts = self.normalize_hosts_with_policy(
|
||||
self.config.allowed_hosts.iter().map(String::as_str),
|
||||
"runtime.wasm.allowed_hosts",
|
||||
)?;
|
||||
let requested_hosts = self.normalize_hosts_with_policy(
|
||||
caps.allowed_hosts.iter().map(String::as_str),
|
||||
"wasm invocation allowed_hosts",
|
||||
)?;
|
||||
|
||||
match self.config.security.capability_escalation_mode {
|
||||
WasmCapabilityEscalationMode::Deny => {
|
||||
if caps.read_workspace && !self.config.allow_workspace_read {
|
||||
bail!(
|
||||
"WASM capability escalation blocked: read_workspace requested but runtime.wasm.allow_workspace_read is false"
|
||||
);
|
||||
}
|
||||
if caps.write_workspace && !self.config.allow_workspace_write {
|
||||
bail!(
|
||||
"WASM capability escalation blocked: write_workspace requested but runtime.wasm.allow_workspace_write is false"
|
||||
);
|
||||
}
|
||||
if caps.fuel_override > self.config.fuel_limit {
|
||||
bail!(
|
||||
"WASM capability escalation blocked: fuel_override={} exceeds runtime.wasm.fuel_limit={}",
|
||||
caps.fuel_override,
|
||||
self.config.fuel_limit
|
||||
);
|
||||
}
|
||||
if caps.memory_override_mb > self.config.memory_limit_mb {
|
||||
bail!(
|
||||
"WASM capability escalation blocked: memory_override_mb={} exceeds runtime.wasm.memory_limit_mb={}",
|
||||
caps.memory_override_mb,
|
||||
self.config.memory_limit_mb
|
||||
);
|
||||
}
|
||||
for host in &requested_hosts {
|
||||
if !default_hosts.contains(host) {
|
||||
bail!(
|
||||
"WASM capability escalation blocked: host '{host}' is not in runtime.wasm.allowed_hosts"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(WasmCapabilities {
|
||||
read_workspace: caps.read_workspace,
|
||||
write_workspace: caps.write_workspace,
|
||||
allowed_hosts: requested_hosts.into_iter().collect(),
|
||||
fuel_override: caps.fuel_override,
|
||||
memory_override_mb: caps.memory_override_mb,
|
||||
})
|
||||
}
|
||||
WasmCapabilityEscalationMode::Clamp => {
|
||||
let mut effective = WasmCapabilities {
|
||||
read_workspace: caps.read_workspace && self.config.allow_workspace_read,
|
||||
write_workspace: caps.write_workspace && self.config.allow_workspace_write,
|
||||
allowed_hosts: requested_hosts
|
||||
.intersection(&default_hosts)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
fuel_override: if caps.fuel_override > self.config.fuel_limit {
|
||||
self.config.fuel_limit
|
||||
} else {
|
||||
caps.fuel_override
|
||||
},
|
||||
memory_override_mb: if caps.memory_override_mb > self.config.memory_limit_mb {
|
||||
self.config.memory_limit_mb
|
||||
} else {
|
||||
caps.memory_override_mb
|
||||
},
|
||||
};
|
||||
|
||||
if caps.read_workspace && !effective.read_workspace {
|
||||
tracing::warn!(
|
||||
"Clamped WASM read_workspace request because runtime.wasm.allow_workspace_read=false"
|
||||
);
|
||||
}
|
||||
if caps.write_workspace && !effective.write_workspace {
|
||||
tracing::warn!(
|
||||
"Clamped WASM write_workspace request because runtime.wasm.allow_workspace_write=false"
|
||||
);
|
||||
}
|
||||
if caps.fuel_override > self.config.fuel_limit {
|
||||
tracing::warn!(
|
||||
requested = caps.fuel_override,
|
||||
allowed = self.config.fuel_limit,
|
||||
"Clamped WASM fuel_override to runtime.wasm.fuel_limit"
|
||||
);
|
||||
}
|
||||
if caps.memory_override_mb > self.config.memory_limit_mb {
|
||||
tracing::warn!(
|
||||
requested = caps.memory_override_mb,
|
||||
allowed = self.config.memory_limit_mb,
|
||||
"Clamped WASM memory_override_mb to runtime.wasm.memory_limit_mb"
|
||||
);
|
||||
}
|
||||
if effective.allowed_hosts.len() != requested_hosts.len() {
|
||||
tracing::warn!(
|
||||
requested = requested_hosts.len(),
|
||||
allowed = effective.allowed_hosts.len(),
|
||||
"Clamped WASM allowed_hosts to runtime.wasm.allowed_hosts"
|
||||
);
|
||||
}
|
||||
|
||||
effective.allowed_hosts.sort();
|
||||
Ok(effective)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a WASM module from the tools directory.
|
||||
///
|
||||
/// This is the primary entry point for running sandboxed tool code.
|
||||
@@ -143,30 +372,108 @@ impl WasmRuntime {
|
||||
) -> Result<WasmExecutionResult> {
|
||||
use wasmi::{Engine, Linker, Module, Store};
|
||||
|
||||
// Resolve module path
|
||||
self.validate_config()?;
|
||||
Self::validate_module_name(module_name)?;
|
||||
let effective_caps = self.validate_capabilities(caps)?;
|
||||
|
||||
// Resolve and normalize module path.
|
||||
let tools_path = self.tools_dir(workspace_dir);
|
||||
let module_path = tools_path.join(format!("{module_name}.wasm"));
|
||||
if !tools_path.exists() {
|
||||
bail!(
|
||||
"WASM tools directory does not exist: {}",
|
||||
tools_path.display()
|
||||
);
|
||||
}
|
||||
let canonical_tools_path = std::fs::canonicalize(&tools_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to canonicalize WASM tools directory: {}",
|
||||
tools_path.display()
|
||||
)
|
||||
})?;
|
||||
if !canonical_tools_path.is_dir() {
|
||||
bail!(
|
||||
"WASM tools path is not a directory: {}",
|
||||
canonical_tools_path.display()
|
||||
);
|
||||
}
|
||||
let module_path = canonical_tools_path.join(format!("{module_name}.wasm"));
|
||||
|
||||
if !module_path.exists() {
|
||||
bail!(
|
||||
"WASM module not found: {} (looked in {})",
|
||||
module_name,
|
||||
tools_path.display()
|
||||
canonical_tools_path.display()
|
||||
);
|
||||
}
|
||||
if self.config.security.reject_symlink_modules {
|
||||
let module_symlink_meta =
|
||||
std::fs::symlink_metadata(&module_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to inspect WASM module metadata: {}",
|
||||
module_path.display()
|
||||
)
|
||||
})?;
|
||||
if module_symlink_meta.file_type().is_symlink() {
|
||||
bail!(
|
||||
"WASM module path must not be a symlink: {}",
|
||||
module_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
let canonical_module_path = std::fs::canonicalize(&module_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to canonicalize WASM module path: {}",
|
||||
module_path.display()
|
||||
)
|
||||
})?;
|
||||
if !canonical_module_path.starts_with(&canonical_tools_path) {
|
||||
bail!(
|
||||
"WASM module path escapes tools directory: {}",
|
||||
canonical_module_path.display()
|
||||
);
|
||||
}
|
||||
if canonical_module_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
!= Some("wasm")
|
||||
{
|
||||
bail!(
|
||||
"WASM module path must end with .wasm: {}",
|
||||
canonical_module_path.display()
|
||||
);
|
||||
}
|
||||
if !canonical_module_path.is_file() {
|
||||
bail!(
|
||||
"WASM module path is not a file: {}",
|
||||
canonical_module_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let module_size_bytes = std::fs::metadata(&canonical_module_path)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to read WASM module metadata: {}",
|
||||
canonical_module_path.display()
|
||||
)
|
||||
})?
|
||||
.len();
|
||||
let max_size_bytes = self.config.max_module_size_mb * 1024 * 1024;
|
||||
if module_size_bytes > max_size_bytes {
|
||||
bail!(
|
||||
"WASM module {} is {} MB — exceeds configured {} MB safety limit",
|
||||
module_name,
|
||||
module_size_bytes / (1024 * 1024),
|
||||
self.config.max_module_size_mb
|
||||
);
|
||||
}
|
||||
|
||||
// Read module bytes
|
||||
let wasm_bytes = std::fs::read(&module_path)
|
||||
.with_context(|| format!("Failed to read WASM module: {}", module_path.display()))?;
|
||||
|
||||
// Validate module size (sanity check)
|
||||
if wasm_bytes.len() > 50 * 1024 * 1024 {
|
||||
bail!(
|
||||
"WASM module {} is {} MB — exceeds 50 MB safety limit",
|
||||
module_name,
|
||||
wasm_bytes.len() / (1024 * 1024)
|
||||
);
|
||||
}
|
||||
let wasm_bytes = std::fs::read(&canonical_module_path).with_context(|| {
|
||||
format!(
|
||||
"Failed to read WASM module: {}",
|
||||
canonical_module_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Configure engine with fuel metering
|
||||
let mut engine_config = wasmi::Config::default();
|
||||
@@ -179,7 +486,7 @@ impl WasmRuntime {
|
||||
|
||||
// Create store with fuel budget
|
||||
let mut store = Store::new(&engine, ());
|
||||
let fuel = self.effective_fuel(caps);
|
||||
let fuel = self.effective_fuel(&effective_caps);
|
||||
if fuel > 0 {
|
||||
store.set_fuel(fuel).with_context(|| {
|
||||
format!("Failed to set fuel budget ({fuel}) for module: {module_name}")
|
||||
@@ -229,7 +536,7 @@ impl WasmRuntime {
|
||||
let fuel_consumed = fuel_before.saturating_sub(fuel_after);
|
||||
|
||||
Ok(WasmExecutionResult {
|
||||
stdout: String::new(), // No WASI stdout yet — pure computation
|
||||
stdout: String::new(), // No WASI stdout yet — pure computation
|
||||
stderr: String::new(),
|
||||
exit_code,
|
||||
fuel_consumed,
|
||||
@@ -266,7 +573,10 @@ impl WasmRuntime {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "wasm") {
|
||||
if let Some(stem) = path.file_stem() {
|
||||
modules.push(stem.to_string_lossy().to_string());
|
||||
let module_name = stem.to_string_lossy().to_string();
|
||||
if Self::validate_module_name(&module_name).is_ok() {
|
||||
modules.push(module_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,6 +586,10 @@ impl WasmRuntime {
|
||||
}
|
||||
|
||||
impl RuntimeAdapter for WasmRuntime {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"wasm"
|
||||
}
|
||||
@@ -379,7 +693,10 @@ mod tests {
|
||||
let rt = WasmRuntime::new(default_config());
|
||||
let result = rt.build_shell_command("echo hello", Path::new("/tmp"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("does not support shell"));
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("does not support shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -391,7 +708,10 @@ mod tests {
|
||||
#[test]
|
||||
fn wasm_storage_path_with_workspace() {
|
||||
let rt = WasmRuntime::with_workspace(default_config(), PathBuf::from("/home/user/project"));
|
||||
assert_eq!(rt.storage_path(), PathBuf::from("/home/user/project/.zeroclaw"));
|
||||
assert_eq!(
|
||||
rt.storage_path(),
|
||||
PathBuf::from("/home/user/project/.zeroclaw")
|
||||
);
|
||||
}
|
||||
|
||||
// ── Config validation ──────────────────────────────────────
|
||||
@@ -414,6 +734,24 @@ mod tests {
|
||||
assert!(err.to_string().contains("4 GB safety limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_zero_fuel() {
|
||||
let mut cfg = default_config();
|
||||
cfg.fuel_limit = 0;
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let err = rt.validate_config().unwrap_err();
|
||||
assert!(err.to_string().contains("fuel_limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_zero_max_module_size() {
|
||||
let mut cfg = default_config();
|
||||
cfg.max_module_size_mb = 0;
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let err = rt.validate_config().unwrap_err();
|
||||
assert!(err.to_string().contains("max_module_size_mb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_empty_tools_dir() {
|
||||
let mut cfg = default_config();
|
||||
@@ -423,6 +761,15 @@ mod tests {
|
||||
assert!(err.to_string().contains("cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_absolute_tools_dir() {
|
||||
let mut cfg = default_config();
|
||||
cfg.tools_dir = "/tmp/wasm-tools".into();
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let err = rt.validate_config().unwrap_err();
|
||||
assert!(err.to_string().contains("workspace-relative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_path_traversal() {
|
||||
let mut cfg = default_config();
|
||||
@@ -432,6 +779,42 @@ mod tests {
|
||||
assert!(err.to_string().contains("path traversal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_allows_absolute_tools_dir_when_configured() {
|
||||
let mut cfg = default_config();
|
||||
cfg.tools_dir = "/tmp/wasm-tools".into();
|
||||
cfg.security.require_workspace_relative_tools_dir = false;
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
assert!(rt.validate_config().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_allows_path_traversal_when_configured() {
|
||||
let mut cfg = default_config();
|
||||
cfg.tools_dir = "../../../etc/passwd".into();
|
||||
cfg.security.require_workspace_relative_tools_dir = false;
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
assert!(rt.validate_config().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_wildcard_host_entries() {
|
||||
let mut cfg = default_config();
|
||||
cfg.allowed_hosts = vec!["*.example.com".into()];
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let err = rt.validate_config().unwrap_err();
|
||||
assert!(err.to_string().contains("wildcard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_ignores_invalid_host_entries_when_non_strict() {
|
||||
let mut cfg = default_config();
|
||||
cfg.allowed_hosts = vec!["*.example.com".into(), "api.example.com".into()];
|
||||
cfg.security.strict_host_validation = false;
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
assert!(rt.validate_config().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_valid_config() {
|
||||
let rt = WasmRuntime::new(default_config());
|
||||
@@ -465,6 +848,18 @@ mod tests {
|
||||
assert_eq!(rt.effective_fuel(&caps), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_fuel_clamps_override_to_config_limit() {
|
||||
let mut cfg = default_config();
|
||||
cfg.fuel_limit = 10;
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let caps = WasmCapabilities {
|
||||
fuel_override: 99,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(rt.effective_fuel(&caps), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_memory_uses_config_default() {
|
||||
let rt = WasmRuntime::new(default_config());
|
||||
@@ -476,10 +871,20 @@ mod tests {
|
||||
fn effective_memory_respects_override() {
|
||||
let rt = WasmRuntime::new(default_config());
|
||||
let caps = WasmCapabilities {
|
||||
memory_override_mb: 128,
|
||||
memory_override_mb: 32,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(rt.effective_memory_bytes(&caps), 128 * 1024 * 1024);
|
||||
assert_eq!(rt.effective_memory_bytes(&caps), 32 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_memory_clamps_override_to_config_limit() {
|
||||
let rt = WasmRuntime::new(default_config());
|
||||
let caps = WasmCapabilities {
|
||||
memory_override_mb: 256,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(rt.effective_memory_bytes(&caps), 64 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -494,6 +899,84 @@ mod tests {
|
||||
assert_eq!(caps.allowed_hosts, vec!["api.example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_capabilities_rejects_fuel_escalation() {
|
||||
let mut cfg = default_config();
|
||||
cfg.fuel_limit = 100;
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let caps = WasmCapabilities {
|
||||
fuel_override: 101,
|
||||
..Default::default()
|
||||
};
|
||||
let err = rt.validate_capabilities(&caps).unwrap_err();
|
||||
assert!(err.to_string().contains("fuel_override"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_capabilities_rejects_memory_escalation() {
|
||||
let mut cfg = default_config();
|
||||
cfg.memory_limit_mb = 64;
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let caps = WasmCapabilities {
|
||||
memory_override_mb: 65,
|
||||
..Default::default()
|
||||
};
|
||||
let err = rt.validate_capabilities(&caps).unwrap_err();
|
||||
assert!(err.to_string().contains("memory_override_mb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_capabilities_rejects_host_escalation() {
|
||||
let mut cfg = default_config();
|
||||
cfg.allowed_hosts = vec!["api.example.com".into()];
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let caps = WasmCapabilities {
|
||||
allowed_hosts: vec!["evil.example.com".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let err = rt.validate_capabilities(&caps).unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("not in runtime.wasm.allowed_hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_capabilities_accepts_host_subset() {
|
||||
let mut cfg = default_config();
|
||||
cfg.allowed_hosts = vec!["api.example.com".into(), "cdn.example.com".into()];
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let caps = WasmCapabilities {
|
||||
allowed_hosts: vec!["api.example.com".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(rt.validate_capabilities(&caps).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_capabilities_clamps_escalation_when_configured() {
|
||||
let mut cfg = default_config();
|
||||
cfg.fuel_limit = 100;
|
||||
cfg.memory_limit_mb = 32;
|
||||
cfg.allowed_hosts = vec!["api.example.com".into()];
|
||||
cfg.security.capability_escalation_mode = WasmCapabilityEscalationMode::Clamp;
|
||||
let rt = WasmRuntime::new(cfg);
|
||||
let caps = WasmCapabilities {
|
||||
read_workspace: true,
|
||||
write_workspace: true,
|
||||
allowed_hosts: vec!["api.example.com".into(), "evil.example.com".into()],
|
||||
fuel_override: 500,
|
||||
memory_override_mb: 64,
|
||||
};
|
||||
let effective = rt
|
||||
.validate_capabilities(&caps)
|
||||
.expect("clamp should succeed");
|
||||
assert!(!effective.read_workspace);
|
||||
assert!(!effective.write_workspace);
|
||||
assert_eq!(effective.allowed_hosts, vec!["api.example.com"]);
|
||||
assert_eq!(effective.fuel_override, 100);
|
||||
assert_eq!(effective.memory_override_mb, 32);
|
||||
}
|
||||
|
||||
// ── Tools directory ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -519,6 +1002,7 @@ mod tests {
|
||||
// Create dummy .wasm files
|
||||
std::fs::write(tools_dir.join("calculator.wasm"), b"\0asm").unwrap();
|
||||
std::fs::write(tools_dir.join("formatter.wasm"), b"\0asm").unwrap();
|
||||
std::fs::write(tools_dir.join("bad$name.wasm"), b"\0asm").unwrap();
|
||||
std::fs::write(tools_dir.join("readme.txt"), b"not a wasm").unwrap();
|
||||
|
||||
let rt = WasmRuntime::new(default_config());
|
||||
@@ -526,6 +1010,12 @@ mod tests {
|
||||
assert_eq!(modules, vec!["calculator", "formatter"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_module_name_rejects_traversal_like_input() {
|
||||
let err = WasmRuntime::validate_module_name("../secrets").unwrap_err();
|
||||
assert!(err.to_string().contains("invalid characters"));
|
||||
}
|
||||
|
||||
// ── Module execution edge cases ────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -602,8 +1092,8 @@ mod tests {
|
||||
memory_override_mb: u64::MAX,
|
||||
..Default::default()
|
||||
};
|
||||
// Should not panic — saturating_mul prevents overflow
|
||||
let _bytes = rt.effective_memory_bytes(&caps);
|
||||
// Should not panic — override is clamped to config ceiling.
|
||||
assert_eq!(rt.effective_memory_bytes(&caps), 64 * 1024 * 1024);
|
||||
}
|
||||
|
||||
// ── WasmCapabilities default ───────────────────────────────
|
||||
@@ -636,10 +1126,7 @@ mod tests {
|
||||
let rt = WasmRuntime::new(default_config());
|
||||
let caps = WasmCapabilities::default();
|
||||
let mem_bytes = rt.effective_memory_bytes(&caps);
|
||||
assert!(
|
||||
mem_bytes > 0,
|
||||
"default memory limit must be > 0"
|
||||
);
|
||||
assert!(mem_bytes > 0, "default memory limit must be > 0");
|
||||
assert!(
|
||||
mem_bytes <= 4096 * 1024 * 1024,
|
||||
"default memory must not exceed 4 GB safety limit"
|
||||
|
||||
Reference in New Issue
Block a user