feat(runtime): add configurable wasm security runtime and tooling

This commit is contained in:
Chummy
2026-02-25 13:17:58 +00:00
committed by Chum Yin
parent e3c9bd9189
commit 604f64f3e7
17 changed files with 1460 additions and 77 deletions
+4
View File
@@ -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
View File
@@ -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 {
+4
View File
@@ -11,6 +11,10 @@ impl NativeRuntime {
}
impl RuntimeAdapter for NativeRuntime {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn name(&self) -> &str {
"native"
}
+8
View File
@@ -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
View File
@@ -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"