diff --git a/Cargo.lock b/Cargo.lock index 7ce9334..1ee2762 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5492,6 +5492,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -7858,6 +7859,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -7895,6 +7925,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -9723,7 +9759,6 @@ dependencies = [ "tracing", "uuid", "zclaw-hands", - "zclaw-kernel", "zclaw-runtime", "zclaw-skills", "zclaw-types", @@ -9840,6 +9875,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "ureq", "uuid", "wasmtime", "wasmtime-wasi", diff --git a/Cargo.toml b/Cargo.toml index 7f491fc..9746469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,9 @@ libsqlite3-sys = { version = "0.27", features = ["bundled"] } # HTTP client (for LLM drivers) reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +# Synchronous HTTP (for WASM host functions in blocking threads) +ureq = { version = "3", features = ["rustls"] } + # URL parsing url = "2" diff --git a/crates/zclaw-skills/Cargo.toml b/crates/zclaw-skills/Cargo.toml index 99a1e00..3fd2b6b 100644 --- a/crates/zclaw-skills/Cargo.toml +++ b/crates/zclaw-skills/Cargo.toml @@ -9,7 +9,7 @@ description = "ZCLAW skill system" [features] default = [] -wasm = ["wasmtime", "wasmtime-wasi/p1"] +wasm = ["wasmtime", "wasmtime-wasi/p1", "ureq"] [dependencies] zclaw-types = { workspace = true } @@ -27,3 +27,4 @@ shlex = { workspace = true } # Optional WASM runtime (enable with --features wasm) wasmtime = { workspace = true, optional = true } wasmtime-wasi = { workspace = true, optional = true } +ureq = { workspace = true, optional = true } diff --git a/crates/zclaw-skills/src/wasm_runner.rs b/crates/zclaw-skills/src/wasm_runner.rs index e48d9b3..a75f876 100644 --- a/crates/zclaw-skills/src/wasm_runner.rs +++ b/crates/zclaw-skills/src/wasm_runner.rs @@ -230,49 +230,100 @@ fn create_engine_config() -> Config { } /// Add ZCLAW host functions to the wasmtime linker. -fn add_host_functions(linker: &mut Linker, _network_allowed: bool) -> Result<()> { +fn add_host_functions(linker: &mut Linker, network_allowed: bool) -> Result<()> { linker .func_wrap( "env", "zclaw_log", - |_caller: Caller<'_, WasiP1Ctx>, _ptr: u32, _len: u32| { - debug!("[WasmSkill] guest called zclaw_log"); + |mut caller: Caller<'_, WasiP1Ctx>, ptr: u32, len: u32| { + let msg = read_guest_string(&mut caller, ptr, len); + debug!("[WasmSkill] guest log: {}", msg); }, ) .map_err(|e| { zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_log: {}", e)) })?; + // zclaw_http_fetch(url_ptr, url_len, out_ptr, out_cap) -> bytes_written (-1 = error) + // Performs a synchronous GET request. Result is written to guest memory as JSON string. + let net = network_allowed; linker .func_wrap( "env", "zclaw_http_fetch", - |_caller: Caller<'_, WasiP1Ctx>, - _url_ptr: u32, - _url_len: u32, - _out_ptr: u32, - _out_cap: u32| - -> i32 { - warn!("[WasmSkill] guest called zclaw_http_fetch — denied"); - -1 + move |mut caller: Caller<'_, WasiP1Ctx>, + url_ptr: u32, + url_len: u32, + out_ptr: u32, + out_cap: u32| + -> i32 { + if !net { + warn!("[WasmSkill] guest called zclaw_http_fetch — denied (network not allowed)"); + return -1; + } + + let url = read_guest_string(&mut caller, url_ptr, url_len); + if url.is_empty() { + return -1; + } + + debug!("[WasmSkill] guest http_fetch: {}", url); + + // Synchronous HTTP GET (we're already on a blocking thread) + let agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(10))) + .build() + .new_agent(); + let response = agent.get(&url).call(); + + match response { + Ok(mut resp) => { + let body = resp.body_mut().read_to_string().unwrap_or_default(); + write_guest_bytes(&mut caller, out_ptr, out_cap, body.as_bytes()) + } + Err(e) => { + warn!("[WasmSkill] http_fetch error for {}: {}", url, e); + -1 + } + } }, ) .map_err(|e| { zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_http_fetch: {}", e)) })?; + // zclaw_file_read(path_ptr, path_len, out_ptr, out_cap) -> bytes_written (-1 = error) + // Reads a file from the preopened /workspace directory. Paths must be relative. linker .func_wrap( "env", "zclaw_file_read", - |_caller: Caller<'_, WasiP1Ctx>, - _path_ptr: u32, - _path_len: u32, - _out_ptr: u32, - _out_cap: u32| + |mut caller: Caller<'_, WasiP1Ctx>, + path_ptr: u32, + path_len: u32, + out_ptr: u32, + out_cap: u32| -> i32 { - warn!("[WasmSkill] guest called zclaw_file_read — denied"); - -1 + let path = read_guest_string(&mut caller, path_ptr, path_len); + if path.is_empty() { + return -1; + } + + // Security: only allow reads under /workspace (preopen root) + if path.starts_with("..") || path.starts_with('/') { + warn!("[WasmSkill] guest file_read denied — path escapes sandbox: {}", path); + return -1; + } + + let full_path = format!("/workspace/{}", path); + + match std::fs::read(&full_path) { + Ok(data) => write_guest_bytes(&mut caller, out_ptr, out_cap, &data), + Err(e) => { + debug!("[WasmSkill] file_read error for {}: {}", path, e); + -1 + } + } }, ) .map_err(|e| { @@ -282,6 +333,38 @@ fn add_host_functions(linker: &mut Linker, _network_allowed: bool) -> Ok(()) } +/// Read a string from WASM guest memory. +fn read_guest_string(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, len: u32) -> String { + let mem = match caller.get_export("memory") { + Some(Extern::Memory(m)) => m, + _ => return String::new(), + }; + let offset = ptr as usize; + let length = len as usize; + let data = mem.data(&caller); + if offset + length > data.len() { + return String::new(); + } + String::from_utf8_lossy(&data[offset..offset + length]).into_owned() +} + +/// Write bytes to WASM guest memory. Returns the number of bytes written, or -1 on overflow. +fn write_guest_bytes(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, cap: u32, data: &[u8]) -> i32 { + let mem = match caller.get_export("memory") { + Some(Extern::Memory(m)) => m, + _ => return -1, + }; + let offset = ptr as usize; + let capacity = cap as usize; + let write_len = data.len().min(capacity); + if offset + write_len > mem.data_size(&caller) { + return -1; + } + // Safety: we've bounds-checked the write region. + mem.data_mut(&mut *caller)[offset..offset + write_len].copy_from_slice(&data[..write_len]); + write_len as i32 +} + #[cfg(test)] mod tests {