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

109
Cargo.lock generated
View File

@ -1631,6 +1631,12 @@ dependencies = [
"litrs",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dtoa"
version = "1.0.11"
@ -2921,6 +2927,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indexmap-nostd"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590"
[[package]]
name = "inout"
version = "0.1.4"
@ -3171,12 +3183,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@ -3828,6 +3834,12 @@ dependencies = [
"pxfm",
]
[[package]]
name = "multi-stash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f"
[[package]]
name = "multimap"
version = "0.10.1"
@ -4059,7 +4071,7 @@ dependencies = [
"core-foundation-sys",
"futures-core",
"io-kit-sys 0.5.0",
"linux-raw-sys 0.11.0",
"linux-raw-sys",
"log",
"once_cell",
"rustix",
@ -4290,6 +4302,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pbkdf2"
version = "0.12.2"
@ -5547,7 +5565,7 @@ dependencies = [
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys 0.12.1",
"linux-raw-sys",
"windows-sys 0.61.2",
]
@ -6100,6 +6118,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "spki"
version = "0.7.3"
@ -6141,6 +6165,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "string-interner"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c6a0d765f5807e98a091107bae0a56ea3799f66a5de47b2c84c94a39c09974e"
dependencies = [
"cfg-if",
"hashbrown 0.14.5",
"serde",
]
[[package]]
name = "string_cache"
version = "0.8.9"
@ -7555,6 +7590,54 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasmi"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b07e84e3bcdab2f4301827623260ada2557596ca462f7470b60f5182a25270b1"
dependencies = [
"arrayvec",
"multi-stash",
"smallvec",
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser-nostd",
]
[[package]]
name = "wasmi_collections"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d0fd5f4f2c4fe0c98554bb7293108ed2b1d0c124dce0974f999de7d517d37bc"
dependencies = [
"ahash",
"hashbrown 0.14.5",
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a5f7bbd933a0fb3bac6c541f8bd90c0c8adcd91bb3ac088a2088995325b3d9"
dependencies = [
"downcast-rs",
"libm",
"num-traits",
"paste",
]
[[package]]
name = "wasmi_ir"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a3345445247388df2b5b35250a30c9209c27c8d2c6db1bf4c89b65636264bf9"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
@ -7567,6 +7650,15 @@ dependencies = [
"semver",
]
[[package]]
name = "wasmparser-nostd"
version = "0.100.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa"
dependencies = [
"indexmap-nostd",
]
[[package]]
name = "web-sys"
version = "0.3.85"
@ -8320,6 +8412,7 @@ dependencies = [
"wa-rs-proto",
"wa-rs-tokio-transport",
"wa-rs-ureq-http",
"wasmi",
"webpki-roots 1.0.6",
"which",
"wiremock",

View File

@ -65,6 +65,9 @@ nanohtml2text = { version = "0.2", optional = true }
# Optional Rust-native browser automation backend
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
# Optional in-process WASM runtime for sandboxed tool execution
wasmi = { version = "0.38", optional = true, default-features = true }
# Error handling
anyhow = "1.0"
thiserror = "2.0"
@ -196,6 +199,8 @@ peripheral-rpi = ["rppal"]
browser-native = ["dep:fantoccini"]
# Backward-compatible alias for older invocations
fantoccini = ["browser-native"]
# In-process WASM runtime (capability-based sandbox)
runtime-wasm = ["dep:wasmi"]
# Sandbox feature aliases used by cfg(feature = "sandbox-*")
sandbox-landlock = ["dep:landlock"]
sandbox-bubblewrap = []

View File

@ -84,6 +84,26 @@ Stop containers and remove volumes and generated config:
**Note:** This removes `target/.zeroclaw` (config/DB) but leaves the `playground/` directory intact. To fully wipe everything, manually delete `playground/`.
## WASM Security Profiles
If you run `runtime.kind = "wasm"`, prebuilt baseline templates are available:
- `dev/config.wasm.dev.toml`
- `dev/config.wasm.staging.toml`
- `dev/config.wasm.prod.toml`
Recommended path:
1. Start with `dev` for module integration (`capability_escalation_mode = "clamp"`).
2. Move to `staging` and fix denied escalation paths.
3. Promote to `prod` with minimal permissions.
Example apply flow:
```bash
cp dev/config.wasm.staging.toml target/.zeroclaw/config.toml
```
## Local CI/CD (Docker-Only)
Use this when you want CI-style validation without relying on GitHub Actions and without running Rust toolchain commands on your host.

25
dev/config.wasm.dev.toml Normal file
View File

@ -0,0 +1,25 @@
workspace_dir = "/zeroclaw-data/workspace"
config_path = "/zeroclaw-data/.zeroclaw/config.toml"
# This is the Ollama Base URL, not a secret key
api_key = "http://host.docker.internal:11434"
default_provider = "ollama"
default_model = "llama3.2"
default_temperature = 0.7
[runtime]
kind = "wasm"
[runtime.wasm]
tools_dir = "tools/wasm"
fuel_limit = 2000000
memory_limit_mb = 128
max_module_size_mb = 64
allow_workspace_read = true
allow_workspace_write = true
allowed_hosts = ["localhost:3000", "127.0.0.1:8080", "api.dev.internal"]
[runtime.wasm.security]
require_workspace_relative_tools_dir = true
reject_symlink_modules = true
strict_host_validation = true
capability_escalation_mode = "clamp"

25
dev/config.wasm.prod.toml Normal file
View File

@ -0,0 +1,25 @@
workspace_dir = "/zeroclaw-data/workspace"
config_path = "/zeroclaw-data/.zeroclaw/config.toml"
# This is the Ollama Base URL, not a secret key
api_key = "http://host.docker.internal:11434"
default_provider = "ollama"
default_model = "llama3.2"
default_temperature = 0.7
[runtime]
kind = "wasm"
[runtime.wasm]
tools_dir = "tools/wasm"
fuel_limit = 500000
memory_limit_mb = 64
max_module_size_mb = 16
allow_workspace_read = false
allow_workspace_write = false
allowed_hosts = []
[runtime.wasm.security]
require_workspace_relative_tools_dir = true
reject_symlink_modules = true
strict_host_validation = true
capability_escalation_mode = "deny"

View File

@ -0,0 +1,25 @@
workspace_dir = "/zeroclaw-data/workspace"
config_path = "/zeroclaw-data/.zeroclaw/config.toml"
# This is the Ollama Base URL, not a secret key
api_key = "http://host.docker.internal:11434"
default_provider = "ollama"
default_model = "llama3.2"
default_temperature = 0.7
[runtime]
kind = "wasm"
[runtime.wasm]
tools_dir = "tools/wasm"
fuel_limit = 1000000
memory_limit_mb = 64
max_module_size_mb = 32
allow_workspace_read = true
allow_workspace_write = false
allowed_hosts = ["api.staging.internal", "cdn.staging.internal:443"]
[runtime.wasm.security]
require_workspace_relative_tools_dir = true
reject_symlink_modules = true
strict_host_validation = true
capability_escalation_mode = "deny"

View File

@ -2,7 +2,7 @@
This is a high-signal reference for common config sections and defaults.
Last verified: **February 21, 2026**.
Last verified: **February 25, 2026**.
Config path resolution at startup:
@ -267,6 +267,7 @@ The agent will research the codebase before responding to queries like:
| Key | Default | Purpose |
|---|---|---|
| `kind` | `native` | Runtime backend: `native`, `docker`, or `wasm` |
| `reasoning_enabled` | unset (`None`) | Global reasoning/thinking override for providers that support explicit controls |
Notes:
@ -275,6 +276,41 @@ Notes:
- `reasoning_enabled = true` explicitly requests reasoning for supported providers (`think: true` on `ollama`).
- Unset keeps provider defaults.
- Deprecated compatibility alias: `runtime.reasoning_level` is still accepted but should be migrated to `provider.reasoning_level`.
- `runtime.kind = "wasm"` enables capability-bounded module execution and disables shell/process style execution.
### `[runtime.wasm]`
| Key | Default | Purpose |
|---|---|---|
| `tools_dir` | `"tools/wasm"` | Workspace-relative directory containing `.wasm` modules |
| `fuel_limit` | `1000000` | Instruction budget per module invocation |
| `memory_limit_mb` | `64` | Per-module memory cap (MB) |
| `max_module_size_mb` | `50` | Maximum allowed `.wasm` file size (MB) |
| `allow_workspace_read` | `false` | Allow WASM host calls to read workspace files (future-facing) |
| `allow_workspace_write` | `false` | Allow WASM host calls to write workspace files (future-facing) |
| `allowed_hosts` | `[]` | Explicit network host allowlist for WASM host calls (future-facing) |
Notes:
- `allowed_hosts` entries must be normalized `host` or `host:port` strings; wildcards, schemes, and paths are rejected when `runtime.wasm.security.strict_host_validation = true`.
- Invocation-time capability overrides are controlled by `runtime.wasm.security.capability_escalation_mode`:
- `deny` (default): reject escalation above runtime baseline.
- `clamp`: reduce requested capabilities to baseline.
### `[runtime.wasm.security]`
| Key | Default | Purpose |
|---|---|---|
| `require_workspace_relative_tools_dir` | `true` | Require `runtime.wasm.tools_dir` to be workspace-relative and reject `..` traversal |
| `reject_symlink_modules` | `true` | Block symlinked `.wasm` module files during execution |
| `strict_host_validation` | `true` | Fail config/invocation on invalid host entries instead of dropping them |
| `capability_escalation_mode` | `"deny"` | Escalation policy: `deny` or `clamp` |
WASM profile templates:
- `dev/config.wasm.dev.toml`
- `dev/config.wasm.staging.toml`
- `dev/config.wasm.prod.toml`
## `[provider]`

View File

@ -17,8 +17,8 @@ pub use schema::{
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
SecretsConfig, SecurityConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig,
StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, SyscallAnomalyConfig,
TelegramConfig, TranscriptionConfig, TunnelConfig, WebFetchConfig, WebSearchConfig,
WebhookConfig,
TelegramConfig, TranscriptionConfig, TunnelConfig, WasmCapabilityEscalationMode,
WasmRuntimeConfig, WasmSecurityConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
};
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {

View File

@ -2314,7 +2314,7 @@ impl Default for AutonomyConfig {
/// Runtime adapter configuration (`[runtime]` section).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RuntimeConfig {
/// Runtime kind (`native` | `docker`).
/// Runtime kind (`native` | `docker` | `wasm`).
#[serde(default = "default_runtime_kind")]
pub kind: String,
@ -2322,6 +2322,10 @@ pub struct RuntimeConfig {
#[serde(default)]
pub docker: DockerRuntimeConfig,
/// WASM runtime settings (used when `kind = "wasm"`).
#[serde(default)]
pub wasm: WasmRuntimeConfig,
/// Global reasoning override for providers that expose explicit controls.
/// - `None`: provider default behavior
/// - `Some(true)`: request reasoning/thinking when supported
@ -2369,6 +2373,73 @@ pub struct DockerRuntimeConfig {
pub allowed_workspace_roots: Vec<String>,
}
/// WASM runtime configuration (`[runtime.wasm]` section).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WasmRuntimeConfig {
/// Workspace-relative directory that stores `.wasm` modules.
#[serde(default = "default_wasm_tools_dir")]
pub tools_dir: String,
/// Fuel limit per invocation (instruction budget).
#[serde(default = "default_wasm_fuel_limit")]
pub fuel_limit: u64,
/// Memory limit per invocation in MB.
#[serde(default = "default_wasm_memory_limit_mb")]
pub memory_limit_mb: u64,
/// Maximum `.wasm` module size in MB.
#[serde(default = "default_wasm_max_module_size_mb")]
pub max_module_size_mb: u64,
/// Allow reading files from workspace inside WASM host calls (future-facing).
#[serde(default)]
pub allow_workspace_read: bool,
/// Allow writing files to workspace inside WASM host calls (future-facing).
#[serde(default)]
pub allow_workspace_write: bool,
/// Explicit host allowlist for outbound HTTP from WASM modules (future-facing).
#[serde(default)]
pub allowed_hosts: Vec<String>,
/// WASM runtime security controls (`[runtime.wasm.security]` section).
#[serde(default)]
pub security: WasmSecurityConfig,
}
/// How to handle invocation capabilities that exceed baseline runtime policy.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WasmCapabilityEscalationMode {
/// Reject any invocation that asks for capabilities above runtime config.
#[default]
Deny,
/// Automatically clamp invocation capabilities to runtime config ceilings.
Clamp,
}
/// Security policy controls for WASM runtime hardening.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WasmSecurityConfig {
/// Require `runtime.wasm.tools_dir` to stay workspace-relative and traversal-free.
#[serde(default = "default_true")]
pub require_workspace_relative_tools_dir: bool,
/// Reject module files that are symlinks before execution.
#[serde(default = "default_true")]
pub reject_symlink_modules: bool,
/// Strictly validate host allowlist entries (`host` or `host:port` only).
#[serde(default = "default_true")]
pub strict_host_validation: bool,
/// Capability escalation handling policy.
#[serde(default)]
pub capability_escalation_mode: WasmCapabilityEscalationMode,
}
fn default_runtime_kind() -> String {
"native".into()
}
@ -2389,6 +2460,22 @@ fn default_docker_cpu_limit() -> Option<f64> {
Some(1.0)
}
fn default_wasm_tools_dir() -> String {
"tools/wasm".into()
}
fn default_wasm_fuel_limit() -> u64 {
1_000_000
}
fn default_wasm_memory_limit_mb() -> u64 {
64
}
fn default_wasm_max_module_size_mb() -> u64 {
50
}
impl Default for DockerRuntimeConfig {
fn default() -> Self {
Self {
@ -2403,11 +2490,38 @@ impl Default for DockerRuntimeConfig {
}
}
impl Default for WasmRuntimeConfig {
fn default() -> Self {
Self {
tools_dir: default_wasm_tools_dir(),
fuel_limit: default_wasm_fuel_limit(),
memory_limit_mb: default_wasm_memory_limit_mb(),
max_module_size_mb: default_wasm_max_module_size_mb(),
allow_workspace_read: false,
allow_workspace_write: false,
allowed_hosts: Vec::new(),
security: WasmSecurityConfig::default(),
}
}
}
impl Default for WasmSecurityConfig {
fn default() -> Self {
Self {
require_workspace_relative_tools_dir: true,
reject_symlink_modules: true,
strict_host_validation: true,
capability_escalation_mode: WasmCapabilityEscalationMode::Deny,
}
}
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
kind: default_runtime_kind(),
docker: DockerRuntimeConfig::default(),
wasm: WasmRuntimeConfig::default(),
reasoning_enabled: None,
reasoning_level: None,
}
@ -6117,6 +6231,20 @@ allowed_roots = []
assert_eq!(r.docker.cpu_limit, Some(1.0));
assert!(r.docker.read_only_rootfs);
assert!(r.docker.mount_workspace);
assert_eq!(r.wasm.tools_dir, "tools/wasm");
assert_eq!(r.wasm.fuel_limit, 1_000_000);
assert_eq!(r.wasm.memory_limit_mb, 64);
assert_eq!(r.wasm.max_module_size_mb, 50);
assert!(!r.wasm.allow_workspace_read);
assert!(!r.wasm.allow_workspace_write);
assert!(r.wasm.allowed_hosts.is_empty());
assert!(r.wasm.security.require_workspace_relative_tools_dir);
assert!(r.wasm.security.reject_symlink_modules);
assert!(r.wasm.security.strict_host_validation);
assert_eq!(
r.wasm.security.capability_escalation_mode,
WasmCapabilityEscalationMode::Deny
);
}
#[test]
@ -6406,6 +6534,100 @@ reasoning_enabled = false
assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
}
#[test]
async fn runtime_wasm_deserializes() {
let raw = r#"
default_temperature = 0.7
[runtime]
kind = "wasm"
[runtime.wasm]
tools_dir = "skills/wasm"
fuel_limit = 500000
memory_limit_mb = 32
max_module_size_mb = 8
allow_workspace_read = true
allow_workspace_write = false
allowed_hosts = ["api.example.com", "cdn.example.com:443"]
[runtime.wasm.security]
require_workspace_relative_tools_dir = false
reject_symlink_modules = false
strict_host_validation = false
capability_escalation_mode = "clamp"
"#;
let parsed: Config = toml::from_str(raw).unwrap();
assert_eq!(parsed.runtime.kind, "wasm");
assert_eq!(parsed.runtime.wasm.tools_dir, "skills/wasm");
assert_eq!(parsed.runtime.wasm.fuel_limit, 500_000);
assert_eq!(parsed.runtime.wasm.memory_limit_mb, 32);
assert_eq!(parsed.runtime.wasm.max_module_size_mb, 8);
assert!(parsed.runtime.wasm.allow_workspace_read);
assert!(!parsed.runtime.wasm.allow_workspace_write);
assert_eq!(
parsed.runtime.wasm.allowed_hosts,
vec!["api.example.com", "cdn.example.com:443"]
);
assert!(
!parsed
.runtime
.wasm
.security
.require_workspace_relative_tools_dir
);
assert!(!parsed.runtime.wasm.security.reject_symlink_modules);
assert!(!parsed.runtime.wasm.security.strict_host_validation);
assert_eq!(
parsed.runtime.wasm.security.capability_escalation_mode,
WasmCapabilityEscalationMode::Clamp
);
}
#[test]
async fn runtime_wasm_dev_template_deserializes() {
let raw = include_str!("../../dev/config.wasm.dev.toml");
let parsed: Config = toml::from_str(raw).expect("dev wasm template should parse");
assert_eq!(parsed.runtime.kind, "wasm");
assert!(parsed.runtime.wasm.allow_workspace_read);
assert!(parsed.runtime.wasm.allow_workspace_write);
assert_eq!(
parsed.runtime.wasm.security.capability_escalation_mode,
WasmCapabilityEscalationMode::Clamp
);
}
#[test]
async fn runtime_wasm_staging_template_deserializes() {
let raw = include_str!("../../dev/config.wasm.staging.toml");
let parsed: Config = toml::from_str(raw).expect("staging wasm template should parse");
assert_eq!(parsed.runtime.kind, "wasm");
assert!(parsed.runtime.wasm.allow_workspace_read);
assert!(!parsed.runtime.wasm.allow_workspace_write);
assert_eq!(
parsed.runtime.wasm.security.capability_escalation_mode,
WasmCapabilityEscalationMode::Deny
);
}
#[test]
async fn runtime_wasm_prod_template_deserializes() {
let raw = include_str!("../../dev/config.wasm.prod.toml");
let parsed: Config = toml::from_str(raw).expect("prod wasm template should parse");
assert_eq!(parsed.runtime.kind, "wasm");
assert!(!parsed.runtime.wasm.allow_workspace_read);
assert!(!parsed.runtime.wasm.allow_workspace_write);
assert!(parsed.runtime.wasm.allowed_hosts.is_empty());
assert_eq!(
parsed.runtime.wasm.security.capability_escalation_mode,
WasmCapabilityEscalationMode::Deny
);
}
#[test]
async fn model_support_vision_deserializes() {
let raw = r#"

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"
}

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 {

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"
}

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"
}

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"

View File

@ -56,6 +56,7 @@ pub mod shell;
pub mod task_plan;
pub mod traits;
pub mod url_validation;
pub mod wasm_module;
pub mod web_fetch;
pub mod web_search_tool;
@ -100,6 +101,7 @@ pub use task_plan::TaskPlanTool;
pub use traits::Tool;
#[allow(unused_imports)]
pub use traits::{ToolResult, ToolSpec};
pub use wasm_module::WasmModuleTool;
pub use web_fetch::WebFetchTool;
pub use web_search_tool::WebSearchTool;
@ -155,14 +157,25 @@ pub fn default_tools_with_runtime(
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
) -> Vec<Box<dyn Tool>> {
vec![
Box::new(ShellTool::new(security.clone(), runtime)),
Box::new(FileReadTool::new(security.clone())),
Box::new(FileWriteTool::new(security.clone())),
Box::new(FileEditTool::new(security.clone())),
Box::new(GlobSearchTool::new(security.clone())),
Box::new(ContentSearchTool::new(security)),
]
let has_shell_access = runtime.has_shell_access();
let has_filesystem_access = runtime.has_filesystem_access();
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
if has_shell_access {
tools.push(Box::new(ShellTool::new(security.clone(), runtime.clone())));
}
if has_filesystem_access {
tools.push(Box::new(FileReadTool::new(security.clone())));
tools.push(Box::new(FileWriteTool::new(security.clone())));
tools.push(Box::new(FileEditTool::new(security.clone())));
tools.push(Box::new(GlobSearchTool::new(security.clone())));
tools.push(Box::new(ContentSearchTool::new(security.clone())));
}
if runtime.as_any().is::<crate::runtime::WasmRuntime>() {
tools.push(Box::new(WasmModuleTool::new(security, runtime)));
}
tools
}
/// Create full tool registry including memory tools and optional Composio
@ -215,6 +228,8 @@ pub fn all_tools_with_runtime(
fallback_api_key: Option<&str>,
root_config: &crate::config::Config,
) -> Vec<Box<dyn Tool>> {
let has_shell_access = runtime.has_shell_access();
let has_filesystem_access = runtime.has_filesystem_access();
let zeroclaw_dir = root_config
.config_path
.parent()
@ -227,21 +242,6 @@ pub fn all_tools_with_runtime(
));
let mut tool_arcs: Vec<Arc<dyn Tool>> = vec![
Arc::new(ShellTool::new_with_syscall_detector(
security.clone(),
runtime.clone(),
Some(syscall_detector.clone()),
)),
Arc::new(ProcessTool::new_with_syscall_detector(
security.clone(),
runtime.clone(),
Some(syscall_detector),
)),
Arc::new(FileReadTool::new(security.clone())),
Arc::new(FileWriteTool::new(security.clone())),
Arc::new(FileEditTool::new(security.clone())),
Arc::new(GlobSearchTool::new(security.clone())),
Arc::new(ContentSearchTool::new(security.clone())),
Arc::new(CronAddTool::new(config.clone(), security.clone())),
Arc::new(CronListTool::new(config.clone())),
Arc::new(CronRemoveTool::new(config.clone(), security.clone())),
@ -258,16 +258,43 @@ pub fn all_tools_with_runtime(
security.clone(),
)),
Arc::new(ProxyConfigTool::new(config.clone(), security.clone())),
Arc::new(GitOperationsTool::new(
security.clone(),
workspace_dir.to_path_buf(),
)),
Arc::new(PushoverTool::new(
security.clone(),
workspace_dir.to_path_buf(),
)),
];
if has_shell_access {
tool_arcs.push(Arc::new(ShellTool::new_with_syscall_detector(
security.clone(),
runtime.clone(),
Some(syscall_detector.clone()),
)));
tool_arcs.push(Arc::new(ProcessTool::new_with_syscall_detector(
security.clone(),
runtime.clone(),
Some(syscall_detector),
)));
tool_arcs.push(Arc::new(GitOperationsTool::new(
security.clone(),
workspace_dir.to_path_buf(),
)));
}
if has_filesystem_access {
tool_arcs.push(Arc::new(FileReadTool::new(security.clone())));
tool_arcs.push(Arc::new(FileWriteTool::new(security.clone())));
tool_arcs.push(Arc::new(FileEditTool::new(security.clone())));
tool_arcs.push(Arc::new(GlobSearchTool::new(security.clone())));
tool_arcs.push(Arc::new(ContentSearchTool::new(security.clone())));
}
if runtime.as_any().is::<crate::runtime::WasmRuntime>() {
tool_arcs.push(Arc::new(WasmModuleTool::new(
security.clone(),
runtime.clone(),
)));
}
if browser_config.enabled {
// Add legacy browser_open tool for simple URL opening
tool_arcs.push(Arc::new(BrowserOpenTool::new(
@ -422,7 +449,8 @@ pub fn all_tools_with_runtime(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{BrowserConfig, Config, MemoryConfig};
use crate::config::{BrowserConfig, Config, MemoryConfig, WasmRuntimeConfig};
use crate::runtime::WasmRuntime;
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config {
@ -440,6 +468,31 @@ mod tests {
assert_eq!(tools.len(), 6);
}
#[test]
fn default_tools_with_runtime_includes_wasm_module_for_wasm_runtime() {
let security = Arc::new(SecurityPolicy::default());
let runtime: Arc<dyn RuntimeAdapter> =
Arc::new(WasmRuntime::new(WasmRuntimeConfig::default()));
let tools = default_tools_with_runtime(security, runtime);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"wasm_module"));
}
#[test]
fn default_tools_with_runtime_excludes_shell_and_fs_for_wasm_runtime() {
let security = Arc::new(SecurityPolicy::default());
let runtime: Arc<dyn RuntimeAdapter> =
Arc::new(WasmRuntime::new(WasmRuntimeConfig::default()));
let tools = default_tools_with_runtime(security, runtime);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(!names.contains(&"shell"));
assert!(!names.contains(&"file_read"));
assert!(!names.contains(&"file_write"));
assert!(!names.contains(&"file_edit"));
assert!(!names.contains(&"glob_search"));
assert!(!names.contains(&"content_search"));
}
#[test]
fn all_tools_excludes_browser_when_disabled() {
let tmp = TempDir::new().unwrap();
@ -524,6 +577,48 @@ mod tests {
assert!(names.contains(&"proxy_config"));
}
#[test]
fn all_tools_with_runtime_includes_wasm_module_for_wasm_runtime() {
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem_cfg = MemoryConfig {
backend: "markdown".into(),
..MemoryConfig::default()
};
let mem: Arc<dyn Memory> =
Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
let runtime: Arc<dyn RuntimeAdapter> =
Arc::new(WasmRuntime::new(WasmRuntimeConfig::default()));
let browser = BrowserConfig::default();
let http = crate::config::HttpRequestConfig::default();
let cfg = test_config(&tmp);
let tools = all_tools_with_runtime(
Arc::new(Config::default()),
&security,
runtime,
mem,
None,
None,
&browser,
&http,
&crate::config::WebFetchConfig::default(),
tmp.path(),
&HashMap::new(),
None,
&cfg,
);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"wasm_module"));
assert!(!names.contains(&"shell"));
assert!(!names.contains(&"process"));
assert!(!names.contains(&"git_operations"));
assert!(!names.contains(&"file_read"));
assert!(!names.contains(&"file_write"));
assert!(!names.contains(&"file_edit"));
}
#[test]
fn default_tools_names() {
let security = Arc::new(SecurityPolicy::default());

View File

@ -779,6 +779,10 @@ mod tests {
struct NoLongRunningRuntime;
impl RuntimeAdapter for NoLongRunningRuntime {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn name(&self) -> &str {
"test-restricted"
}

314
src/tools/wasm_module.rs Normal file
View File

@ -0,0 +1,314 @@
use super::traits::{Tool, ToolResult};
use crate::runtime::{RuntimeAdapter, WasmCapabilities, WasmRuntime};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
/// Tool for listing and executing sandboxed WASM modules.
pub struct WasmModuleTool {
security: Arc<SecurityPolicy>,
runtime: Arc<dyn RuntimeAdapter>,
}
impl WasmModuleTool {
pub fn new(security: Arc<SecurityPolicy>, runtime: Arc<dyn RuntimeAdapter>) -> Self {
Self { security, runtime }
}
fn wasm_runtime(&self) -> Option<&WasmRuntime> {
self.runtime.as_any().downcast_ref::<WasmRuntime>()
}
fn parse_caps(args: &serde_json::Value) -> anyhow::Result<WasmCapabilities> {
let read_workspace = args
.get("read_workspace")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let write_workspace = args
.get("write_workspace")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let fuel_override = args
.get("fuel_override")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let memory_override_mb = args
.get("memory_override_mb")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let allowed_hosts = match args.get("allowed_hosts") {
Some(value) => {
let arr = value.as_array().ok_or_else(|| {
anyhow::anyhow!("'allowed_hosts' must be an array of strings")
})?;
let mut hosts = Vec::with_capacity(arr.len());
for entry in arr {
let host = entry
.as_str()
.ok_or_else(|| {
anyhow::anyhow!("'allowed_hosts' must be an array of strings")
})?
.trim()
.to_string();
if !host.is_empty() {
hosts.push(host);
}
}
hosts
}
None => Vec::new(),
};
Ok(WasmCapabilities {
read_workspace,
write_workspace,
allowed_hosts,
fuel_override,
memory_override_mb,
})
}
}
#[async_trait]
impl Tool for WasmModuleTool {
fn name(&self) -> &str {
"wasm_module"
}
fn description(&self) -> &str {
"List or execute sandboxed WASM modules from runtime.wasm.tools_dir"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["list", "run"],
"description": "Action to perform: list modules or run a module"
},
"module": {
"type": "string",
"description": "WASM module name (without .wasm extension), required when action=run"
},
"read_workspace": {
"type": "boolean",
"description": "Request read_workspace capability (must be allowed by runtime policy)"
},
"write_workspace": {
"type": "boolean",
"description": "Request write_workspace capability (must be allowed by runtime policy)"
},
"allowed_hosts": {
"type": "array",
"items": { "type": "string" },
"description": "Requested host allowlist subset for this invocation"
},
"fuel_override": {
"type": "integer",
"minimum": 0,
"description": "Optional fuel override; cannot exceed runtime.wasm.fuel_limit"
},
"memory_override_mb": {
"type": "integer",
"minimum": 0,
"description": "Optional memory override in MB; cannot exceed runtime.wasm.memory_limit_mb"
}
},
"required": ["action"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let action = args
.get("action")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?;
if self.security.is_rate_limited() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: too many actions in the last hour".into()),
});
}
if !self.security.record_action() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Rate limit exceeded: action budget exhausted".into()),
});
}
let Some(wasm_runtime) = self.wasm_runtime() else {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"wasm_module tool is only available when runtime.kind = \"wasm\"".into(),
),
});
};
match action {
"list" => match wasm_runtime.list_modules(&self.security.workspace_dir) {
Ok(modules) => Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&json!({ "modules": modules }))?,
error: None,
}),
Err(err) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(err.to_string()),
}),
},
"run" => {
let module = args
.get("module")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| anyhow::anyhow!("Missing 'module' parameter for action=run"))?;
let caps = Self::parse_caps(&args)?;
match wasm_runtime.execute_module(module, &self.security.workspace_dir, &caps) {
Ok(result) => {
let output = serde_json::to_string_pretty(&json!({
"module": module,
"exit_code": result.exit_code,
"fuel_consumed": result.fuel_consumed,
"stdout": result.stdout,
"stderr": result.stderr
}))?;
let success = result.exit_code == 0;
let error = if success {
None
} else if result.stderr.is_empty() {
Some(format!("WASM module exited with code {}", result.exit_code))
} else {
Some(result.stderr)
};
Ok(ToolResult {
success,
output,
error,
})
}
Err(err) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(err.to_string()),
}),
}
}
other => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Unsupported action '{other}'. Use 'list' or 'run'."
)),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::WasmRuntimeConfig;
use crate::runtime::NativeRuntime;
use crate::security::{AutonomyLevel, SecurityPolicy};
fn test_security(workspace_dir: std::path::PathBuf) -> Arc<SecurityPolicy> {
Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Full,
workspace_dir,
..SecurityPolicy::default()
})
}
#[test]
fn wasm_module_tool_name() {
let dir = tempfile::tempdir().unwrap();
let security = test_security(dir.path().to_path_buf());
let runtime: Arc<dyn RuntimeAdapter> =
Arc::new(WasmRuntime::new(WasmRuntimeConfig::default()));
let tool = WasmModuleTool::new(security, runtime);
assert_eq!(tool.name(), "wasm_module");
}
#[tokio::test]
async fn list_action_returns_modules() {
let dir = tempfile::tempdir().unwrap();
let tools_dir = dir.path().join("tools/wasm");
std::fs::create_dir_all(&tools_dir).unwrap();
std::fs::write(tools_dir.join("alpha.wasm"), b"\0asm").unwrap();
std::fs::write(tools_dir.join("beta.wasm"), b"\0asm").unwrap();
std::fs::write(tools_dir.join("bad$name.wasm"), b"\0asm").unwrap();
let security = test_security(dir.path().to_path_buf());
let runtime: Arc<dyn RuntimeAdapter> =
Arc::new(WasmRuntime::new(WasmRuntimeConfig::default()));
let tool = WasmModuleTool::new(security, runtime);
let result = tool.execute(json!({"action": "list"})).await.unwrap();
assert!(result.success);
assert!(result.output.contains("alpha"));
assert!(result.output.contains("beta"));
assert!(!result.output.contains("bad$name"));
}
#[tokio::test]
async fn run_action_requires_module() {
let dir = tempfile::tempdir().unwrap();
let security = test_security(dir.path().to_path_buf());
let runtime: Arc<dyn RuntimeAdapter> =
Arc::new(WasmRuntime::new(WasmRuntimeConfig::default()));
let tool = WasmModuleTool::new(security, runtime);
let result = tool.execute(json!({"action": "run"})).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("module"));
}
#[tokio::test]
async fn run_action_errors_without_runtime_wasm_feature() {
if WasmRuntime::is_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let tools_dir = dir.path().join("tools/wasm");
std::fs::create_dir_all(&tools_dir).unwrap();
std::fs::write(tools_dir.join("hello.wasm"), b"\0asm\x01\0\0\0").unwrap();
let security = test_security(dir.path().to_path_buf());
let runtime: Arc<dyn RuntimeAdapter> =
Arc::new(WasmRuntime::new(WasmRuntimeConfig::default()));
let tool = WasmModuleTool::new(security, runtime);
let result = tool
.execute(json!({"action": "run", "module": "hello"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap_or_default().contains("not available"));
}
#[tokio::test]
async fn tool_rejects_non_wasm_runtime() {
let dir = tempfile::tempdir().unwrap();
let security = test_security(dir.path().to_path_buf());
let runtime: Arc<dyn RuntimeAdapter> = Arc::new(NativeRuntime::new());
let tool = WasmModuleTool::new(security, runtime);
let result = tool.execute(json!({"action": "list"})).await.unwrap();
assert!(!result.success);
assert!(result
.error
.unwrap_or_default()
.contains("runtime.kind = \"wasm\""));
}
}