From 604f64f3e7b292c76ce7ffca9aa8f877186544ea Mon Sep 17 00:00:00 2001 From: Chummy Date: Wed, 25 Feb 2026 13:17:58 +0000 Subject: [PATCH] feat(runtime): add configurable wasm security runtime and tooling --- Cargo.lock | 109 ++++++- Cargo.toml | 5 + dev/README.md | 20 ++ dev/config.wasm.dev.toml | 25 ++ dev/config.wasm.prod.toml | 25 ++ dev/config.wasm.staging.toml | 25 ++ docs/config-reference.md | 38 ++- src/config/mod.rs | 4 +- src/config/schema.rs | 224 +++++++++++++- src/runtime/docker.rs | 4 + src/runtime/mod.rs | 20 +- src/runtime/native.rs | 4 + src/runtime/traits.rs | 8 + src/runtime/wasm.rs | 557 ++++++++++++++++++++++++++++++++--- src/tools/mod.rs | 151 ++++++++-- src/tools/process.rs | 4 + src/tools/wasm_module.rs | 314 ++++++++++++++++++++ 17 files changed, 1460 insertions(+), 77 deletions(-) create mode 100644 dev/config.wasm.dev.toml create mode 100644 dev/config.wasm.prod.toml create mode 100644 dev/config.wasm.staging.toml create mode 100644 src/tools/wasm_module.rs diff --git a/Cargo.lock b/Cargo.lock index 2b8ce8465..5db686780 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 4365baa24..43e4922d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 = [] diff --git a/dev/README.md b/dev/README.md index 427b5660f..4f66fe1d1 100644 --- a/dev/README.md +++ b/dev/README.md @@ -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. diff --git a/dev/config.wasm.dev.toml b/dev/config.wasm.dev.toml new file mode 100644 index 000000000..b99e27989 --- /dev/null +++ b/dev/config.wasm.dev.toml @@ -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" diff --git a/dev/config.wasm.prod.toml b/dev/config.wasm.prod.toml new file mode 100644 index 000000000..cc654faf8 --- /dev/null +++ b/dev/config.wasm.prod.toml @@ -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" diff --git a/dev/config.wasm.staging.toml b/dev/config.wasm.staging.toml new file mode 100644 index 000000000..5f0d6ac56 --- /dev/null +++ b/dev/config.wasm.staging.toml @@ -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" diff --git a/docs/config-reference.md b/docs/config-reference.md index 7f8a36bea..b736b5f5d 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -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]` diff --git a/src/config/mod.rs b/src/config/mod.rs index c58f38cc0..1c10abfa5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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(channel: Option<&T>) -> (&'static str, bool) { diff --git a/src/config/schema.rs b/src/config/schema.rs index fd1f1b94f..bb521aeb0 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -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, } +/// 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, + + /// 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 { 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#" diff --git a/src/runtime/docker.rs b/src/runtime/docker.rs index 695b44cc2..ad749302d 100644 --- a/src/runtime/docker.rs +++ b/src/runtime/docker.rs @@ -53,6 +53,10 @@ impl DockerRuntime { } impl RuntimeAdapter for DockerRuntime { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn name(&self) -> &str { "docker" } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index cea7aa30f..fbdc4191a 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -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 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 { diff --git a/src/runtime/native.rs b/src/runtime/native.rs index 927c89514..e1b1b8587 100644 --- a/src/runtime/native.rs +++ b/src/runtime/native.rs @@ -11,6 +11,10 @@ impl NativeRuntime { } impl RuntimeAdapter for NativeRuntime { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn name(&self) -> &str { "native" } diff --git a/src/runtime/traits.rs b/src/runtime/traits.rs index 7e3e06a6c..b73fcb0d7 100644 --- a/src/runtime/traits.rs +++ b/src/runtime/traits.rs @@ -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" } diff --git a/src/runtime/wasm.rs b/src/runtime/wasm.rs index fd4142756..cfbe30618 100644 --- a/src/runtime/wasm.rs +++ b/src/runtime/wasm.rs @@ -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 { + 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> + where + I: IntoIterator, + { + 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 { + 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::>(), + 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 { 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" diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 261f18e36..bd272886f 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -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, runtime: Arc, ) -> Vec> { - 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> = 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::() { + 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> { + 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> = 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::() { + 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 = + 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 = + 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 = + Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap()); + let runtime: Arc = + 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()); diff --git a/src/tools/process.rs b/src/tools/process.rs index 1fda35e60..146b0da9e 100644 --- a/src/tools/process.rs +++ b/src/tools/process.rs @@ -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" } diff --git a/src/tools/wasm_module.rs b/src/tools/wasm_module.rs new file mode 100644 index 000000000..950058079 --- /dev/null +++ b/src/tools/wasm_module.rs @@ -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, + runtime: Arc, +} + +impl WasmModuleTool { + pub fn new(security: Arc, runtime: Arc) -> Self { + Self { security, runtime } + } + + fn wasm_runtime(&self) -> Option<&WasmRuntime> { + self.runtime.as_any().downcast_ref::() + } + + fn parse_caps(args: &serde_json::Value) -> anyhow::Result { + 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 { + 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 { + 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 = + 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 = + 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 = + 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 = + 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 = 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\"")); + } +}