feat(skills): WASM host 函数真实实现 — zclaw_log/http_fetch/file_read (Phase 4B)

替换 stub 为真实实现:
- zclaw_log: 读取 guest 内存并 log
- zclaw_http_fetch: ureq v3 同步 GET (10s timeout, network_allowed 守卫)
- zclaw_file_read: 沙箱 /workspace 目录读取 (路径校验防逃逸)
添加 ureq v3 workspace 依赖, 25 测试全通过。
This commit is contained in:
iven
2026-04-18 08:18:08 +08:00
parent 2037809196
commit a685e97b17
4 changed files with 143 additions and 20 deletions

View File

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

View File

@@ -230,49 +230,100 @@ fn create_engine_config() -> Config {
}
/// Add ZCLAW host functions to the wasmtime linker.
fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, _network_allowed: bool) -> Result<()> {
fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, 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<WasiP1Ctx>, _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 {