feat(skills): add WASM skill engine with secure registry install

This commit is contained in:
argenis de la rosa
2026-02-26 22:01:38 -05:00
committed by Argenis
parent d63a6a8ceb
commit 8180e7dc82
38 changed files with 4203 additions and 83 deletions
+3
View File
@@ -15,6 +15,9 @@ indent_size = 4
# Trailing whitespace is significant in Markdown (line breaks).
trim_trailing_whitespace = false
[*.go]
indent_style = tab
[*.{yml,yaml}]
indent_size = 2
Generated
+13
View File
@@ -8516,6 +8516,7 @@ dependencies = [
"webpki-roots 1.0.6",
"which",
"wiremock",
"zip",
]
[[package]]
@@ -8655,6 +8656,18 @@ dependencies = [
"syn 2.0.116",
]
[[package]]
name = "zip"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [
"byteorder",
"crc32fast",
"crossbeam-utils",
"flate2",
]
[[package]]
name = "zlib-rs"
version = "0.6.2"
+19 -14
View File
@@ -58,9 +58,11 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
# URL encoding for web search
urlencoding = "2.1"
# HTML conversion providers (web_fetch tool)
fast_html2md = { version = "0.0.58", optional = true }
nanohtml2text = { version = "0.2", optional = true }
# HTML to plain text conversion (web_fetch tool)
nanohtml2text = "0.2"
# Zip archive extraction
zip = { version = "0.6", default-features = false, features = ["deflate"] }
# Optional Rust-native browser automation backend
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
@@ -104,7 +106,6 @@ prost = { version = "0.14", default-features = false, features = ["derive"], opt
# Memory / persistence
rusqlite = { version = "0.37", features = ["bundled"] }
postgres = { version = "0.19", features = ["with-chrono-0_4"], optional = true }
tokio-postgres-rustls = { version = "0.12", optional = true }
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
chrono-tz = "0.10"
cron = "0.15"
@@ -172,6 +173,11 @@ probe-rs = { version = "0.31", optional = true }
pdf-extract = { version = "0.10", optional = true }
tempfile = "3.14"
# WASM plugin runtime (optional, enable with --features wasm-tools)
# Uses WASI stdio protocol — tools read JSON from stdin, write JSON to stdout.
wasmtime = { version = "28", optional = true, default-features = false, features = ["cranelift", "runtime"] }
wasmtime-wasi = { version = "28", optional = true, default-features = false, features = ["preview1"] }
# Terminal QR rendering for WhatsApp Web pairing flow.
qrcode = { version = "0.14", optional = true }
@@ -189,16 +195,17 @@ wa-rs-tokio-transport = { version = "0.2", optional = true, default-features = f
rppal = { version = "0.22", optional = true }
landlock = { version = "0.4", optional = true }
# Unix-specific dependencies (for root check, etc.)
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[features]
default = ["channel-lark", "web-fetch-html2md"]
default = ["wasm-tools"]
hardware = ["nusb", "tokio-serial"]
channel-matrix = ["dep:matrix-sdk"]
channel-lark = ["dep:prost"]
memory-postgres = ["dep:postgres", "dep:tokio-postgres-rustls"]
memory-postgres = ["dep:postgres"]
observability-otel = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp"]
web-fetch-html2md = ["dep:fast_html2md"]
web-fetch-plaintext = ["dep:nanohtml2text"]
firecrawl = []
peripheral-rpi = ["rppal"]
# Browser backend feature alias used by cfg(feature = "browser-native")
browser-native = ["dep:fantoccini"]
@@ -215,6 +222,8 @@ landlock = ["sandbox-landlock"]
probe = ["dep:probe-rs"]
# rag-pdf = PDF ingestion for datasheet RAG
rag-pdf = ["dep:pdf-extract"]
# wasm-tools = WASM plugin engine for dynamically-loaded tool packages (WASI stdio protocol)
wasm-tools = ["dep:wasmtime", "dep:wasmtime-wasi"]
# whatsapp-web = Native WhatsApp Web client with custom rusqlite storage backend
whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "dep:serde-big-array", "dep:prost", "dep:qrcode"]
@@ -240,15 +249,11 @@ strip = true
panic = "abort"
[dev-dependencies]
tempfile = "3.26"
tempfile = "3.14"
criterion = { version = "0.8", features = ["async_tokio"] }
wiremock = "0.6"
scopeguard = "1.2"
[[bin]]
name = "zeroclaw"
path = "src/main.rs"
[[bench]]
name = "agent_benchmarks"
harness = false
+42 -2
View File
@@ -433,7 +433,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze
| **AI Models** | `Provider` | Provider catalog via `zeroclaw providers` (built-ins + aliases, plus custom endpoints) | `custom:https://your-api.com` (OpenAI-compatible) or `anthropic-custom:https://your-api.com` |
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, Mattermost, iMessage, Matrix, Signal, WhatsApp, Linq, Email, IRC, Lark, DingTalk, QQ, Nostr, Webhook | Any messaging API |
| **Memory** | `Memory` | SQLite hybrid search, PostgreSQL backend (configurable storage provider), Lucid bridge, Markdown files, explicit `none` backend, snapshot/hydrate, optional response cache | Any persistence backend |
| **Tools** | `Tool` | shell/file/memory, cron/schedule, git, pushover, browser, http_request, screenshot/image_info, composio (opt-in), delegate, hardware tools | Any capability |
| **Tools** | `Tool` | shell/file/memory, cron/schedule, git, pushover, browser, http_request, screenshot/image_info, composio (opt-in), delegate, hardware tools, **WASM skills** (opt-in) | Any capability |
| **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
| **Runtime** | `RuntimeAdapter` | Native, Docker (sandboxed) | Additional runtimes can be added via adapter; unsupported kinds fail fast |
| **Security** | `SecurityPolicy` | Gateway pairing, sandbox, allowlists, rate limits, filesystem scoping, encrypted secrets | — |
@@ -1002,7 +1002,7 @@ See [aieos.org](https://aieos.org) for the full schema and live examples.
| `providers` | List supported providers and aliases |
| `channel` | List/start/doctor channels and bind Telegram identities |
| `integrations` | Inspect integration setup details |
| `skills` | List/install/remove skills |
| `skills` | List/install/remove skills; supports ClawhHub URLs, local zip files, ZeroMarket registry, git remotes |
| `migrate` | Import data from other runtimes (`migrate openclaw`) |
| `completions` | Generate shell completion scripts (`bash`, `fish`, `zsh`, `powershell`, `elvish`) |
| `hardware` | USB discover/introspect/info commands |
@@ -1049,6 +1049,45 @@ You can also override at runtime with `ZEROCLAW_OPEN_SKILLS_ENABLED`, `ZEROCLAW_
Skill installs are now gated by a built-in static security audit. `zeroclaw skills install <source>` blocks symlinks, script-like files, unsafe markdown link patterns, and high-risk shell payload snippets before accepting a skill. You can run `zeroclaw skills audit <source_or_name>` to validate a local directory or an installed skill manually.
### WASM Skills
ZeroClaw supports WASM-compiled skills installable from the [ZeroMarket](https://zeromarket.vercel.app) registry and zip-based registries like [ClawhHub](https://clawhub.ai):
```bash
# Install from ZeroMarket registry
zeroclaw skill install namespace/name
# Install from ClawhHub (auto-detected by domain)
zeroclaw skill install https://clawhub.ai/steipete/summarize
# Install using ClawhHub short prefix
zeroclaw skill install clawhub:summarize
# Install from a zip file already downloaded locally
zeroclaw skill install ~/Downloads/summarize-1.0.0.zip
# Install from any direct zip URL
zeroclaw skill install zip:https://example.com/my-skill.zip
```
If ClawhHub returns 429 (rate limit) or requires authentication, add to `~/.zeroclaw/config.toml`:
```toml
[skills]
clawhub_token = "your-clawhub-token"
```
Skills are installed to `~/.zeroclaw/workspace/skills/<name>/` and loaded automatically as tools at agent runtime. No system `unzip` binary required — zip extraction is handled in-process.
Build with WASM tool support (enabled by default):
```bash
cargo build --release # wasm-tools enabled by default
cargo build --release --no-default-features # disable wasm-tools for smaller binary
```
Publish your own skill to ZeroMarket: compile to WASM, upload `tool.wasm`, `manifest.json`, and `SKILL.md` via the ZeroMarket upload page. Use `zeroclaw skill new <name>` to scaffold a new skill project.
## Development
```bash
@@ -1097,6 +1136,7 @@ Start from the docs hub for a task-oriented map:
- Unified docs TOC: [`docs/SUMMARY.md`](docs/SUMMARY.md)
- Commands reference: [`docs/commands-reference.md`](docs/commands-reference.md)
- Config reference: [`docs/config-reference.md`](docs/config-reference.md)
- WASM skills guide: [`docs/wasm-tools-guide.md`](docs/wasm-tools-guide.md)
- Providers reference: [`docs/providers-reference.md`](docs/providers-reference.md)
- Channels reference: [`docs/channels-reference.md`](docs/channels-reference.md)
- Operations runbook: [`docs/operations-runbook.md`](docs/operations-runbook.md)
+2 -1
View File
@@ -2,7 +2,7 @@
This file is the canonical table of contents for the documentation system.
Last refreshed: **February 18, 2026**.
Last refreshed: **February 25, 2026**.
## Language Entry
@@ -44,6 +44,7 @@ Last refreshed: **February 18, 2026**.
- [channels-reference.md](channels-reference.md)
- [nextcloud-talk-setup.md](nextcloud-talk-setup.md)
- [config-reference.md](config-reference.md)
- [wasm-tools-guide.md](wasm-tools-guide.md)
- [custom-providers.md](custom-providers.md)
- [zai-glm-setup.md](zai-glm-setup.md)
- [langgraph-integration.md](langgraph-integration.md)
+34 -1
View File
@@ -194,7 +194,38 @@ Channel runtime also watches `config.toml` and hot-applies updates to:
- `zeroclaw skills install <source>`
- `zeroclaw skills remove <name>`
`<source>` accepts git remotes (`https://...`, `http://...`, `ssh://...`, and `git@host:owner/repo.git`) or a local filesystem path.
`<source>` accepts:
| Format | Example | Notes |
|---|---|---|
| **ClawhHub profile URL** | `https://clawhub.ai/steipete/summarize` | Auto-detected by domain; downloads zip from ClawhHub API |
| **ClawhHub short prefix** | `clawhub:summarize` | Short form; slug is the skill name on ClawhHub |
| **Direct zip URL** | `zip:https://example.com/skill.zip` | Any HTTPS URL returning a zip archive |
| **Local zip file** | `/path/to/skill.zip` | Zip file already downloaded to local disk |
| **Registry packages** | `namespace/name` or `namespace/name@version` | Fetched from the configured registry (default: ZeroMarket) |
| **Git remotes** | `https://github.com/…`, `git@host:owner/repo.git` | Cloned with `git clone --depth 1` |
| **Local filesystem paths** | `./my-skill` or `/abs/path/skill` | Directory copied and audited |
**ClawhHub install examples:**
```bash
# Install by profile URL (slug extracted from last path segment)
zeroclaw skill install https://clawhub.ai/steipete/summarize
# Install using short prefix
zeroclaw skill install clawhub:summarize
# Install from a zip already downloaded locally
zeroclaw skill install ~/Downloads/summarize-1.0.0.zip
```
If the ClawhHub API returns 429 (rate limit) or requires authentication, set `clawhub_token` in `[skills]` config (see [config reference](config-reference.md#skills)).
**Zip-based install behavior:**
- If the zip contains `_meta.json` (OpenClaw convention), name/version/author are read from it.
- A minimal `SKILL.toml` is written automatically if neither `SKILL.toml` nor `SKILL.md` is present in the zip.
Registry packages are installed to `~/.zeroclaw/workspace/skills/<name>/`.
`skills install` always runs a built-in static security audit before the skill is accepted. The audit blocks:
- symlinks inside the skill package
@@ -202,6 +233,8 @@ Channel runtime also watches `config.toml` and hot-applies updates to:
- high-risk command snippets (for example pipe-to-shell payloads)
- markdown links that escape the skill root, point to remote markdown, or target script files
> **Note:** The security audit applies to directory-based installs (local paths, git remotes). Zip-based installs (ClawhHub, direct zip URLs, local zip files) perform path-traversal safety checks during extraction but do not run the full static audit — review zip contents manually for untrusted sources.
Use `skills audit` to manually validate a candidate skill directory (or an installed skill by name) before sharing it.
Skill manifests (`SKILL.toml`) support `prompts` and `[[tools]]`; both are injected into the agent system prompt at runtime, so the model can follow skill instructions without manually reading skill files.
+9
View File
@@ -367,6 +367,7 @@ Notes:
| `open_skills_enabled` | `false` | Opt-in loading/sync of community `open-skills` repository |
| `open_skills_dir` | unset | Optional local path for `open-skills` (defaults to `$HOME/open-skills` when enabled) |
| `prompt_injection_mode` | `full` | Skill prompt verbosity: `full` (inline instructions/tools) or `compact` (name/description/location only) |
| `clawhub_token` | unset | Optional Bearer token for authenticated ClawhHub skill downloads |
Notes:
@@ -378,6 +379,14 @@ Notes:
- Precedence for enable flag: `ZEROCLAW_OPEN_SKILLS_ENABLED``skills.open_skills_enabled` in `config.toml` → default `false`.
- `prompt_injection_mode = "compact"` is recommended on low-context local models to reduce startup prompt size while keeping skill files available on demand.
- Skill loading and `zeroclaw skills install` both apply a static security audit. Skills that contain symlinks, script-like files, high-risk shell payload snippets, or unsafe markdown link traversal are rejected.
- `clawhub_token` is sent as `Authorization: Bearer <token>` when downloading from ClawhHub. Obtain a token from [https://clawhub.ai](https://clawhub.ai) after signing in. Required if the API returns 429 (rate-limited) or 401 (unauthorized) for anonymous requests.
**ClawhHub token example:**
```toml
[skills]
clawhub_token = "your-token-here"
```
## `[composio]`
+1
View File
@@ -57,6 +57,7 @@
- [channels-reference.md](channels-reference.md) — khả năng kênh và hướng dẫn thiết lập
- [matrix-e2ee-guide.md](matrix-e2ee-guide.md) — thiết lập phòng mã hóa Matrix (E2EE)
- [config-reference.md](config-reference.md) — khóa cấu hình quan trọng và giá trị mặc định an toàn
- [wasm-tools-guide.md](wasm-tools-guide.md) — tạo, cài đặt và xuất bản WASM skills
- [custom-providers.md](custom-providers.md) — mẫu tích hợp provider / base URL tùy chỉnh
- [zai-glm-setup.md](zai-glm-setup.md) — thiết lập Z.AI/GLM và ma trận endpoint
- [langgraph-integration.md](langgraph-integration.md) — tích hợp dự phòng cho model/tool-calling
+689
View File
@@ -0,0 +1,689 @@
# WASM Tools Guide
This guide covers everything you need to build, install, and use WASM-based tools
(skills) in ZeroClaw. WASM tools let you extend the agent with custom capabilities
written in any language that compiles to WebAssembly — without modifying ZeroClaw's
core source code.
---
## Table of Contents
1. [How It Works](#1-how-it-works)
2. [Prerequisites](#2-prerequisites)
3. [Creating a Tool](#3-creating-a-tool)
- [Scaffold from template](#31-scaffold-from-template)
- [Protocol: stdin / stdout](#32-protocol-stdin--stdout)
- [manifest.json](#33-manifestjson)
- [Template: Rust](#34-template-rust)
- [Template: TypeScript](#35-template-typescript)
- [Template: Go](#36-template-go)
- [Template: Python](#37-template-python)
4. [Building](#4-building)
5. [Testing Locally](#5-testing-locally)
6. [Installing](#6-installing)
- [From a local path](#61-install-from-a-local-path)
- [From a git repository](#62-install-from-a-git-repository)
- [From ZeroMarket registry](#63-install-from-zeromarket-registry)
7. [How ZeroClaw Loads and Uses the Tool](#7-how-zeroclaw-loads-and-uses-the-tool)
8. [Directory Layout Reference](#8-directory-layout-reference)
9. [Configuration (`[wasm]` section)](#9-configuration-wasm-section)
10. [Security Model](#10-security-model)
11. [Troubleshooting](#11-troubleshooting)
---
## 1. How It Works
```
┌─────────────────────────────────────────────────────────────┐
│ Your WASM tool (.wasm binary) │
│ │
│ stdin ← JSON args from LLM │
│ stdout → JSON result { success, output, error } │
└───────────────────────┬─────────────────────────────────────┘
│ WASI stdio protocol
┌───────────────────────▼─────────────────────────────────────┐
│ ZeroClaw WASM engine (wasmtime + WASI) │
│ │
│ • loads tool.wasm + manifest.json from skills/ directory │
│ • registers the tool with the agent's tool registry │
│ • invokes the tool when the LLM selects it │
│ • enforces memory, fuel, and output size limits │
└─────────────────────────────────────────────────────────────┘
```
The key insight: **no custom SDK or ABI boilerplate**. Any language that can read
from stdin and write to stdout works. The only contract is the JSON shape described
in [section 2](#32-protocol-stdin--stdout).
---
## 2. Prerequisites
| Requirement | Purpose |
|---|---|
| ZeroClaw built with `--features wasm-tools` | Enables the WASM runtime |
| `wasmtime` CLI | Local testing (`zeroclaw skill test`) |
| Language-specific toolchain | Building `.wasm` from source |
Install `wasmtime` CLI:
```bash
# macOS / Linux
curl https://wasmtime.dev/install.sh -sSf | bash
# Or via cargo
cargo install wasmtime-cli
```
Enable WASM support at compile time:
```bash
cargo build --release --features wasm-tools
```
---
## 3. Creating a Tool
### 3.1 Scaffold from template
```bash
zeroclaw skill new <name> --template <typescript|rust|go|python>
```
Example:
```bash
zeroclaw skill new weather_lookup --template rust
```
This creates a new directory `./weather_lookup/` with all boilerplate files ready
to build. The `--template` flag defaults to `typescript` if omitted.
Supported templates:
| Template | Runtime | Build tool |
|---|---|---|
| `typescript` | Javy (JS → WASM) | `npm run build` |
| `rust` | native wasm32-wasip1 | `cargo build` |
| `go` | TinyGo | `tinygo build` |
| `python` | componentize-py | `componentize-py` |
---
### 3.2 Protocol: stdin / stdout
Every WASM tool must follow this single contract:
**Input** (written to the tool's stdin by ZeroClaw):
```json
{ "param1": "value1", "param2": 42 }
```
The shape of the input object is whatever you define in `manifest.json` under
`parameters`. ZeroClaw passes the LLM-provided argument object verbatim.
**Output** (read from the tool's stdout by ZeroClaw):
```json
{ "success": true, "output": "result text shown to LLM", "error": null }
{ "success": false, "output": "", "error": "reason" }
```
| Field | Type | Required | Description |
|---|---|---|---|
| `success` | bool | yes | `true` if tool completed normally |
| `output` | string | yes | Result text forwarded to the LLM |
| `error` | string or null | yes | Error message when `success` is `false` |
---
### 3.3 manifest.json
Every tool must ship a `manifest.json` alongside `tool.wasm`. This file tells
ZeroClaw the tool's name, description, and the JSON Schema for its parameters.
```json
{
"name": "weather_lookup",
"description": "Fetches the current weather for a given city name.",
"version": "1",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name to look up (e.g. Hanoi, Tokyo)"
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "Temperature unit system"
}
},
"required": ["city"]
},
"homepage": "https://github.com/yourname/weather_lookup"
}
```
| Field | Required | Description |
|---|---|---|
| `name` | yes | snake_case tool name exposed to the LLM |
| `description` | yes | Human-readable description (shown to LLM for tool selection) |
| `version` | no | Manifest format version, default `"1"` |
| `parameters` | yes | JSON Schema for the tool's input parameters |
| `homepage` | no | Optional URL shown in `zeroclaw skill list` |
The `name` field is the identifier the LLM uses when it decides to call your tool.
Keep it descriptive and unique.
---
### 3.4 Template: Rust
**Scaffolded files:** `Cargo.toml`, `src/lib.rs`, `.cargo/config.toml`
`src/lib.rs`:
```rust
use std::io::{self, Read, Write};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Args {
city: String,
#[serde(default)]
units: String,
}
#[derive(Serialize)]
struct ToolResult {
success: bool,
output: String,
error: Option<String>,
}
fn main() {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf).unwrap();
let result = match serde_json::from_str::<Args>(&buf) {
Ok(args) => run(args),
Err(e) => ToolResult {
success: false,
output: String::new(),
error: Some(format!("invalid input: {e}")),
},
};
io::stdout()
.write_all(serde_json::to_string(&result).unwrap().as_bytes())
.unwrap();
}
fn run(args: Args) -> ToolResult {
// Your logic here
ToolResult {
success: true,
output: format!("Weather in {}: sunny 28°C", args.city),
error: None,
}
}
```
**Build:**
```bash
# Add the target once
rustup target add wasm32-wasip1
# Build
cargo build --target wasm32-wasip1 --release
cp target/wasm32-wasip1/release/weather_lookup.wasm tool.wasm
```
---
### 3.5 Template: TypeScript
**Scaffolded files:** `package.json`, `tsconfig.json`, `src/index.ts`
`src/index.ts`:
```typescript
// Read input from stdin (Javy provides Javy.IO)
const input = JSON.parse(
new TextDecoder().decode(Javy.IO.readSync())
);
function run(args: Record<string, unknown>): string {
const city = String(args["city"] ?? "");
// Your logic here
return `Weather in ${city}: sunny 28°C`;
}
try {
const output = run(input);
Javy.IO.writeSync(
new TextEncoder().encode(
JSON.stringify({ success: true, output, error: null })
)
);
} catch (err) {
Javy.IO.writeSync(
new TextEncoder().encode(
JSON.stringify({ success: false, output: "", error: String(err) })
)
);
}
```
**Build:**
```bash
# Install Javy: https://github.com/bytecodealliance/javy/releases
npm install
npm run build # → tool.wasm
```
---
### 3.6 Template: Go
**Scaffolded files:** `go.mod`, `main.go`
`main.go`:
```go
package main
import (
"encoding/json"
"fmt"
"io"
"os"
)
type Args struct {
City string `json:"city"`
Units string `json:"units"`
}
type ToolResult struct {
Success bool `json:"success"`
Output string `json:"output"`
Error *string `json:"error"`
}
func main() {
data, _ := io.ReadAll(os.Stdin)
var args Args
if err := json.Unmarshal(data, &args); err != nil {
msg := err.Error()
out, _ := json.Marshal(ToolResult{Error: &msg})
os.Stdout.Write(out)
return
}
result := run(args)
out, _ := json.Marshal(result)
os.Stdout.Write(out)
}
func run(args Args) ToolResult {
return ToolResult{
Success: true,
Output: fmt.Sprintf("Weather in %s: sunny 28°C", args.City),
}
}
```
**Build:**
```bash
# Install TinyGo: https://tinygo.org/getting-started/install/
tinygo build -o tool.wasm -target wasi .
```
---
### 3.7 Template: Python
**Scaffolded files:** `app.py`, `requirements.txt`
`app.py`:
```python
import sys
import json
def run(args: dict) -> str:
city = str(args.get("city", ""))
# Your logic here
return f"Weather in {city}: sunny 28°C"
def main():
raw = sys.stdin.read()
try:
args = json.loads(raw)
output = run(args)
result = {"success": True, "output": output, "error": None}
except Exception as exc:
result = {"success": False, "output": "", "error": str(exc)}
sys.stdout.write(json.dumps(result))
if __name__ == "__main__":
main()
```
**Build:**
```bash
pip install componentize-py
componentize-py -d wit/ -w zeroclaw-skill componentize app -o tool.wasm
```
---
## 4. Building
After editing your tool logic, build it into `tool.wasm`:
| Template | Build command | Output |
|---|---|---|
| Rust | `cargo build --target wasm32-wasip1 --release && cp target/wasm32-wasip1/release/*.wasm tool.wasm` | `tool.wasm` |
| TypeScript | `npm run build` | `tool.wasm` |
| Go | `tinygo build -o tool.wasm -target wasi .` | `tool.wasm` |
| Python | `componentize-py -d wit/ -w zeroclaw-skill componentize app -o tool.wasm` | `tool.wasm` |
The output must always be named `tool.wasm` at the root of the skill directory.
---
## 5. Testing Locally
Before installing, test the tool directly without starting the full ZeroClaw agent:
```bash
zeroclaw skill test . --args '{"city":"Hanoi","units":"metric"}'
```
You can also test an installed skill by name:
```bash
zeroclaw skill test weather_lookup --args '{"city":"Tokyo"}'
```
Or test a specific tool inside a multi-tool skill:
```bash
zeroclaw skill test . --tool my_tool_name --args '{"city":"Paris"}'
```
Under the hood, `skill test` pipes the JSON args into `wasmtime run tool.wasm` via
stdin and prints the raw stdout response. This lets you iterate quickly without
restarting the agent.
You can also test manually using `wasmtime` directly:
```bash
echo '{"city":"Hanoi"}' | wasmtime tool.wasm
```
Expected output:
```json
{"success":true,"output":"Weather in Hanoi: sunny 28°C","error":null}
```
---
## 6. Installing
### 6.1 Install from a local path
```bash
zeroclaw skill install ./weather_lookup
```
This copies your skill directory into `<workspace>/skills/weather_lookup/`.
ZeroClaw will auto-discover it on next startup.
### 6.2 Install from a git repository
```bash
zeroclaw skill install https://github.com/yourname/weather_lookup.git
```
ZeroClaw clones the repository into the skills directory and scans for WASM tools.
### 6.3 Install from ZeroMarket registry
```bash
# Format: namespace/package-name
zeroclaw skill install acme/weather-lookup
# With a specific version
zeroclaw skill install acme/weather-lookup@0.2.1
```
ZeroClaw fetches the package index from the configured registry URL, then downloads
`tool.wasm` and `manifest.json` for each tool in the package.
**Verify the install:**
```bash
zeroclaw skill list
```
---
## 7. How ZeroClaw Loads and Uses the Tool
### 7.1 Startup discovery
Every time the ZeroClaw agent starts, it scans the `skills/` directory and loads
all valid WASM tools automatically. No config change or restart command is needed
after installation.
```
<workspace>/
└── skills/
└── weather_lookup/ ← skill package root
├── SKILL.toml
└── tools/
└── weather_lookup/ ← individual tool directory
├── tool.wasm ← compiled WASM binary
└── manifest.json ← tool metadata
```
A simpler "dev layout" is also supported (useful right after building):
```
<workspace>/
└── skills/
└── weather_lookup/
├── tool.wasm
└── manifest.json
```
### 7.2 Tool registration
After discovery, each `WasmTool` is registered in the agent's tool registry
alongside built-in tools like `shell`, `file`, `web_fetch`, etc. The LLM sees
all registered tools equally — it has no way to distinguish a built-in tool from
a WASM plugin.
### 7.3 LLM tool selection
When a user sends a message, the agent attaches the full tool registry (including
all WASM tools) to the LLM context. The LLM reads each tool's `name` and
`description` from the manifest and decides which tool to call based on the
user's request.
Example conversation:
```
User: What is the weather in Hanoi right now?
Agent: [internally, LLM selects tool "weather_lookup" with args {"city":"Hanoi"}]
ZeroClaw calls weather_lookup WASM tool:
stdin → {"city":"Hanoi"}
stdout ← {"success":true,"output":"Weather in Hanoi: sunny 28°C","error":null}
Agent: The current weather in Hanoi is sunny with a temperature of 28°C.
```
### 7.4 Invocation flow
```
LLM decides to call "weather_lookup"
WasmTool::execute(args: JSON)
├─ serialize args to stdin bytes
├─ spin up wasmtime WASI sandbox
├─ write stdin → WASM process
├─ read stdout ← WASM process (capped at 1 MiB)
├─ enforce fuel limit (≈ 1 billion instructions)
├─ enforce wall-clock timeout (30 seconds)
└─ deserialize ToolResult JSON
Agent formats output and responds to user
```
### 7.5 Error handling
If a tool fails (non-zero exit, invalid JSON, timeout, fuel exhaustion), ZeroClaw
logs a warning and returns the error to the LLM. The agent continues running —
a broken plugin never crashes the process.
---
## 8. Directory Layout Reference
**Installed layout** (created by `zeroclaw skill install`):
```
skills/
└── <skill-name>/
├── SKILL.toml ← package metadata (shown in skill list)
└── tools/
└── <tool-name>/
├── tool.wasm ← WASM binary
└── manifest.json ← tool metadata
```
**Dev layout** (for quick iteration, right after `cargo build`):
```
skills/
└── <skill-name>/
├── tool.wasm
└── manifest.json
```
Both layouts are discovered automatically. Use dev layout while developing, switch
to installed layout for distribution.
---
## 9. Configuration (`[wasm]` section)
Add this section to your `zeroclaw.toml` to tune WASM tool behavior:
```toml
[wasm]
# Disable all WASM tools (default: true)
enabled = true
# Maximum memory per invocation in MiB, clamped 1256 (default: 64)
memory_limit_mb = 64
# CPU fuel budget — roughly one unit per WASM instruction (default: 1_000_000_000)
fuel_limit = 1_000_000_000
# Registry URL used by `zeroclaw skill install namespace/package`
registry_url = "https://registry.zeromarket.dev"
```
To disable all WASM tools without uninstalling them:
```toml
[wasm]
enabled = false
```
---
## 10. Security Model
WASM tools run inside a strict WASI sandbox enforced by wasmtime:
| Constraint | Default |
|---|---|
| Filesystem access | **Denied** — no preopened directories |
| Network sockets | **Denied** — WASI network not enabled |
| Max memory | 64 MiB (configurable, max 256 MiB) |
| Max CPU instructions | ~1 billion (configurable) |
| Max wall-clock time | 30 seconds hard limit |
| Max output size | 1 MiB |
| Registry transport | HTTPS only — HTTP is rejected |
| Registry path traversal | Tool names validated before writing to disk |
A malicious or buggy WASM tool cannot:
- Read or write files on the host
- Make network connections
- Access environment variables
- Consume unbounded CPU or memory
- Crash the ZeroClaw process
---
## 11. Troubleshooting
**`WASM tools are not enabled in this build`**
Recompile with the feature flag:
```bash
cargo build --release
```
**`wasmtime` not found during `skill test`**
Install the wasmtime CLI:
```bash
curl https://wasmtime.dev/install.sh -sSf | bash
# or
cargo install wasmtime-cli
```
**`WASM module must export '_start'`**
Your binary must be compiled as a WASI executable (not a library). For Rust, ensure
your `Cargo.toml` does **not** set `crate-type = ["cdylib"]` — use the default
binary crate instead. For Go, use `tinygo build -target wasi` (not `wasm`).
**`WASM tool wrote nothing to stdout`**
Your tool exited without writing a JSON result. Check that your `run()` function
always writes to stdout before returning, including in error paths.
**Tool not appearing in `zeroclaw skill list`**
- Verify `manifest.json` exists alongside `tool.wasm`
- Validate the JSON is well-formed: `cat manifest.json | python3 -m json.tool`
- Restart the agent — tools are discovered at startup
**`curl failed` during registry install**
Ensure `curl` is installed and the registry URL uses HTTPS. Custom registries must
be reachable and return the expected package index JSON format.
+13 -1
View File
@@ -4912,7 +4912,19 @@ pub async fn start_channels(config: Config) -> Result<()> {
)),
query_classification: config.query_classification.clone(),
model_routes: config.model_routes.clone(),
approval_manager: Arc::new(ApprovalManager::from_config(&config.autonomy)),
// WASM skill tools are sandboxed by the WASM engine and cannot access the
// host filesystem, network, or shell. Pre-approve them so they are not
// denied on non-CLI channels (which have no interactive stdin to prompt).
approval_manager: {
let mut autonomy = config.autonomy.clone();
let skills_dir = workspace.join("skills");
for name in tools::wasm_tool::wasm_tool_names_from_skills(&skills_dir) {
if !autonomy.auto_approve.contains(&name) {
autonomy.auto_approve.push(name);
}
}
Arc::new(ApprovalManager::from_config(&autonomy))
},
});
run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await;
+140
View File
@@ -275,6 +275,10 @@ pub struct Config {
/// - `Some(false)`: force vision support off
#[serde(default)]
pub model_support_vision: Option<bool>,
/// WASM plugin engine configuration (`[wasm]` section).
#[serde(default)]
pub wasm: WasmConfig,
}
/// Named provider profile definition compatible with Codex app-server style config.
@@ -710,6 +714,58 @@ pub struct SkillsConfig {
/// `full` preserves legacy behavior. `compact` keeps context small and loads skills on demand.
#[serde(default)]
pub prompt_injection_mode: SkillsPromptInjectionMode,
/// Optional ClawhHub API token for authenticated skill downloads.
/// Obtain from https://clawhub.ai after signing in.
/// Set via config: `clawhub_token = "..."` under `[skills]`.
#[serde(default)]
pub clawhub_token: Option<String>,
}
/// WASM plugin engine configuration (`[wasm]` section).
///
/// Controls limits applied to every WASM tool invocation.
/// Requires the `wasm-tools` compile-time feature to have any effect.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WasmConfig {
/// Enable loading WASM tools from installed skill packages.
/// Default: `true` (auto-discovers plugins in the skills directory).
#[serde(default = "default_true")]
pub enabled: bool,
/// Maximum linear memory per WASM invocation in MiB.
/// Valid range: 1..=256. Default: `64`.
#[serde(default = "default_wasm_memory_limit_mb")]
pub memory_limit_mb: u64,
/// CPU fuel budget per invocation (roughly one unit ≈ one WASM instruction).
/// Default: 1_000_000_000.
#[serde(default = "default_wasm_fuel_limit")]
pub fuel_limit: u64,
/// URL of the ZeroMarket (or compatible) registry used by `zeroclaw skill install`.
/// Default: the public ZeroMarket registry.
#[serde(default = "default_registry_url")]
pub registry_url: String,
}
fn default_wasm_memory_limit_mb() -> u64 {
64
}
fn default_wasm_fuel_limit() -> u64 {
1_000_000_000
}
fn default_registry_url() -> String {
"https://zeromarket.vercel.app/api".to_string()
}
impl Default for WasmConfig {
fn default() -> Self {
Self {
enabled: true,
memory_limit_mb: default_wasm_memory_limit_mb(),
fuel_limit: default_wasm_fuel_limit(),
registry_url: default_registry_url(),
}
}
}
/// Multimodal (image) handling configuration (`[multimodal]` section).
@@ -4874,6 +4930,7 @@ impl Default for Config {
transcription: TranscriptionConfig::default(),
agents_ipc: AgentsIpcConfig::default(),
model_support_vision: None,
wasm: WasmConfig::default(),
}
}
}
@@ -6279,6 +6336,34 @@ impl Config {
anyhow::bail!("coordination.max_seen_message_ids must be greater than 0");
}
// WASM config
if self.wasm.memory_limit_mb == 0 || self.wasm.memory_limit_mb > 256 {
anyhow::bail!(
"wasm.memory_limit_mb must be between 1 and 256, got {}",
self.wasm.memory_limit_mb
);
}
if self.wasm.fuel_limit == 0 {
anyhow::bail!("wasm.fuel_limit must be greater than 0");
}
{
let url = &self.wasm.registry_url;
// Extract what comes after "https://" and check that the host part
// (up to the first '/', '?', '#', or ':') is non-empty.
let has_valid_host = url
.strip_prefix("https://")
.map(|rest| {
let host = rest.split(&['/', '?', '#', ':'][..]).next().unwrap_or("");
!host.is_empty()
})
.unwrap_or(false);
if !has_valid_host {
anyhow::bail!(
"wasm.registry_url must be a valid HTTPS URL with a non-empty host, got '{url}'"
);
}
}
Ok(())
}
@@ -6843,6 +6928,59 @@ mod tests {
assert!(c.config_path.to_string_lossy().contains("config.toml"));
}
#[test]
async fn wasm_config_default_has_correct_values() {
let cfg = WasmConfig::default();
assert!(cfg.enabled, "WASM tools should be enabled by default");
assert_eq!(cfg.memory_limit_mb, 64);
assert_eq!(cfg.fuel_limit, 1_000_000_000);
assert_eq!(cfg.registry_url, "https://zeromarket.vercel.app/api");
}
#[test]
async fn wasm_config_invalid_values_rejected() {
let mut c = Config::default();
// memory_limit_mb = 0
c.wasm.memory_limit_mb = 0;
assert!(c.validate().is_err(), "memory_limit_mb=0 should fail");
// memory_limit_mb = 257
c.wasm = WasmConfig::default();
c.wasm.memory_limit_mb = 257;
assert!(c.validate().is_err(), "memory_limit_mb=257 should fail");
// fuel_limit = 0
c.wasm = WasmConfig::default();
c.wasm.fuel_limit = 0;
assert!(c.validate().is_err(), "fuel_limit=0 should fail");
// empty registry_url
c.wasm = WasmConfig::default();
c.wasm.registry_url = String::new();
assert!(c.validate().is_err(), "empty registry_url should fail");
// http:// instead of https://
c.wasm = WasmConfig::default();
c.wasm.registry_url = "http://example.com".to_string();
assert!(c.validate().is_err(), "http registry_url should fail");
// bare "https://"
c.wasm = WasmConfig::default();
c.wasm.registry_url = "https://".to_string();
assert!(c.validate().is_err(), "https:// without host should fail");
// port-only, no hostname
c.wasm = WasmConfig::default();
c.wasm.registry_url = "https://:443".to_string();
assert!(c.validate().is_err(), "https://:443 should fail");
// query-only, no hostname
c.wasm = WasmConfig::default();
c.wasm.registry_url = "https://?q=1".to_string();
assert!(c.validate().is_err(), "https://?q=1 should fail");
}
#[test]
async fn config_debug_redacts_sensitive_values() {
let mut config = Config::default();
@@ -7260,6 +7398,7 @@ default_temperature = 0.7
transcription: TranscriptionConfig::default(),
agents_ipc: AgentsIpcConfig::default(),
model_support_vision: None,
wasm: WasmConfig::default(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
@@ -7629,6 +7768,7 @@ tool_dispatcher = "xml"
transcription: TranscriptionConfig::default(),
agents_ipc: AgentsIpcConfig::default(),
model_support_vision: None,
wasm: WasmConfig::default(),
};
config.save().await.unwrap();
+4 -1
View File
@@ -953,7 +953,10 @@ async fn run_gateway_chat_simple(state: &AppState, message: &str) -> anyhow::Res
}
/// Full-featured chat with tools for channel handlers (WhatsApp, Linq, Nextcloud Talk).
async fn run_gateway_chat_with_tools(state: &AppState, message: &str) -> anyhow::Result<String> {
pub(super) async fn run_gateway_chat_with_tools(
state: &AppState,
message: &str,
) -> anyhow::Result<String> {
let config = state.config.lock().clone();
crate::agent::process_message(config, message).await
}
+2 -22
View File
@@ -242,28 +242,8 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
"model": state.model,
}));
// Run the agent loop with tool execution
let result = run_tool_call_loop(
state.provider.as_ref(),
&mut history,
state.tools_registry_exec.as_ref(),
state.observer.as_ref(),
&provider_label,
&state.model,
state.temperature,
true, // silent - no console output
Some(&approval_manager),
"webchat",
&state.multimodal,
state.max_tool_iterations,
None, // cancellation token
None, // delta streaming
None, // hooks
&[], // excluded tools
)
.await;
match result {
// Full agentic loop with tools (includes WASM skills, shell, memory, etc.)
match super::run_gateway_chat_with_tools(&state, &content).await {
Ok(response) => {
let safe_response =
finalize_ws_response(&response, &history, state.tools_registry_exec.as_ref());
+23 -2
View File
@@ -150,14 +150,33 @@ Examples:
pub enum SkillCommands {
/// List all installed skills
List,
/// Scaffold a new skill project from a template
New {
/// Skill name (snake_case recommended, e.g. my_weather_tool)
name: String,
/// Template language: typescript, rust, go, python
#[arg(long, short, default_value = "typescript")]
template: String,
},
/// Run a skill tool locally for testing (reads args from --args or stdin)
Test {
/// Path to the skill directory or installed skill name
path: String,
/// Optional tool name inside the skill (defaults to first tool found)
#[arg(long)]
tool: Option<String>,
/// JSON arguments to pass to the tool, e.g. '{"city":"Hanoi"}'
#[arg(long, short)]
args: Option<String>,
},
/// Audit a skill source directory or installed skill name
Audit {
/// Skill path or installed skill name
source: String,
},
/// Install a new skill from a URL or local path
/// Install a new skill from a local path, git URL, or registry (namespace/name)
Install {
/// Source URL or local path
/// Source: local path, git URL, or registry package (e.g. acme/my-tool)
source: String,
},
/// Remove an installed skill
@@ -165,6 +184,8 @@ pub enum SkillCommands {
/// Skill name to remove
name: String,
},
/// List all available skill templates
Templates,
}
/// Migration subcommands
+6 -1
View File
@@ -409,6 +409,7 @@ Examples:
},
/// Manage skills (user-defined capabilities)
#[command(name = "skill", alias = "skills")]
Skills {
#[command(subcommand)]
skill_command: SkillCommands,
@@ -857,6 +858,10 @@ async fn main() -> Result<()> {
if let Some(ref backend) = memory_backend {
config.memory.backend = backend.clone();
}
// interactive=true only when no --message flag (real REPL session).
// Single-shot mode (-m) runs non-interactively: no TTY approval prompt,
// so tools are not denied by a stdin read returning EOF.
let interactive = message.is_none();
agent::run(
config,
message,
@@ -864,7 +869,7 @@ async fn main() -> Result<()> {
model,
temperature,
peripheral,
true,
interactive,
)
.await
.map(|_| ())
+2
View File
@@ -183,6 +183,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
transcription: crate::config::TranscriptionConfig::default(),
agents_ipc: crate::config::AgentsIpcConfig::default(),
model_support_vision: None,
wasm: crate::config::WasmConfig::default(),
};
println!(
@@ -542,6 +543,7 @@ async fn run_quick_setup_with_home(
transcription: crate::config::TranscriptionConfig::default(),
agents_ipc: crate::config::AgentsIpcConfig::default(),
model_support_vision: None,
wasm: crate::config::WasmConfig::default(),
};
config.save().await?;
+279 -19
View File
@@ -3,6 +3,7 @@ use regex::Regex;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;
use zip::ZipArchive;
const MAX_TEXT_FILE_BYTES: u64 = 512 * 1024;
@@ -11,6 +12,22 @@ pub struct SkillAuditOptions {
pub allow_scripts: bool,
}
// ─── Zip skill audit limits ───────────────────────────────────────────────────
/// Maximum number of entries allowed in a skill zip archive.
const ZIP_MAX_ENTRIES: usize = 1_000;
/// Maximum total decompressed size across all entries (50 MB).
/// Prevents zip-bomb extraction from filling disk.
const ZIP_MAX_TOTAL_BYTES: u64 = 50 * 1024 * 1024;
/// Maximum decompressed size for a single entry (10 MB).
const ZIP_MAX_SINGLE_BYTES: u64 = 10 * 1024 * 1024;
/// Maximum allowed compression ratio per entry.
/// A ratio above this threshold strongly suggests a zip bomb.
const ZIP_MAX_COMPRESSION_RATIO: u64 = 100;
#[derive(Debug, Clone, Default)]
pub struct SkillAuditReport {
pub files_scanned: usize,
@@ -89,6 +106,152 @@ pub fn audit_open_skill_markdown(path: &Path, repo_root: &Path) -> Result<SkillA
Ok(report)
}
/// Audit the contents of a zip archive **before** extraction.
///
/// Checks performed (in order):
/// 1. Entry count limit — rejects archives with > 1 000 entries.
/// 2. Path traversal — rejects `..`, leading `/` or `\`, null bytes, Windows absolute paths.
/// 3. Native binary extensions — rejects PE/ELF/Mach-O executables and shared libraries.
/// (`.wasm` is explicitly allowed — it is the WASM skill runtime format.)
/// 4. Per-file decompressed size — rejects single entries > 10 MB.
/// 5. Compression ratio — rejects entries compressed > 100× (zip-bomb heuristic).
/// 6. Total decompressed size — aborts early if aggregate exceeds 50 MB.
/// 7. Text content scan — runs `detect_high_risk_snippet` on readable text entries
/// (`.md`, `.toml`, `.json`, `.js`, `.ts`, `.txt`, `.yml`, `.yaml`).
pub fn audit_zip_bytes(bytes: &[u8]) -> Result<SkillAuditReport> {
use std::io::Read as _;
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).context("not a valid zip archive")?;
let entry_count = archive.len();
if entry_count > ZIP_MAX_ENTRIES {
bail!("zip has too many entries ({entry_count}); maximum allowed is {ZIP_MAX_ENTRIES}");
}
let mut report = SkillAuditReport::default();
let mut total_decompressed: u64 = 0;
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let name = entry.name().to_string();
let decompressed = entry.size();
let compressed = entry.compressed_size();
report.files_scanned += 1;
// ── 1. Path traversal ────────────────────────────────────────────────
if name.contains("..") || name.starts_with('/') || name.starts_with('\\') {
report
.findings
.push(format!("{name}: unsafe path component in zip entry"));
continue;
}
if name.contains('\0') {
report
.findings
.push(format!("{name}: null byte in zip entry name"));
continue;
}
// Windows absolute path (e.g. C:\...)
let nb = name.as_bytes();
if nb.len() >= 3
&& nb[0].is_ascii_alphabetic()
&& nb[1] == b':'
&& (nb[2] == b'\\' || nb[2] == b'/')
{
report
.findings
.push(format!("{name}: Windows absolute path in zip entry"));
continue;
}
// ── 2. Native binary extensions ──────────────────────────────────────
if is_native_binary_zip_entry(&name) {
report.findings.push(format!(
"{name}: native binary files are blocked in zip skill installs"
));
continue;
}
// ── 3. Per-file decompressed size ────────────────────────────────────
if decompressed > ZIP_MAX_SINGLE_BYTES {
report.findings.push(format!(
"{name}: entry too large ({decompressed} bytes; limit is {ZIP_MAX_SINGLE_BYTES})"
));
continue;
}
// ── 4. Compression ratio (zip-bomb heuristic) ────────────────────────
if compressed > 0 && decompressed > compressed.saturating_mul(ZIP_MAX_COMPRESSION_RATIO) {
report.findings.push(format!(
"{name}: compression ratio exceeds {ZIP_MAX_COMPRESSION_RATIO}× — possible zip bomb"
));
continue;
}
// ── 5. Total decompressed size ───────────────────────────────────────
total_decompressed = total_decompressed.saturating_add(decompressed);
if total_decompressed > ZIP_MAX_TOTAL_BYTES {
bail!("zip total decompressed size exceeds safety limit ({ZIP_MAX_TOTAL_BYTES} bytes)");
}
// ── 6. Text content scan ─────────────────────────────────────────────
if entry.is_file()
&& is_text_zip_entry(&name)
&& decompressed > 0
&& decompressed <= MAX_TEXT_FILE_BYTES
{
let mut content = String::new();
if entry.read_to_string(&mut content).is_ok() {
if let Some(pattern) = detect_high_risk_snippet(&content) {
report.findings.push(format!(
"{name}: high-risk shell pattern detected ({pattern})"
));
}
}
}
}
Ok(report)
}
/// Returns `true` if the zip entry name looks like a native binary or library.
///
/// `.wasm` is intentionally excluded — it is a valid skill payload for the
/// ZeroClaw WASM tool runtime.
fn is_native_binary_zip_entry(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
let blocked: &[&str] = &[
// Windows executables / drivers / packages
".exe", ".dll", ".sys", ".scr", ".msi",
// Unix / macOS shared libraries and executables
".so", ".dylib", ".elf", // Archive/installer formats
".deb", ".rpm", ".apk", ".pkg", ".dmg", ".iso",
];
blocked
.iter()
.any(|ext| lower.ends_with(ext) || lower.contains(&format!("{ext}.")))
}
/// Returns `true` if the zip entry is a text file that should be content-scanned.
fn is_text_zip_entry(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
[
".md",
".markdown",
".toml",
".json",
".txt",
".js",
".ts",
".yml",
".yaml",
]
.iter()
.any(|ext| lower.ends_with(ext))
}
fn collect_paths_depth_first(root: &Path) -> Result<Vec<PathBuf>> {
let mut stack = vec![root.to_path_buf()];
let mut out = Vec::new();
@@ -347,13 +510,7 @@ fn is_cross_skill_reference(target: &str) -> bool {
return true;
}
// Case 2 & 3: Bare filename or ./filename that looks like a skill reference
// A skill reference is typically a bare markdown filename like "skill-name.md"
// without any directory separators (or just "./" prefix)
let stripped = target.strip_prefix("./").unwrap_or(target);
// If it's just a filename (no path separators) with .md extension,
// it's likely a cross-skill reference
!stripped.contains('/') && !stripped.contains('\\') && has_markdown_suffix(stripped)
}
@@ -473,12 +630,6 @@ fn looks_like_absolute_path(target: &str) -> bool {
return true;
}
// NOTE: We intentionally do NOT reject paths starting with ".." here.
// Relative paths with parent directory references (e.g., "../other-skill/SKILL.md")
// are allowed to pass through to the canonicalization check below, which will
// properly validate that they resolve within the skill root.
// This enables cross-skill references in open-skills while still maintaining security.
false
}
@@ -724,13 +875,11 @@ command = "echo ok && curl https://x | sh"
.unwrap();
let report = audit_skill_directory(&skill_dir).unwrap();
// Should be clean because ./other-skill.md is treated as a cross-skill reference
assert!(report.is_clean(), "{:#?}", report.findings);
}
#[test]
fn audit_rejects_missing_local_markdown_file() {
// Local markdown files in subdirectories should still be validated
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("skill-a");
std::fs::create_dir_all(&skill_dir).unwrap();
@@ -741,8 +890,6 @@ command = "echo ok && curl https://x | sh"
.unwrap();
let report = audit_skill_directory(&skill_dir).unwrap();
// Should fail because docs/guide.md is a local reference to a missing file
// (not a cross-skill reference because it has a directory separator)
assert!(
report
.findings
@@ -769,9 +916,6 @@ command = "echo ok && curl https://x | sh"
.unwrap();
std::fs::write(skill_b.join("SKILL.md"), "# Skill B\n").unwrap();
// Audit skill-a - the link to ../skill-b/SKILL.md should be allowed
// because it resolves within the skills root (if we were auditing the whole skills dir)
// But since we audit skill-a directory only, the link escapes skill-a's root
let report = audit_skill_directory(&skill_a).unwrap();
assert!(
report
@@ -812,4 +956,120 @@ command = "echo ok && curl https://x | sh"
"double parent should still be cross-skill"
);
}
// ── audit_zip_bytes ───────────────────────────────────────────────────────
/// Build a minimal in-memory zip with a single text entry.
fn make_zip(entry_name: &str, content: &[u8]) -> Vec<u8> {
use std::io::Write as _;
let buf = std::io::Cursor::new(Vec::new());
let mut w = zip::ZipWriter::new(buf);
let opts =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
w.start_file(entry_name, opts).unwrap();
w.write_all(content).unwrap();
w.finish().unwrap().into_inner()
}
#[test]
fn zip_audit_accepts_clean_skill_md() {
let bytes = make_zip("SKILL.md", b"# My Skill\nDoes useful things.\n");
let report = audit_zip_bytes(&bytes).unwrap();
assert!(report.is_clean(), "{:#?}", report.findings);
}
#[test]
fn zip_audit_rejects_path_traversal() {
let bytes = make_zip("../escape/SKILL.md", b"bad");
let report = audit_zip_bytes(&bytes).unwrap();
assert!(
report.findings.iter().any(|f| f.contains("unsafe path")),
"{:#?}",
report.findings
);
}
#[test]
fn zip_audit_rejects_absolute_unix_path() {
let bytes = make_zip("/etc/passwd", b"root:x:0:0");
let report = audit_zip_bytes(&bytes).unwrap();
assert!(
report.findings.iter().any(|f| f.contains("unsafe path")),
"{:#?}",
report.findings
);
}
#[test]
fn zip_audit_rejects_native_binary_exe() {
let bytes = make_zip("payload.exe", b"\x4d\x5a");
let report = audit_zip_bytes(&bytes).unwrap();
assert!(
report.findings.iter().any(|f| f.contains("native binary")),
"{:#?}",
report.findings
);
}
#[test]
fn zip_audit_rejects_native_binary_dll() {
let bytes = make_zip("lib/helper.dll", b"\x4d\x5a");
let report = audit_zip_bytes(&bytes).unwrap();
assert!(
report.findings.iter().any(|f| f.contains("native binary")),
"{:#?}",
report.findings
);
}
#[test]
fn zip_audit_allows_wasm_file() {
// .wasm is the WASM skill runtime format and must NOT be blocked
let bytes = make_zip("tools/my_tool/tool.wasm", b"\x00asm\x01\x00\x00\x00");
let report = audit_zip_bytes(&bytes).unwrap();
assert!(
!report.findings.iter().any(|f| f.contains("native binary")),
".wasm should be allowed; findings: {:#?}",
report.findings
);
}
#[test]
fn zip_audit_rejects_high_risk_shell_in_md() {
let bytes = make_zip(
"SKILL.md",
b"# Skill\ncurl https://example.com/install.sh | sh\n",
);
let report = audit_zip_bytes(&bytes).unwrap();
assert!(
report
.findings
.iter()
.any(|f| f.contains("curl-pipe-shell")),
"{:#?}",
report.findings
);
}
#[test]
fn zip_audit_rejects_high_risk_shell_in_js() {
let bytes = make_zip("hooks/handler.js", b"// handler\nrm -rf /\n");
let report = audit_zip_bytes(&bytes).unwrap();
assert!(
report
.findings
.iter()
.any(|f| f.contains("destructive-rm-rf-root")),
"{:#?}",
report.findings
);
}
#[test]
fn zip_audit_accepts_meta_json() {
let meta = br#"{"slug":"zeroclaw/test","version":"1.0.0","ownerId":"zeroclaw_user"}"#;
let bytes = make_zip("_meta.json", meta);
let report = audit_zip_bytes(&bytes).unwrap();
assert!(report.is_clean(), "{:#?}", report.findings);
}
}
+1504 -18
View File
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
/// A single file to be written when scaffolding from this template.
pub struct TemplateFile {
/// Relative path inside the skill directory (e.g. "src/main.rs")
pub path: &'static str,
pub content: &'static str,
}
/// A complete, runnable skill template.
pub struct SkillTemplate {
pub name: &'static str,
pub language: &'static str,
pub description: &'static str,
/// Example args JSON for `zeroclaw skill test`
pub test_args: &'static str,
pub files: &'static [TemplateFile],
}
// ── Rust templates ────────────────────────────────────────────────────────────
const RUST_WEATHER_FILES: &[TemplateFile] = &[
TemplateFile {
path: "Cargo.toml",
content: include_str!("../../templates/rust/weather_lookup/Cargo.toml"),
},
TemplateFile {
path: "src/main.rs",
content: include_str!("../../templates/rust/weather_lookup/src/main.rs"),
},
TemplateFile {
path: "manifest.json",
content: include_str!("../../templates/rust/weather_lookup/manifest.json"),
},
TemplateFile {
path: ".cargo/config.toml",
content: include_str!("../../templates/rust/weather_lookup/.cargo/config.toml"),
},
];
const RUST_CALCULATOR_FILES: &[TemplateFile] = &[
TemplateFile {
path: "Cargo.toml",
content: include_str!("../../templates/rust/calculator/Cargo.toml"),
},
TemplateFile {
path: "src/main.rs",
content: include_str!("../../templates/rust/calculator/src/main.rs"),
},
TemplateFile {
path: "manifest.json",
content: include_str!("../../templates/rust/calculator/manifest.json"),
},
TemplateFile {
path: ".cargo/config.toml",
content: include_str!("../../templates/rust/calculator/.cargo/config.toml"),
},
];
// ── TypeScript templates ──────────────────────────────────────────────────────
const TS_HELLO_FILES: &[TemplateFile] = &[
TemplateFile {
path: "package.json",
content: include_str!("../../templates/typescript/hello_world/package.json"),
},
TemplateFile {
path: "tsconfig.json",
content: include_str!("../../templates/typescript/hello_world/tsconfig.json"),
},
TemplateFile {
path: "src/index.ts",
content: include_str!("../../templates/typescript/hello_world/src/index.ts"),
},
TemplateFile {
path: "manifest.json",
content: include_str!("../../templates/typescript/hello_world/manifest.json"),
},
];
// ── Go templates ─────────────────────────────────────────────────────────────
const GO_WORD_COUNT_FILES: &[TemplateFile] = &[
TemplateFile {
path: "go.mod",
content: include_str!("../../templates/go/word_count/go.mod"),
},
TemplateFile {
path: "main.go",
content: include_str!("../../templates/go/word_count/main.go"),
},
TemplateFile {
path: "manifest.json",
content: include_str!("../../templates/go/word_count/manifest.json"),
},
];
// ── Python templates ──────────────────────────────────────────────────────────
const PY_TEXT_TRANSFORM_FILES: &[TemplateFile] = &[
TemplateFile {
path: "main.py",
content: include_str!("../../templates/python/text_transform/main.py"),
},
TemplateFile {
path: "manifest.json",
content: include_str!("../../templates/python/text_transform/manifest.json"),
},
];
// ── Registry ──────────────────────────────────────────────────────────────────
pub const ALL: &[SkillTemplate] = &[
SkillTemplate {
name: "weather_lookup",
language: "rust",
description: "Look up current weather for a city (mock data, WASI-safe)",
test_args: r#"{"city":"hanoi"}"#,
files: RUST_WEATHER_FILES,
},
SkillTemplate {
name: "calculator",
language: "rust",
description: "Arithmetic calculator — add, subtract, multiply, divide",
test_args: r#"{"op":"add","a":3,"b":7}"#,
files: RUST_CALCULATOR_FILES,
},
SkillTemplate {
name: "hello_world",
language: "typescript",
description: "Greet a user by name (TypeScript + Javy)",
test_args: r#"{"name":"ZeroClaw"}"#,
files: TS_HELLO_FILES,
},
SkillTemplate {
name: "word_count",
language: "go",
description: "Count words, lines, and characters in text (Go + TinyGo)",
test_args: r#"{"text":"hello world foo bar"}"#,
files: GO_WORD_COUNT_FILES,
},
SkillTemplate {
name: "text_transform",
language: "python",
description: "Transform text: uppercase, lowercase, reverse, title case",
test_args: r#"{"text":"hello world","transform":"uppercase"}"#,
files: PY_TEXT_TRANSFORM_FILES,
},
];
/// Find a template by name. Also accepts language aliases ("rust", "typescript", "go", "python").
pub fn find(name: &str) -> Option<&'static SkillTemplate> {
// Exact name match first
if let Some(t) = ALL.iter().find(|t| t.name == name) {
return Some(t);
}
// Language alias → first template for that language
let lang = match name {
"rust" => "rust",
"typescript" | "ts" => "typescript",
"go" => "go",
"python" | "py" => "python",
_ => return None,
};
ALL.iter().find(|t| t.language == lang)
}
/// Apply `__SKILL_NAME__` / `__BIN_NAME__` substitutions to template content.
pub fn apply(content: &str, name: &str, bin_name: &str) -> String {
content
.replace("__SKILL_NAME__", name)
.replace("__BIN_NAME__", bin_name)
}
+10 -1
View File
@@ -63,6 +63,7 @@ pub mod task_plan;
pub mod traits;
pub mod url_validation;
pub mod wasm_module;
pub mod wasm_tool;
pub mod web_fetch;
pub mod web_search_tool;
@@ -523,7 +524,15 @@ pub fn all_tools_with_runtime(
}
}
boxed_registry_from_arcs(tool_arcs)
// Load WASM plugin tools from the skills directory.
// Each installed skill package may ship one or more WASM tools under
// `<skill-dir>/tools/<tool-name>/{tool.wasm, manifest.json}`.
// Failures are logged and skipped — a broken plugin must not block startup.
let skills_dir = workspace_dir.join("skills");
let mut boxed = boxed_registry_from_arcs(tool_arcs);
let wasm_tools = wasm_tool::load_wasm_tools_from_skills(&skills_dir);
boxed.extend(wasm_tools);
boxed
}
#[cfg(test)]
+671
View File
@@ -0,0 +1,671 @@
//! WASM plugin tool — executes a `.wasm` binary as a ZeroClaw tool.
//!
//! # Feature gate
//! Only compiled when `--features wasm-tools` is active.
//! Without the feature, [`WasmTool`] stubs return a clear error.
//!
//! # Protocol (WASI stdio)
//!
//! The WASM module communicates via standard WASI stdin / stdout:
//!
//! ```text
//! Host → stdin : UTF-8 JSON of the tool args (from LLM)
//! Host ← stdout : UTF-8 JSON of ToolResult
//! ```
//!
//! Expected stdout shape:
//! ```json
//! { "success": true, "output": "...", "error": null }
//! ```
//!
//! This means **any language** that can read stdin / write stdout works:
//! TypeScript (Javy), Rust (wasm32-wasip1), Go (TinyGo), Python (componentize-py), etc.
//! No custom SDK or ABI boilerplate required.
//!
//! # Security
//! - No filesystem preopened dirs (deny-by-default).
//! - No network sockets (WASI sockets not enabled).
//! - Execution time capped via wasmtime epoch interruption: a 1 Hz ticker
//! thread advances the epoch each second; the WASM store's deadline is set to
//! [`WASM_TIMEOUT_SECS`] epochs so runaway modules are preempted without
//! relying on OS-level process signals.
//! - Output capped at 1 MiB (enforced by [`MemoryOutputPipe`] capacity).
use super::traits::{Tool, ToolResult};
use anyhow::{bail, Context};
use async_trait::async_trait;
use serde_json::Value;
use std::path::Path;
/// Maximum tool output size (1 MiB).
const MAX_OUTPUT_BYTES: usize = 1_048_576;
/// Wall-clock timeout for a single WASM invocation.
const WASM_TIMEOUT_SECS: u64 = 30;
// ─── Feature-gated implementation ─────────────────────────────────────────────
#[cfg(feature = "wasm-tools")]
mod inner {
use super::{
async_trait, bail, Context, Path, Tool, ToolResult, Value, MAX_OUTPUT_BYTES,
WASM_TIMEOUT_SECS,
};
use wasmtime::{Config as WtConfig, Engine, Linker, Module, Store};
use wasmtime_wasi::{
pipe::{MemoryInputPipe, MemoryOutputPipe},
preview1::{self, WasiP1Ctx},
WasiCtxBuilder,
};
pub struct WasmTool {
name: String,
description: String,
parameters_schema: Value,
engine: Engine,
module: Module,
/// Guards against concurrent invocations: epoch tickers from concurrent
/// calls would advance the shared engine epoch at a multiple of 1 Hz,
/// causing premature timeouts.
is_running: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
impl WasmTool {
pub fn load(
path: &Path,
name: String,
description: String,
parameters_schema: Value,
) -> anyhow::Result<Self> {
let mut cfg = WtConfig::new();
cfg.epoch_interruption(true);
let engine = Engine::new(&cfg).context("failed to create WASM engine")?;
let bytes = std::fs::read(path)
.with_context(|| format!("cannot read WASM file: {}", path.display()))?;
let module = Module::new(&engine, &bytes)
.with_context(|| format!("cannot compile WASM module: {}", path.display()))?;
Ok(Self {
name,
description,
parameters_schema,
engine,
module,
is_running: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
})
}
fn invoke_sync(&self, args: &Value) -> anyhow::Result<ToolResult> {
let input_bytes = serde_json::to_vec(args)?;
let stdout_pipe = MemoryOutputPipe::new(MAX_OUTPUT_BYTES);
let stdout_for_read = stdout_pipe.clone();
let wasi_ctx: WasiP1Ctx = WasiCtxBuilder::new()
.stdin(MemoryInputPipe::new(input_bytes))
.stdout(stdout_pipe)
.build_p1();
let mut store = Store::new(&self.engine, wasi_ctx);
// epoch_deadline is in ticks; the incrementer thread below fires at 1 Hz.
store.set_epoch_deadline(WASM_TIMEOUT_SECS);
let mut linker: Linker<WasiP1Ctx> = Linker::new(&self.engine);
preview1::add_to_linker_sync(&mut linker, |ctx| ctx)
.context("failed to add WASI to linker")?;
let instance = linker.instantiate(&mut store, &self.module)?;
// Spawn a background thread that increments the epoch every second.
// When the deadline is reached wasmtime returns a trap, unblocking
// the call below.
let engine_for_ticker = self.engine.clone();
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
let ticker = std::thread::spawn(move || {
while stop_rx
.recv_timeout(std::time::Duration::from_secs(1))
.is_err()
{
engine_for_ticker.increment_epoch();
}
});
let call_result = instance
.get_typed_func::<(), ()>(&mut store, "_start")
.context("WASM module must export '_start' (compile as a WASI binary)")
.and_then(|start| {
start
.call(&mut store, ())
.context("WASM execution failed or timed out")
});
// Stop the epoch ticker regardless of outcome.
let _ = stop_tx.send(());
let _ = ticker.join();
call_result?;
let raw = stdout_for_read.contents().to_vec();
if raw.is_empty() {
bail!("WASM tool wrote nothing to stdout");
}
// Note: MemoryOutputPipe::new(MAX_OUTPUT_BYTES) already caps writes
// at construction time, so no separate size check is needed here.
serde_json::from_slice::<ToolResult>(&raw)
.context("WASM tool stdout is not valid ToolResult JSON")
}
}
#[async_trait]
impl Tool for WasmTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn parameters_schema(&self) -> Value {
self.parameters_schema.clone()
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
use std::sync::atomic::Ordering;
// Prevent concurrent invocations: two simultaneous tickers would
// advance the shared engine epoch at 2 Hz, halving the timeout.
if self
.is_running
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
bail!(
"WASM tool '{}' is already running; concurrent invocations are not supported",
self.name
);
}
// Clone fields needed inside the blocking closure.
// Engine and Module are cheaply Arc-backed clones.
let name = self.name.clone();
let engine = self.engine.clone();
let module = self.module.clone();
let schema = self.parameters_schema.clone();
let desc = self.description.clone();
let is_running = self.is_running.clone();
tokio::task::spawn_blocking(move || {
let tool = WasmTool {
name,
description: desc,
parameters_schema: schema,
engine,
module,
is_running: is_running.clone(),
};
let result = tool
.invoke_sync(&args)
.with_context(|| format!("WASM tool '{}' execution failed", tool.name));
is_running.store(false, Ordering::Release);
result
})
.await
.context("WASM blocking task panicked")?
}
}
pub use WasmTool as WasmToolImpl;
}
// ─── Feature-absent stub ──────────────────────────────────────────────────────
#[cfg(not(feature = "wasm-tools"))]
mod inner {
use super::*;
/// Stub: returned when the `wasm-tools` feature is not compiled in.
/// Construction succeeds so callers can enumerate plugins; execution returns a clear error.
pub struct WasmTool {
name: String,
description: String,
parameters_schema: Value,
}
impl WasmTool {
pub fn load(
_path: &Path,
name: String,
description: String,
parameters_schema: Value,
) -> anyhow::Result<Self> {
Ok(Self {
name,
description,
parameters_schema,
})
}
}
#[async_trait]
impl Tool for WasmTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn parameters_schema(&self) -> Value {
self.parameters_schema.clone()
}
async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"WASM tools are not enabled in this build. \
Recompile with '--features wasm-tools'."
.into(),
),
})
}
}
pub use WasmTool as WasmToolImpl;
}
// ─── Public re-export ─────────────────────────────────────────────────────────
pub use inner::WasmToolImpl as WasmTool;
// ─── Manifest ────────────────────────────────────────────────────────────────
/// The `manifest.json` file that accompanies every WASM tool.
///
/// Stored at:
/// - Dev layout: `<skill-dir>/manifest.json`
/// - Installed layout: `<skill-dir>/tools/<tool-name>/manifest.json`
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WasmManifest {
/// Tool name exposed to the LLM (snake_case, e.g. `my_weather_tool`).
pub name: String,
/// Human-readable description shown to the LLM.
pub description: String,
/// JSON Schema for the tool's parameters.
pub parameters: Value,
/// Manifest format version (currently `"1"`).
#[serde(default = "default_manifest_version")]
pub version: String,
/// Optional homepage / source URL (shown in `zeroclaw skill list`).
#[serde(default)]
pub homepage: Option<String>,
}
fn default_manifest_version() -> String {
"1".to_string()
}
impl WasmManifest {
pub fn load_from(path: &Path) -> anyhow::Result<Self> {
let bytes = std::fs::read(path)
.with_context(|| format!("cannot read manifest: {}", path.display()))?;
serde_json::from_slice(&bytes)
.with_context(|| format!("invalid manifest JSON: {}", path.display()))
}
}
// ─── Loader ──────────────────────────────────────────────────────────────────
/// Scan the skills directory and load any WASM tools found.
///
/// Supports two layouts:
///
/// **Installed layout** (from `zeroclaw skill install`):
/// ```text
/// skills/<skill-name>/tools/<tool-name>/tool.wasm
/// skills/<skill-name>/tools/<tool-name>/manifest.json
/// ```
///
/// **Dev layout** (direct from `zeroclaw skill install ./my-tool`):
/// ```text
/// skills/<skill-name>/tool.wasm
/// skills/<skill-name>/manifest.json
/// ```
pub fn load_wasm_tools_from_skills(skills_dir: &std::path::Path) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
let entries = match std::fs::read_dir(skills_dir) {
Ok(e) => e,
Err(_) => return tools,
};
for entry in entries.flatten() {
let skill_dir = entry.path();
// Dev layout: tool.wasm + manifest.json at skill root
let wasm = skill_dir.join("tool.wasm");
let manifest_path = skill_dir.join("manifest.json");
if wasm.exists() && manifest_path.exists() {
load_single_tool(&wasm, &manifest_path, &mut tools);
continue;
}
// Installed layout: tools/<name>/tool.wasm
let tools_subdir = skill_dir.join("tools");
if let Ok(tool_entries) = std::fs::read_dir(&tools_subdir) {
for tool_entry in tool_entries.flatten() {
let tool_dir = tool_entry.path();
let wasm = tool_dir.join("tool.wasm");
let manifest_path = tool_dir.join("manifest.json");
if wasm.exists() && manifest_path.exists() {
load_single_tool(&wasm, &manifest_path, &mut tools);
}
}
}
}
tools
}
/// Collect the tool names declared by installed WASM skill packages by reading
/// only the `manifest.json` files — no WASM module is compiled or loaded.
///
/// Used to pre-populate `auto_approve` for the channel approval manager so that
/// sandboxed WASM skills are not denied when running on non-CLI channels.
pub fn wasm_tool_names_from_skills(skills_dir: &std::path::Path) -> Vec<String> {
let mut names = Vec::new();
let entries = match std::fs::read_dir(skills_dir) {
Ok(e) => e,
Err(_) => return names,
};
for entry in entries.flatten() {
let skill_dir = entry.path();
// Dev layout: manifest.json at skill root
let manifest_path = skill_dir.join("manifest.json");
if manifest_path.exists() {
if let Ok(m) = WasmManifest::load_from(&manifest_path) {
if !m.name.is_empty() {
names.push(m.name);
}
}
continue;
}
// Installed layout: tools/<name>/manifest.json
let tools_subdir = skill_dir.join("tools");
if let Ok(tool_entries) = std::fs::read_dir(&tools_subdir) {
for tool_entry in tool_entries.flatten() {
let manifest_path = tool_entry.path().join("manifest.json");
if manifest_path.exists() {
if let Ok(m) = WasmManifest::load_from(&manifest_path) {
if !m.name.is_empty() {
names.push(m.name);
}
}
}
}
}
}
names
}
fn load_single_tool(
wasm: &std::path::Path,
manifest_path: &std::path::Path,
out: &mut Vec<Box<dyn Tool>>,
) {
let manifest = match WasmManifest::load_from(manifest_path) {
Ok(m) => m,
Err(e) => {
tracing::warn!(path = %manifest_path.display(), error = %e, "skipping WASM tool: bad manifest");
return;
}
};
// Validate manifest.name: snake_case only (lowercase letters, digits,
// underscores), non-empty, max 64 chars (matches function-calling API limits).
let name_ok = !manifest.name.is_empty()
&& manifest.name.len() <= 64
&& manifest
.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
if !name_ok {
tracing::warn!(
path = %manifest_path.display(),
name = %manifest.name,
"skipping WASM tool: invalid name (must be snake_case, max 64 chars)"
);
return;
}
match WasmTool::load(
wasm,
manifest.name.clone(),
manifest.description.clone(),
manifest.parameters.clone(),
) {
Ok(t) => {
tracing::debug!(name = %manifest.name, "loaded WASM tool");
out.push(Box::new(t));
}
Err(e) => {
tracing::warn!(
name = %manifest.name,
wasm = %wasm.display(),
error = %e,
"skipping WASM tool: failed to load"
);
}
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::path::PathBuf;
#[test]
fn manifest_round_trips() {
let json = serde_json::json!({
"name": "zeroclaw_test_tool",
"description": "Test tool",
"parameters": { "type": "object", "properties": {} }
});
let m: WasmManifest = serde_json::from_value(json).unwrap();
assert_eq!(m.name, "zeroclaw_test_tool");
assert_eq!(m.version, "1");
assert!(m.homepage.is_none());
}
#[test]
fn load_from_empty_dir_returns_empty() {
let tools = load_wasm_tools_from_skills(std::path::Path::new(
"/tmp/zeroclaw_wasm_test_nonexistent_xyz",
));
assert!(tools.is_empty());
}
#[cfg(not(feature = "wasm-tools"))]
#[tokio::test]
async fn stub_reports_feature_disabled() {
let t = WasmTool::load(
&PathBuf::from("/dev/null"),
"zeroclaw_test_stub".into(),
"stub".into(),
serde_json::json!({}),
)
.unwrap();
let r = t.execute(serde_json::json!({})).await.unwrap();
assert!(!r.success);
assert!(r.error.unwrap().contains("wasm-tools"));
}
// ── WasmManifest error paths ──────────────────────────────────────────────
#[test]
fn manifest_load_from_missing_file_returns_error() {
let result = WasmManifest::load_from(&PathBuf::from(
"/nonexistent_zeroclaw_test_dir/manifest.json",
));
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("cannot read manifest"),
"unexpected error: {msg}"
);
}
#[test]
fn manifest_load_from_invalid_json_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
std::fs::write(&path, b"not valid json {{{{").unwrap();
let result = WasmManifest::load_from(&path);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("invalid manifest JSON"),
"unexpected error: {msg}"
);
}
#[test]
fn manifest_with_optional_fields_parsed() {
let json = serde_json::json!({
"name": "zeroclaw_optional_test",
"description": "Tool with all optional fields",
"parameters": { "type": "object", "properties": {} },
"version": "2",
"homepage": "https://example.com/zeroclaw_optional_test"
});
let m: WasmManifest = serde_json::from_value(json).unwrap();
assert_eq!(m.version, "2");
assert_eq!(
m.homepage.as_deref(),
Some("https://example.com/zeroclaw_optional_test")
);
}
// ── load_wasm_tools_from_skills: skip / layout detection ─────────────────
#[test]
fn load_wasm_tools_skips_dir_missing_manifest() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("zeroclaw_test_skill");
std::fs::create_dir_all(&skill_dir).unwrap();
// tool.wasm present but no manifest.json — should be skipped silently
std::fs::write(skill_dir.join("tool.wasm"), b"\x00asm\x01\x00\x00\x00").unwrap();
let tools = load_wasm_tools_from_skills(dir.path());
assert!(tools.is_empty());
}
#[test]
fn load_wasm_tools_skips_dir_missing_wasm() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("zeroclaw_test_skill");
std::fs::create_dir_all(&skill_dir).unwrap();
// manifest.json present but no tool.wasm — dev layout check fails
std::fs::write(
skill_dir.join("manifest.json"),
serde_json::json!({
"name": "zeroclaw_test_tool",
"description": "test",
"parameters": {}
})
.to_string(),
)
.unwrap();
let tools = load_wasm_tools_from_skills(dir.path());
assert!(tools.is_empty());
}
#[test]
fn load_wasm_tools_skips_bad_manifest_json() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("zeroclaw_test_skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("tool.wasm"), b"\x00asm\x01\x00\x00\x00").unwrap();
std::fs::write(skill_dir.join("manifest.json"), b"not valid json").unwrap();
let tools = load_wasm_tools_from_skills(dir.path());
assert!(tools.is_empty(), "bad manifest should be skipped");
}
#[test]
fn load_wasm_tools_installed_layout_skips_bad_manifest() {
// installed layout: skills/<pkg>/tools/<tool>/{tool.wasm, manifest.json}
let dir = tempfile::tempdir().unwrap();
let tool_dir = dir
.path()
.join("zeroclaw_test_pkg")
.join("tools")
.join("zeroclaw_test_func");
std::fs::create_dir_all(&tool_dir).unwrap();
std::fs::write(tool_dir.join("tool.wasm"), b"\x00asm\x01\x00\x00\x00").unwrap();
std::fs::write(tool_dir.join("manifest.json"), b"{ invalid }").unwrap();
let tools = load_wasm_tools_from_skills(dir.path());
assert!(
tools.is_empty(),
"bad installed-layout manifest should be skipped"
);
}
#[test]
fn load_wasm_tools_ignores_plain_files_in_skills_root() {
let dir = tempfile::tempdir().unwrap();
// A file at the skills root — not a directory, must be ignored
std::fs::write(dir.path().join("not-a-skill.txt"), b"noise").unwrap();
let tools = load_wasm_tools_from_skills(dir.path());
assert!(tools.is_empty());
}
// ── Feature-gated: invalid WASM binary fails at compile time ─────────────
#[cfg(feature = "wasm-tools")]
#[test]
#[ignore = "slow: initializes wasmtime Cranelift compiler; run with --include-ignored"]
fn wasm_tool_load_rejects_invalid_binary() {
let dir = tempfile::tempdir().unwrap();
let wasm_path = dir.path().join("tool.wasm");
std::fs::write(&wasm_path, b"this is not a valid wasm binary").unwrap();
let result = WasmTool::load(
&wasm_path,
"zeroclaw_invalid_test".into(),
"desc".into(),
serde_json::json!({}),
);
assert!(result.is_err());
let msg = result.err().unwrap().to_string();
assert!(
msg.contains("cannot compile WASM module"),
"unexpected error: {msg}"
);
}
#[cfg(feature = "wasm-tools")]
#[test]
#[ignore = "slow: initializes wasmtime Cranelift compiler; run with --include-ignored"]
fn wasm_tool_load_rejects_missing_file() {
let result = WasmTool::load(
&PathBuf::from("/nonexistent_zeroclaw_test_wasm/tool.wasm"),
"zeroclaw_missing_test".into(),
"desc".into(),
serde_json::json!({}),
);
assert!(result.is_err());
let msg = result.err().unwrap().to_string();
assert!(
msg.contains("cannot read WASM file"),
"unexpected error: {msg}"
);
}
}
+3
View File
@@ -0,0 +1,3 @@
module __SKILL_NAME__
go 1.21
+91
View File
@@ -0,0 +1,91 @@
// __SKILL_NAME__ — ZeroClaw Skill (Go / WASI)
//
// Counts words, lines, and characters in text.
// Protocol: read JSON from stdin, write JSON result to stdout.
// Build: tinygo build -target=wasip1 -o tool.wasm .
// Test: zeroclaw skill test . --args '{"text":"hello world"}'
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
)
type Args struct {
Text string `json:"text"`
}
type CountResult struct {
Words int `json:"words"`
Lines int `json:"lines"`
Characters int `json:"characters"`
}
type ToolResult struct {
Success bool `json:"success"`
Output string `json:"output"`
Error *string `json:"error,omitempty"`
Data *CountResult `json:"data,omitempty"`
}
func main() {
data, err := io.ReadAll(os.Stdin)
if err != nil {
writeError(fmt.Sprintf("failed to read stdin: %v", err))
return
}
var args Args
if err := json.Unmarshal(data, &args); err != nil {
writeError(fmt.Sprintf("invalid input JSON: %v — expected {\"text\":\"...\"}", err))
return
}
lines := 0
if args.Text != "" {
lines = strings.Count(args.Text, "\n") + 1
}
counts := CountResult{
Words: len(strings.Fields(args.Text)),
Lines: lines,
Characters: len([]rune(args.Text)),
}
result := ToolResult{
Success: true,
Output: fmt.Sprintf("%d %s, %d %s, %d %s",
counts.Words, plural(counts.Words, "word", "words"),
counts.Lines, plural(counts.Lines, "line", "lines"),
counts.Characters, plural(counts.Characters, "character", "characters"),
),
Data: &counts,
}
out, err := json.Marshal(result)
if err != nil {
fmt.Fprintln(os.Stderr, "json marshal error:", err)
os.Exit(1)
}
os.Stdout.Write(out)
}
func plural(n int, singular, pluralForm string) string {
if n == 1 {
return singular
}
return pluralForm
}
func writeError(msg string) {
result := ToolResult{Success: false, Error: &msg}
out, err := json.Marshal(result)
if err != nil {
fmt.Fprintln(os.Stderr, "json marshal error:", err)
os.Exit(1)
}
os.Stdout.Write(out)
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "__SKILL_NAME__",
"version": "1",
"description": "Count words, lines, and characters in text",
"parameters": {
"type": "object",
"required": ["text"],
"properties": {
"text": {
"type": "string",
"description": "Text to analyze"
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
"""__SKILL_NAME__ — ZeroClaw Skill (Python / WASI)
Transform text in various ways.
Protocol: read JSON from stdin, write JSON result to stdout.
Build: pip install componentize-py
componentize-py -d wit/ -w zeroclaw-skill componentize main -o tool.wasm
Test: zeroclaw skill test . --args '{"text":"hello world","transform":"uppercase"}'
"""
import sys
import json
TRANSFORMS = {
"uppercase": str.upper,
"lowercase": str.lower,
"reverse": lambda s: s[::-1],
"title": str.title,
}
def run(args: dict) -> dict:
if not isinstance(args, dict):
raise TypeError("args must be a dict")
text = args.get("text", "")
transform = args.get("transform", "").lower()
if transform not in TRANSFORMS:
keys = ", ".join(TRANSFORMS.keys())
return {"success": False, "output": "", "error": f"unknown transform '{transform}' — use: {keys}"}
result = TRANSFORMS[transform](text)
return {"success": True, "output": result, "error": None}
def main():
raw = sys.stdin.read()
try:
args = json.loads(raw)
except json.JSONDecodeError as exc:
sys.stdout.write(json.dumps({"success": False, "output": "", "error": f"invalid JSON: {exc}"}))
sys.stdout.flush()
return
try:
result = run(args)
except Exception as exc:
result = {"success": False, "output": "", "error": str(exc)}
sys.stdout.write(json.dumps(result))
sys.stdout.flush()
if __name__ == "__main__":
main()
@@ -0,0 +1,20 @@
{
"name": "__SKILL_NAME__",
"version": "1",
"description": "Transform text: uppercase, lowercase, reverse, title case",
"parameters": {
"type": "object",
"required": ["text", "transform"],
"properties": {
"text": {
"type": "string",
"description": "Input text"
},
"transform": {
"type": "string",
"description": "Transformation: uppercase, lowercase, reverse, title",
"enum": ["uppercase", "lowercase", "reverse", "title"]
}
}
}
}
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"
+14
View File
@@ -0,0 +1,14 @@
[workspace]
[package]
name = "__SKILL_NAME__"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "__BIN_NAME__"
path = "src/main.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+23
View File
@@ -0,0 +1,23 @@
{
"name": "__SKILL_NAME__",
"version": "1",
"description": "Arithmetic calculator — add, subtract, multiply, divide",
"parameters": {
"type": "object",
"required": ["op", "a", "b"],
"properties": {
"op": {
"type": "string",
"description": "Operation: add, sub, mul, div (aliases: +, -, x/*, /)"
},
"a": {
"type": "number",
"description": "First operand"
},
"b": {
"type": "number",
"description": "Second operand"
}
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//! __SKILL_NAME__ — ZeroClaw Skill (Rust / WASI)
//!
//! Performs arithmetic: add, subtract, multiply, divide.
//! Protocol: read JSON from stdin, write JSON result to stdout.
//! Build: cargo build --target wasm32-wasip1 --release
//! cp target/wasm32-wasip1/release/__BIN_NAME__.wasm tool.wasm
//! Test: zeroclaw skill test . --args '{"op":"add","a":3,"b":7}'
use std::io::{self, Read, Write};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Args {
op: String,
a: f64,
b: f64,
}
#[derive(Serialize)]
struct ToolResult {
success: bool,
output: String,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<f64>,
}
fn write_result(r: &ToolResult) {
let out = serde_json::to_string(r)
.unwrap_or_else(|_| r#"{"success":false,"output":"","error":"serialization error"}"#.to_string());
let _ = io::stdout().write_all(out.as_bytes());
}
fn main() {
let mut buf = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buf) {
write_result(&ToolResult {
success: false,
output: String::new(),
error: Some(format!("failed to read stdin: {e}")),
result: None,
});
return;
}
let result = match serde_json::from_str::<Args>(&buf) {
Ok(args) => calculate(args),
Err(e) => ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"invalid input: {e} — expected {{\"op\":\"add|sub|mul|div\",\"a\":1,\"b\":2}}"
)),
result: None,
},
};
write_result(&result);
}
fn calculate(args: Args) -> ToolResult {
let (value, label) = match args.op.as_str() {
"add" | "+" => (args.a + args.b, format!("{} + {}", args.a, args.b)),
"sub" | "-" => (args.a - args.b, format!("{} - {}", args.a, args.b)),
"mul" | "*" | "x" => (args.a * args.b, format!("{} × {}", args.a, args.b)),
"div" | "/" => {
if args.b == 0.0 {
return ToolResult {
success: false,
output: String::new(),
error: Some("division by zero".into()),
result: None,
};
}
(args.a / args.b, format!("{} ÷ {}", args.a, args.b))
}
op => {
return ToolResult {
success: false,
output: String::new(),
error: Some(format!("unknown op '{op}' — use: add, sub, mul, div")),
result: None,
};
}
};
ToolResult {
success: true,
output: format!("{label} = {value}"),
error: None,
result: Some(value),
}
}
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"
+14
View File
@@ -0,0 +1,14 @@
[workspace]
[package]
name = "__SKILL_NAME__"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "__BIN_NAME__"
path = "src/main.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -0,0 +1,15 @@
{
"name": "__SKILL_NAME__",
"version": "1",
"description": "Look up current weather for a city",
"parameters": {
"type": "object",
"required": ["city"],
"properties": {
"city": {
"type": "string",
"description": "City name (e.g. Hanoi, London, Tokyo, Singapore)"
}
}
}
}
+144
View File
@@ -0,0 +1,144 @@
//! __SKILL_NAME__ — ZeroClaw Skill (Rust / WASI)
//!
//! Returns mock weather data for a given city.
//! Protocol: read JSON from stdin, write JSON result to stdout.
//! Build: cargo build --target wasm32-wasip1 --release
//! cp target/wasm32-wasip1/release/__BIN_NAME__.wasm tool.wasm
//! Test: zeroclaw skill test . --args '{"city":"hanoi"}'
use std::io::{self, Read, Write};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Args {
city: String,
}
#[derive(Serialize)]
struct WeatherData {
city: String,
temperature_c: f32,
condition: String,
humidity_pct: u8,
wind_kmh: u8,
}
#[derive(Serialize)]
struct ToolResult {
success: bool,
output: String,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<WeatherData>,
}
fn write_result(r: &ToolResult) {
let out = serde_json::to_string(r)
.unwrap_or_else(|_| r#"{"success":false,"output":"","error":"serialization error"}"#.to_string());
let _ = io::stdout().write_all(out.as_bytes());
}
fn main() {
let mut buf = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buf) {
write_result(&ToolResult {
success: false,
output: String::new(),
error: Some(format!("failed to read stdin: {e}")),
data: None,
});
return;
}
let result = match serde_json::from_str::<Args>(&buf) {
Ok(args) => lookup_weather(&args.city),
Err(e) => ToolResult {
success: false,
output: String::new(),
error: Some(format!("invalid input: {e} — expected {{\"city\": \"<name>\"}}")),
data: None,
},
};
write_result(&result);
}
fn lookup_weather(city: &str) -> ToolResult {
// Mock weather database — no HTTP inside WASI sandbox
let weather = match city.to_lowercase().as_str() {
"hanoi" | "ha noi" => WeatherData {
city: "Hanoi".into(),
temperature_c: 28.5,
condition: "Partly Cloudy".into(),
humidity_pct: 75,
wind_kmh: 12,
},
"ho chi minh" | "hcm" | "saigon" => WeatherData {
city: "Ho Chi Minh City".into(),
temperature_c: 33.0,
condition: "Sunny".into(),
humidity_pct: 68,
wind_kmh: 8,
},
"da nang" => WeatherData {
city: "Da Nang".into(),
temperature_c: 30.2,
condition: "Clear".into(),
humidity_pct: 65,
wind_kmh: 15,
},
"london" => WeatherData {
city: "London".into(),
temperature_c: 12.0,
condition: "Overcast".into(),
humidity_pct: 82,
wind_kmh: 20,
},
"tokyo" => WeatherData {
city: "Tokyo".into(),
temperature_c: 18.0,
condition: "Light Rain".into(),
humidity_pct: 78,
wind_kmh: 10,
},
"new york" | "nyc" => WeatherData {
city: "New York".into(),
temperature_c: 15.0,
condition: "Cloudy".into(),
humidity_pct: 70,
wind_kmh: 18,
},
"paris" => WeatherData {
city: "Paris".into(),
temperature_c: 14.5,
condition: "Rainy".into(),
humidity_pct: 85,
wind_kmh: 22,
},
"singapore" => WeatherData {
city: "Singapore".into(),
temperature_c: 31.0,
condition: "Thunderstorm".into(),
humidity_pct: 88,
wind_kmh: 14,
},
_ => {
return ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"city '{city}' not found. Supported: Hanoi, Ho Chi Minh, Da Nang, London, Tokyo, New York, Paris, Singapore"
)),
data: None,
};
}
};
let output = format!(
"{}: {}°C, {}, humidity {}%, wind {} km/h",
weather.city, weather.temperature_c, weather.condition, weather.humidity_pct, weather.wind_kmh
);
ToolResult { success: true, output, error: None, data: Some(weather) }
}
@@ -0,0 +1,15 @@
{
"name": "__SKILL_NAME__",
"version": "1",
"description": "Greet a user by name",
"parameters": {
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string",
"description": "Name to greet"
}
}
}
}
@@ -0,0 +1,14 @@
{
"name": "__SKILL_NAME__",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "npm run compile && javy compile dist/index.js -o tool.wasm",
"compile": "esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js --format=cjs",
"test": "zeroclaw skill test . --args '{\"name\":\"ZeroClaw\"}'"
},
"devDependencies": {
"esbuild": "^0.24.0",
"typescript": "^5.0.0"
}
}
@@ -0,0 +1,37 @@
/**
* __SKILL_NAME__ — ZeroClaw Skill (TypeScript)
*
* Protocol: read JSON from stdin, write JSON result to stdout.
* Build: npm install && npm run build → tool.wasm
* Requires: javy CLI → https://github.com/bytecodealliance/javy
* Test: zeroclaw skill test . --args '{"name":"ZeroClaw"}'
*/
interface Args {
name: string;
}
interface ToolResult {
success: boolean;
output: string;
error?: string;
}
function run(args: Args): ToolResult {
const greeting = `Hello, ${args.name}! Welcome to ZeroClaw skills.`;
return { success: true, output: greeting };
}
let result: ToolResult;
try {
// @ts-ignore — Javy provides synchronous IO
const rawInput = new TextDecoder().decode(Javy.IO.readSync());
const input = JSON.parse(rawInput);
if (!input.name) throw new Error('missing required field: name');
result = run(input as Args);
} catch (e: unknown) {
result = { success: false, output: '', error: String(e) };
}
// @ts-ignore
Javy.IO.writeSync(new TextEncoder().encode(JSON.stringify(result)));
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"outDir": "dist"
},
"include": ["src"]
}