mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
docs(zap): add comprehensive ZAP protocol documentation
Add Cap'n Proto-style documentation covering: - schema.md: Type system, tool definitions, schema evolution - encoding.md: Wire format specification, zero-copy parsing - rpc.md: Request/response, pipelining, streaming, batching - security.md: AskForApproval policies, SandboxPolicy modes - languages.md: Bindings for Rust, Python, Go, Node.js, Ruby, Elixir - tools.md: Complete API reference for 14 tool categories Total: ~4,000 lines of documentation for mainstream adoption.
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
# Wire Format Specification
|
||||
|
||||
ZAP uses a simple, efficient binary wire format designed for zero-copy parsing and minimal overhead.
|
||||
|
||||
## Design Goals
|
||||
|
||||
1. **Zero-copy parsing**: Read data directly from network buffers
|
||||
2. **Minimal framing**: Simple length-prefixed messages
|
||||
3. **Streaming friendly**: Process messages before fully received
|
||||
4. **Language agnostic**: Easy to implement in any language
|
||||
|
||||
## Message Framing
|
||||
|
||||
Every ZAP message consists of a fixed header followed by a variable-length payload:
|
||||
|
||||
```
|
||||
+----------+----------+------------------+
|
||||
| Length | MsgType | Payload |
|
||||
| (4 bytes)| (1 byte) | (variable) |
|
||||
| LE u32 | | |
|
||||
+----------+----------+------------------+
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
| Field | Size | Type | Description |
|
||||
|-------|------|------|-------------|
|
||||
| Length | 4 bytes | Little-endian u32 | Total size of MsgType + Payload |
|
||||
| MsgType | 1 byte | u8 | Message type code |
|
||||
| Payload | Variable | bytes | JSON or binary payload |
|
||||
|
||||
### Example: ListTools Request
|
||||
|
||||
```
|
||||
Hex: 01 00 00 00 10
|
||||
|________| |
|
||||
Length=1 MsgType=0x10 (ListTools)
|
||||
|
||||
No payload for ListTools request.
|
||||
```
|
||||
|
||||
### Example: CallTool Request
|
||||
|
||||
```
|
||||
Hex: 2A 00 00 00 12 7B 22 6E 61 6D 65 22 3A 22 72 65 61 64 5F 66 69 6C 65 22 2C 22 61 72 67 73 22 3A 7B 22 70 61 74 68 22 3A 22 2E 22 7D 7D
|
||||
|________| | |_______________________________________________________________________________________________________________|
|
||||
Length=42 MT JSON payload: {"name":"read_file","args":{"path":"."}}
|
||||
```
|
||||
|
||||
## Message Types
|
||||
|
||||
| Type | Code | Direction | Description |
|
||||
|------|------|-----------|-------------|
|
||||
| `Init` | `0x01` | C->S | Client handshake |
|
||||
| `InitAck` | `0x02` | S->C | Server handshake response |
|
||||
| `ListTools` | `0x10` | C->S | Request available tools |
|
||||
| `ListToolsResponse` | `0x11` | S->C | Tool list |
|
||||
| `CallTool` | `0x12` | C->S | Execute a tool |
|
||||
| `CallToolResponse` | `0x13` | S->C | Tool result |
|
||||
| `ListResources` | `0x20` | C->S | Request resources |
|
||||
| `ListResourcesResponse` | `0x21` | S->C | Resource list |
|
||||
| `ReadResource` | `0x22` | C->S | Read a resource |
|
||||
| `ReadResourceResponse` | `0x23` | S->C | Resource content |
|
||||
| `ListPrompts` | `0x30` | C->S | Request prompts |
|
||||
| `ListPromptsResponse` | `0x31` | S->C | Prompt list |
|
||||
| `GetPrompt` | `0x32` | C->S | Get a prompt |
|
||||
| `GetPromptResponse` | `0x33` | S->C | Prompt content |
|
||||
| `AddServer` | `0x40` | C->S | Add MCP server (gateway) |
|
||||
| `AddServerResponse` | `0x41` | S->C | Server ID |
|
||||
| `RemoveServer` | `0x42` | C->S | Remove MCP server |
|
||||
| `RemoveServerResponse` | `0x43` | S->C | Success flag |
|
||||
| `ListServers` | `0x44` | C->S | List connected servers |
|
||||
| `ListServersResponse` | `0x45` | S->C | Server list |
|
||||
| `Error` | `0xFE` | S->C | Error response |
|
||||
| `Close` | `0xFF` | Both | Close connection |
|
||||
|
||||
## Payload Encoding
|
||||
|
||||
Payloads use JSON encoding with the following conventions:
|
||||
|
||||
### String Encoding
|
||||
|
||||
Strings are UTF-8 encoded JSON strings:
|
||||
|
||||
```json
|
||||
{"path": "/home/user/file.txt"}
|
||||
```
|
||||
|
||||
### Binary Data
|
||||
|
||||
Binary data is base64-encoded in JSON:
|
||||
|
||||
```json
|
||||
{"content": "SGVsbG8gV29ybGQh"}
|
||||
```
|
||||
|
||||
For large binary transfers, consider using resources instead of tool calls.
|
||||
|
||||
### Numbers
|
||||
|
||||
- Integers: JSON numbers (no quotes)
|
||||
- Floats: JSON numbers with decimal point
|
||||
- Large integers (>53 bits): JSON strings to preserve precision
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 42,
|
||||
"ratio": 3.14159,
|
||||
"big_id": "9007199254740993"
|
||||
}
|
||||
```
|
||||
|
||||
### Timestamps
|
||||
|
||||
ISO 8601 format in UTC:
|
||||
|
||||
```json
|
||||
{"created_at": "2025-01-15T10:30:00Z"}
|
||||
```
|
||||
|
||||
### Null vs Absent
|
||||
|
||||
- `null`: Field is explicitly empty
|
||||
- Absent: Field uses default value
|
||||
|
||||
```json
|
||||
// Explicit null - offset is definitely 0
|
||||
{"path": "/file.txt", "offset": null}
|
||||
|
||||
// Absent - offset uses default (also 0, but semantically different)
|
||||
{"path": "/file.txt"}
|
||||
```
|
||||
|
||||
## Zero-Copy Reading
|
||||
|
||||
ZAP's wire format enables zero-copy parsing. The reader maintains a position in the original buffer:
|
||||
|
||||
```rust
|
||||
pub struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
/// Read bytes without copying - returns slice into original buffer
|
||||
pub fn read_bytes(&mut self) -> Result<&'a [u8]> {
|
||||
let len = self.read_u32_le()? as usize;
|
||||
let bytes = &self.data[self.pos..self.pos + len];
|
||||
self.pos += len;
|
||||
Ok(bytes) // Zero-copy: points into original data
|
||||
}
|
||||
|
||||
/// Read string without copying
|
||||
pub fn read_string(&mut self) -> Result<&'a str> {
|
||||
let bytes = self.read_bytes()?;
|
||||
std::str::from_utf8(bytes)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Primitive Encoding
|
||||
|
||||
For binary payloads (not JSON), ZAP uses these formats:
|
||||
|
||||
| Type | Size | Format |
|
||||
|------|------|--------|
|
||||
| `u8` | 1 | Unsigned byte |
|
||||
| `u16` | 2 | Little-endian |
|
||||
| `u32` | 4 | Little-endian |
|
||||
| `u64` | 8 | Little-endian |
|
||||
| `i32` | 4 | Little-endian, two's complement |
|
||||
| `i64` | 8 | Little-endian, two's complement |
|
||||
| `f32` | 4 | IEEE 754 single |
|
||||
| `f64` | 8 | IEEE 754 double |
|
||||
| `bool` | 1 | 0x00 = false, 0x01 = true |
|
||||
| `bytes` | 4 + n | Length-prefixed (u32 LE + data) |
|
||||
| `string` | 4 + n | Length-prefixed UTF-8 |
|
||||
|
||||
### Example: Length-Prefixed String
|
||||
|
||||
```
|
||||
"hello" encoded:
|
||||
05 00 00 00 68 65 6C 6C 6F
|
||||
|________| |____________|
|
||||
len=5 "hello"
|
||||
```
|
||||
|
||||
## Message Size Limits
|
||||
|
||||
| Limit | Value | Notes |
|
||||
|-------|-------|-------|
|
||||
| Maximum message size | 16 MB | Configurable |
|
||||
| Maximum header size | 5 bytes | Fixed |
|
||||
| Minimum message size | 5 bytes | Header only |
|
||||
|
||||
Messages exceeding the size limit result in an error:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -32600,
|
||||
"message": "Message too large: 17825792 bytes exceeds limit of 16777216"
|
||||
}
|
||||
```
|
||||
|
||||
## Handshake Protocol
|
||||
|
||||
### Init Message
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hanzo-agent",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### InitAck Message
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hanzo-zap-gateway",
|
||||
"version": "0.1.0",
|
||||
"capabilities": {
|
||||
"tools": true,
|
||||
"resources": true,
|
||||
"prompts": true,
|
||||
"logging": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Message Format
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -32601,
|
||||
"message": "Tool not found: unknown_tool"
|
||||
}
|
||||
```
|
||||
|
||||
### Standard Error Codes
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| -32700 | Parse error | Invalid JSON |
|
||||
| -32600 | Invalid request | Malformed request |
|
||||
| -32601 | Method not found | Tool/resource not found |
|
||||
| -32602 | Invalid params | Invalid arguments |
|
||||
| -32603 | Internal error | Server error |
|
||||
| -32000 | Server error | Generic server error |
|
||||
| -32001 | Timeout | Request timed out |
|
||||
| -32002 | Permission denied | Not authorized |
|
||||
|
||||
## Implementation Guide
|
||||
|
||||
### Reading a Message (Rust)
|
||||
|
||||
```rust
|
||||
use std::io::Read;
|
||||
|
||||
fn read_message<R: Read>(reader: &mut R) -> Result<(u8, Vec<u8>)> {
|
||||
// Read length header
|
||||
let mut header = [0u8; 4];
|
||||
reader.read_exact(&mut header)?;
|
||||
let length = u32::from_le_bytes(header) as usize;
|
||||
|
||||
// Validate size
|
||||
if length > MAX_MESSAGE_SIZE {
|
||||
return Err(Error::MessageTooLarge);
|
||||
}
|
||||
|
||||
// Read message body
|
||||
let mut body = vec![0u8; length];
|
||||
reader.read_exact(&mut body)?;
|
||||
|
||||
// Extract message type and payload
|
||||
let msg_type = body[0];
|
||||
let payload = body[1..].to_vec();
|
||||
|
||||
Ok((msg_type, payload))
|
||||
}
|
||||
```
|
||||
|
||||
### Writing a Message (Rust)
|
||||
|
||||
```rust
|
||||
use std::io::Write;
|
||||
|
||||
fn write_message<W: Write>(writer: &mut W, msg_type: u8, payload: &[u8]) -> Result<()> {
|
||||
// Calculate total length
|
||||
let total_len = 1 + payload.len();
|
||||
|
||||
// Write length header
|
||||
writer.write_all(&(total_len as u32).to_le_bytes())?;
|
||||
|
||||
// Write message type
|
||||
writer.write_all(&[msg_type])?;
|
||||
|
||||
// Write payload
|
||||
writer.write_all(payload)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Reading a Message (Python)
|
||||
|
||||
```python
|
||||
import struct
|
||||
|
||||
def read_message(sock):
|
||||
# Read length header
|
||||
header = sock.recv(4)
|
||||
if len(header) < 4:
|
||||
raise ConnectionError("Connection closed")
|
||||
|
||||
length = struct.unpack('<I', header)[0]
|
||||
|
||||
# Validate size
|
||||
if length > MAX_MESSAGE_SIZE:
|
||||
raise ValueError(f"Message too large: {length}")
|
||||
|
||||
# Read message body
|
||||
body = b''
|
||||
while len(body) < length:
|
||||
chunk = sock.recv(length - len(body))
|
||||
if not chunk:
|
||||
raise ConnectionError("Connection closed")
|
||||
body += chunk
|
||||
|
||||
# Extract message type and payload
|
||||
msg_type = body[0]
|
||||
payload = body[1:]
|
||||
|
||||
return msg_type, payload
|
||||
```
|
||||
|
||||
### Reading a Message (Go)
|
||||
|
||||
```go
|
||||
func readMessage(conn net.Conn) (byte, []byte, error) {
|
||||
// Read length header
|
||||
header := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
length := binary.LittleEndian.Uint32(header)
|
||||
|
||||
// Validate size
|
||||
if length > MaxMessageSize {
|
||||
return 0, nil, fmt.Errorf("message too large: %d", length)
|
||||
}
|
||||
|
||||
// Read message body
|
||||
body := make([]byte, length)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
// Extract message type and payload
|
||||
msgType := body[0]
|
||||
payload := body[1:]
|
||||
|
||||
return msgType, payload, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Compression (Optional)
|
||||
|
||||
ZAP supports optional payload compression for large messages:
|
||||
|
||||
| Flag | Algorithm | Notes |
|
||||
|------|-----------|-------|
|
||||
| `0x80` | None | Default, no compression |
|
||||
| `0x81` | LZ4 | Fast compression |
|
||||
| `0x82` | Zstd | Better ratio |
|
||||
|
||||
The compression flag is OR'd with the message type:
|
||||
|
||||
```
|
||||
MsgType = 0x13 (CallToolResponse) | 0x81 (LZ4) = 0x94
|
||||
```
|
||||
|
||||
Decompression:
|
||||
```rust
|
||||
let compression = msg_type & 0xF0;
|
||||
let actual_type = msg_type & 0x0F;
|
||||
|
||||
let payload = match compression {
|
||||
0x80 => raw_payload, // No compression
|
||||
0x81 => lz4_decompress(raw_payload)?,
|
||||
0x82 => zstd_decompress(raw_payload)?,
|
||||
_ => return Err(Error::UnknownCompression),
|
||||
};
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [RPC Protocol](rpc.md): Request/response patterns and streaming
|
||||
- [Security](security.md): Permission checking and sandboxing
|
||||
@@ -0,0 +1,763 @@
|
||||
# Language Bindings
|
||||
|
||||
ZAP provides native bindings for multiple programming languages, enabling agents to be written in whatever language is most appropriate for the task.
|
||||
|
||||
## Supported Languages
|
||||
|
||||
| Language | Package | Status | Native Tools | MCP Bridge |
|
||||
|----------|---------|--------|--------------|------------|
|
||||
| Rust | `hanzo-zap` | Stable | Yes | Yes |
|
||||
| Python | `hanzo-zap` | Stable | Yes | Yes |
|
||||
| Go | `github.com/hanzoai/zap-go` | Stable | Yes | Yes |
|
||||
| Node.js | `@hanzo/zap` | Stable | Yes | Yes |
|
||||
| C/C++ | `libzap` | Beta | FFI | Yes |
|
||||
| Ruby | `hanzo-zap` | Beta | Via FFI | Yes |
|
||||
| Elixir | `hanzo_zap` | Beta | Via NIF | Yes |
|
||||
|
||||
## Rust
|
||||
|
||||
The reference implementation with full zero-copy support.
|
||||
|
||||
### Installation
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hanzo-zap = "0.1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde_json = "1"
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```rust
|
||||
use hanzo_zap::Client;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let mut client = Client::connect("zap://localhost:9999").await?;
|
||||
|
||||
// List tools
|
||||
let tools = client.list_tools().await?;
|
||||
for tool in &tools {
|
||||
println!("{}: {}", tool.name, tool.description);
|
||||
}
|
||||
|
||||
// Call a tool
|
||||
let result = client.call_tool("read_file", json!({
|
||||
"path": "src/main.rs"
|
||||
})).await?;
|
||||
|
||||
println!("{}", result.content);
|
||||
|
||||
client.close().await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Gateway Usage
|
||||
|
||||
```rust
|
||||
use hanzo_zap::{Gateway, GatewayConfig, McpServerConfig, Transport};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let config = GatewayConfig {
|
||||
listen: "0.0.0.0:9999".to_string(),
|
||||
servers: vec![
|
||||
McpServerConfig {
|
||||
name: "filesystem".to_string(),
|
||||
transport: Transport::Stdio {
|
||||
command: "mcp-server-filesystem".to_string(),
|
||||
args: vec!["/home".to_string()],
|
||||
env: Default::default(),
|
||||
},
|
||||
auth: None,
|
||||
timeout_ms: None,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let gateway = Gateway::new(config).await?;
|
||||
gateway.serve().await
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Tool Execution
|
||||
|
||||
```rust
|
||||
use hanzo_zap::{default_dispatcher, ExecutorContext};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let dispatcher = default_dispatcher();
|
||||
let ctx = ExecutorContext::with_cwd(".");
|
||||
|
||||
let result = dispatcher.execute(
|
||||
"read_file",
|
||||
json!({ "path": "README.md" }),
|
||||
&ctx
|
||||
).await?;
|
||||
|
||||
println!("{}", result.content);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Python
|
||||
|
||||
Pythonic async/await interface with type hints.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
pip install hanzo-zap
|
||||
|
||||
# With specific tool packages
|
||||
pip install hanzo-tools-fs hanzo-tools-vcs hanzo-tools-shell
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from hanzo_zap import Client
|
||||
|
||||
async def main():
|
||||
client = await Client.connect("zap://localhost:9999")
|
||||
|
||||
# List tools
|
||||
tools = await client.list_tools()
|
||||
for tool in tools:
|
||||
print(f"{tool.name}: {tool.description}")
|
||||
|
||||
# Call a tool
|
||||
result = await client.call_tool("read_file", {"path": "src/main.py"})
|
||||
print(result.content)
|
||||
|
||||
await client.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Direct Tool Execution
|
||||
|
||||
```python
|
||||
from hanzo_tools.core import BaseTool, PermissionManager
|
||||
from hanzo_tools.fs import FsTool
|
||||
from hanzo_tools.vcs import VcsTool
|
||||
|
||||
async def main():
|
||||
# Create permission manager
|
||||
pm = PermissionManager(
|
||||
allowed_paths=["."],
|
||||
deny_patterns=[".env", "*.pem"]
|
||||
)
|
||||
|
||||
# Create tools
|
||||
fs = FsTool(permission_manager=pm)
|
||||
vcs = VcsTool(cwd=".")
|
||||
|
||||
# Execute
|
||||
content = await fs.call(ctx=None, action="read", path="README.md")
|
||||
status = await vcs.call(ctx=None, action="status")
|
||||
|
||||
print(content)
|
||||
print(status)
|
||||
```
|
||||
|
||||
### Type Hints
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List
|
||||
|
||||
@dataclass
|
||||
class ReadFileArgs:
|
||||
path: str
|
||||
offset: Optional[int] = None
|
||||
limit: Optional[int] = None
|
||||
|
||||
@dataclass
|
||||
class ReadFileResult:
|
||||
content: str
|
||||
mime_type: str
|
||||
size: int
|
||||
|
||||
async def read_file(client: Client, args: ReadFileArgs) -> ReadFileResult:
|
||||
result = await client.call_tool("read_file", {
|
||||
"path": args.path,
|
||||
"offset": args.offset,
|
||||
"limit": args.limit,
|
||||
})
|
||||
return ReadFileResult(
|
||||
content=result.content,
|
||||
mime_type=result.metadata.get("mime_type", "text/plain"),
|
||||
size=int(result.metadata.get("size", "0"))
|
||||
)
|
||||
```
|
||||
|
||||
## Go
|
||||
|
||||
Idiomatic Go with context support and error handling.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
go get github.com/hanzoai/zap-go
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
zap "github.com/hanzoai/zap-go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := zap.Connect(ctx, "zap://localhost:9999")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// List tools
|
||||
tools, err := client.ListTools(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
fmt.Printf("%s: %s\n", tool.Name, tool.Description)
|
||||
}
|
||||
|
||||
// Call a tool
|
||||
result, err := client.CallTool(ctx, "read_file", map[string]interface{}{
|
||||
"path": "main.go",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(result.Content)
|
||||
}
|
||||
```
|
||||
|
||||
### Typed Tool Calls
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
zap "github.com/hanzoai/zap-go"
|
||||
"github.com/hanzoai/zap-go/tools/filesystem"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
client, _ := zap.Connect(ctx, "zap://localhost:9999")
|
||||
defer client.Close()
|
||||
|
||||
// Typed arguments
|
||||
args := filesystem.ReadFileArgs{
|
||||
Path: "main.go",
|
||||
Offset: nil,
|
||||
Limit: nil,
|
||||
}
|
||||
|
||||
// Typed result
|
||||
var result filesystem.ReadFileResult
|
||||
err := client.CallToolTyped(ctx, "read_file", args, &result)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Printf("Read %d bytes\n", result.Size)
|
||||
}
|
||||
```
|
||||
|
||||
### Executor Context
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/zap-go/executor"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := executor.NewContext(
|
||||
executor.WithCwd("."),
|
||||
executor.WithApprovalPolicy(executor.OnRequest),
|
||||
executor.WithSandboxPolicy(executor.WorkspaceWrite{
|
||||
WritableRoots: []string{},
|
||||
NetworkAccess: true,
|
||||
}),
|
||||
)
|
||||
|
||||
dispatcher := executor.NewDispatcher()
|
||||
|
||||
result, err := dispatcher.Execute(ctx, "read_file", map[string]interface{}{
|
||||
"path": "main.go",
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println(result.Content)
|
||||
}
|
||||
```
|
||||
|
||||
## Node.js / TypeScript
|
||||
|
||||
Modern async/await with full TypeScript support.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install @hanzo/zap
|
||||
# or
|
||||
pnpm add @hanzo/zap
|
||||
```
|
||||
|
||||
### Client Usage
|
||||
|
||||
```typescript
|
||||
import { Client } from '@hanzo/zap';
|
||||
|
||||
async function main() {
|
||||
const client = await Client.connect('zap://localhost:9999');
|
||||
|
||||
// List tools
|
||||
const tools = await client.listTools();
|
||||
for (const tool of tools) {
|
||||
console.log(`${tool.name}: ${tool.description}`);
|
||||
}
|
||||
|
||||
// Call a tool
|
||||
const result = await client.callTool('read_file', {
|
||||
path: 'src/index.ts'
|
||||
});
|
||||
|
||||
console.log(result.content);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
```
|
||||
|
||||
### TypeScript Types
|
||||
|
||||
```typescript
|
||||
import { Client, Tool, ToolResult } from '@hanzo/zap';
|
||||
|
||||
interface ReadFileArgs {
|
||||
path: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface ReadFileResult {
|
||||
content: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
async function readFile(
|
||||
client: Client,
|
||||
args: ReadFileArgs
|
||||
): Promise<ReadFileResult> {
|
||||
const result = await client.callTool<ReadFileResult>('read_file', args);
|
||||
return result.content as ReadFileResult;
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Bridge
|
||||
|
||||
```typescript
|
||||
import { Gateway, McpServerConfig } from '@hanzo/zap';
|
||||
|
||||
async function main() {
|
||||
const gateway = new Gateway({
|
||||
listen: '0.0.0.0:9999',
|
||||
servers: [
|
||||
{
|
||||
name: 'filesystem',
|
||||
transport: {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-filesystem', '/home'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await gateway.serve();
|
||||
}
|
||||
```
|
||||
|
||||
## C/C++ (FFI)
|
||||
|
||||
Low-level bindings for performance-critical applications.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Build from source
|
||||
git clone https://github.com/hanzoai/zap
|
||||
cd zap
|
||||
cargo build --release --features c-ffi
|
||||
|
||||
# Copy library
|
||||
cp target/release/libzap.{so,dylib,dll} /usr/local/lib/
|
||||
cp zap/include/zap.h /usr/local/include/
|
||||
```
|
||||
|
||||
### Header File
|
||||
|
||||
```c
|
||||
// zap.h
|
||||
#ifndef ZAP_H
|
||||
#define ZAP_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct ZapClient ZapClient;
|
||||
typedef struct ZapToolResult ZapToolResult;
|
||||
|
||||
// Connection
|
||||
ZapClient* zap_connect(const char* url);
|
||||
void zap_close(ZapClient* client);
|
||||
|
||||
// Tool operations
|
||||
char** zap_list_tools(ZapClient* client, size_t* count);
|
||||
ZapToolResult* zap_call_tool(ZapClient* client, const char* name, const char* args_json);
|
||||
|
||||
// Result access
|
||||
const char* zap_result_content(ZapToolResult* result);
|
||||
const char* zap_result_error(ZapToolResult* result);
|
||||
void zap_result_free(ZapToolResult* result);
|
||||
|
||||
// Error handling
|
||||
const char* zap_last_error(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // ZAP_H
|
||||
```
|
||||
|
||||
### C Usage
|
||||
|
||||
```c
|
||||
#include <stdio.h>
|
||||
#include "zap.h"
|
||||
|
||||
int main() {
|
||||
// Connect
|
||||
ZapClient* client = zap_connect("zap://localhost:9999");
|
||||
if (!client) {
|
||||
fprintf(stderr, "Failed to connect: %s\n", zap_last_error());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// List tools
|
||||
size_t count;
|
||||
char** tools = zap_list_tools(client, &count);
|
||||
printf("Available tools (%zu):\n", count);
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
printf(" - %s\n", tools[i]);
|
||||
}
|
||||
|
||||
// Call tool
|
||||
ZapToolResult* result = zap_call_tool(client, "read_file",
|
||||
"{\"path\": \"README.md\"}");
|
||||
|
||||
const char* error = zap_result_error(result);
|
||||
if (error) {
|
||||
fprintf(stderr, "Error: %s\n", error);
|
||||
} else {
|
||||
printf("Content:\n%s\n", zap_result_content(result));
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
zap_result_free(result);
|
||||
zap_close(client);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### C++ Wrapper
|
||||
|
||||
```cpp
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <stdexcept>
|
||||
#include "zap.h"
|
||||
|
||||
namespace zap {
|
||||
|
||||
class Client {
|
||||
public:
|
||||
explicit Client(const std::string& url) {
|
||||
client_ = zap_connect(url.c_str());
|
||||
if (!client_) {
|
||||
throw std::runtime_error(zap_last_error());
|
||||
}
|
||||
}
|
||||
|
||||
~Client() {
|
||||
if (client_) {
|
||||
zap_close(client_);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> list_tools() {
|
||||
size_t count;
|
||||
char** tools = zap_list_tools(client_, &count);
|
||||
std::vector<std::string> result(tools, tools + count);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string call_tool(const std::string& name, const std::string& args) {
|
||||
ZapToolResult* result = zap_call_tool(client_, name.c_str(), args.c_str());
|
||||
|
||||
const char* error = zap_result_error(result);
|
||||
if (error) {
|
||||
std::string err(error);
|
||||
zap_result_free(result);
|
||||
throw std::runtime_error(err);
|
||||
}
|
||||
|
||||
std::string content(zap_result_content(result));
|
||||
zap_result_free(result);
|
||||
return content;
|
||||
}
|
||||
|
||||
private:
|
||||
ZapClient* client_;
|
||||
};
|
||||
|
||||
} // namespace zap
|
||||
```
|
||||
|
||||
## Ruby
|
||||
|
||||
Ruby gem with idiomatic interface.
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
gem install hanzo-zap
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```ruby
|
||||
require 'hanzo/zap'
|
||||
|
||||
# Connect
|
||||
client = Hanzo::Zap::Client.connect('zap://localhost:9999')
|
||||
|
||||
# List tools
|
||||
client.list_tools.each do |tool|
|
||||
puts "#{tool.name}: #{tool.description}"
|
||||
end
|
||||
|
||||
# Call tool
|
||||
result = client.call_tool('read_file', path: 'README.md')
|
||||
puts result.content
|
||||
|
||||
# Close
|
||||
client.close
|
||||
```
|
||||
|
||||
### Block Style
|
||||
|
||||
```ruby
|
||||
Hanzo::Zap::Client.connect('zap://localhost:9999') do |client|
|
||||
result = client.call_tool('read_file', path: 'README.md')
|
||||
puts result.content
|
||||
end # Automatically closed
|
||||
```
|
||||
|
||||
## Elixir
|
||||
|
||||
Elixir package with OTP integration.
|
||||
|
||||
### Installation
|
||||
|
||||
```elixir
|
||||
# mix.exs
|
||||
defp deps do
|
||||
[
|
||||
{:hanzo_zap, "~> 0.1"}
|
||||
]
|
||||
end
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```elixir
|
||||
defmodule MyAgent do
|
||||
use GenServer
|
||||
alias HanzoZap.Client
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
def init(_opts) do
|
||||
{:ok, client} = Client.connect("zap://localhost:9999")
|
||||
{:ok, %{client: client}}
|
||||
end
|
||||
|
||||
def handle_call({:call_tool, name, args}, _from, %{client: client} = state) do
|
||||
result = Client.call_tool(client, name, args)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
def handle_cast(:close, %{client: client} = state) do
|
||||
Client.close(client)
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
{:ok, _pid} = MyAgent.start_link([])
|
||||
result = GenServer.call(MyAgent, {:call_tool, "read_file", %{path: "README.md"}})
|
||||
IO.puts(result.content)
|
||||
```
|
||||
|
||||
## MCP Bridge
|
||||
|
||||
All language bindings support bridging existing MCP servers:
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| ZAP Client | | ZAP Gateway | | MCP Server |
|
||||
| (any language) |<--->| (Rust) |<--->| (any language) |
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| | |
|
||||
| ZAP binary | JSON-RPC translation | JSON-RPC
|
||||
| protocol | | (stdio/HTTP)
|
||||
```
|
||||
|
||||
### Supported MCP Transports
|
||||
|
||||
| Transport | ZAP Config | Description |
|
||||
|-----------|------------|-------------|
|
||||
| Stdio | `type: "stdio"` | Subprocess with stdin/stdout |
|
||||
| HTTP | `type: "http"` | HTTP/SSE endpoint |
|
||||
| WebSocket | `type: "websocket"` | WebSocket endpoint |
|
||||
| ZAP | `type: "zap"` | Native ZAP server |
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```toml
|
||||
[[servers]]
|
||||
name = "filesystem"
|
||||
[servers.transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-filesystem", "/home"]
|
||||
|
||||
[[servers]]
|
||||
name = "github"
|
||||
[servers.transport]
|
||||
type = "http"
|
||||
url = "https://mcp.github.com"
|
||||
[servers.auth]
|
||||
type = "bearer"
|
||||
token = "${GITHUB_TOKEN}"
|
||||
|
||||
[[servers]]
|
||||
name = "internal-search"
|
||||
[servers.transport]
|
||||
type = "zap"
|
||||
url = "zaps://search.internal:9999"
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Connection Pooling
|
||||
|
||||
```rust
|
||||
// Rust - Use Arc for shared client
|
||||
let client = Arc::new(Mutex::new(Client::connect(url).await?));
|
||||
|
||||
// Python - Use connection pool
|
||||
pool = ConnectionPool("zap://localhost:9999", max_connections=10)
|
||||
async with pool.acquire() as client:
|
||||
result = await client.call_tool(...)
|
||||
|
||||
// Go - Use sync.Pool
|
||||
var clientPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
client, _ := zap.Connect(context.Background(), url)
|
||||
return client
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Handle Reconnection
|
||||
|
||||
```python
|
||||
async def resilient_call(tool: str, args: dict, max_retries: int = 3):
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await client.call_tool(tool, args)
|
||||
except ConnectionError:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
await asyncio.sleep(2 ** attempt) # Exponential backoff
|
||||
await client.reconnect()
|
||||
```
|
||||
|
||||
### 3. Use Typed Interfaces
|
||||
|
||||
```typescript
|
||||
// TypeScript - Define interfaces
|
||||
interface GrepArgs {
|
||||
pattern: string;
|
||||
path: string;
|
||||
ignoreCase?: boolean;
|
||||
}
|
||||
|
||||
interface GrepMatch {
|
||||
path: string;
|
||||
lineNumber: number;
|
||||
line: string;
|
||||
}
|
||||
|
||||
const matches = await client.callTool<GrepMatch[]>('grep', {
|
||||
pattern: 'TODO',
|
||||
path: 'src/**/*.ts',
|
||||
} as GrepArgs);
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Tool Reference](tools.md): Complete API documentation for all tools
|
||||
@@ -0,0 +1,543 @@
|
||||
# RPC Protocol
|
||||
|
||||
ZAP provides a simple yet powerful RPC protocol for agent-tool communication. This document covers request/response patterns, streaming, pipelining, and error handling.
|
||||
|
||||
## Connection Lifecycle
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- Init -------------------->|
|
||||
|<------- InitAck -----------------|
|
||||
| |
|
||||
|-------- Request 1 --------------->|
|
||||
|<------- Response 1 ---------------|
|
||||
| |
|
||||
|-------- Request 2 --------------->|
|
||||
|-------- Request 3 --------------->| (pipelined)
|
||||
|<------- Response 2 ---------------|
|
||||
|<------- Response 3 ---------------|
|
||||
| |
|
||||
|-------- Close ------------------->|
|
||||
| |
|
||||
```
|
||||
|
||||
## Handshake
|
||||
|
||||
Every connection begins with a handshake:
|
||||
|
||||
### Client Init
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-agent",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### Server InitAck
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hanzo-zap-gateway",
|
||||
"version": "0.1.0",
|
||||
"capabilities": {
|
||||
"tools": true,
|
||||
"resources": true,
|
||||
"prompts": true,
|
||||
"logging": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `capabilities` object indicates which features the server supports:
|
||||
- `tools`: Tool listing and execution
|
||||
- `resources`: Resource listing and reading
|
||||
- `prompts`: Prompt templates
|
||||
- `logging`: Server-side logging
|
||||
|
||||
## Request/Response Pattern
|
||||
|
||||
### Listing Tools
|
||||
|
||||
**Request** (MsgType: `0x10`):
|
||||
```
|
||||
Empty payload
|
||||
```
|
||||
|
||||
**Response** (MsgType: `0x11`):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "read_file",
|
||||
"description": "Read contents of a file",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"offset": { "type": "integer" },
|
||||
"limit": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write_file",
|
||||
"description": "Write contents to a file",
|
||||
"inputSchema": { ... }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Calling a Tool
|
||||
|
||||
**Request** (MsgType: `0x12`):
|
||||
```json
|
||||
{
|
||||
"id": "req-1",
|
||||
"name": "read_file",
|
||||
"arguments": {
|
||||
"path": "/src/main.rs"
|
||||
},
|
||||
"metadata": {
|
||||
"session_id": "agent-001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (MsgType: `0x13`):
|
||||
```json
|
||||
{
|
||||
"id": "req-1",
|
||||
"content": "fn main() {\n println!(\"Hello\");\n}",
|
||||
"metadata": {
|
||||
"mime_type": "text/x-rust",
|
||||
"size": "42"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
**Response** (MsgType: `0xFE`):
|
||||
```json
|
||||
{
|
||||
"code": -32601,
|
||||
"message": "Tool not found: unknown_tool"
|
||||
}
|
||||
```
|
||||
|
||||
## Pipelining
|
||||
|
||||
ZAP supports request pipelining - sending multiple requests without waiting for responses:
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- CallTool(A) ------------->|
|
||||
|-------- CallTool(B) ------------->| <- Sent immediately
|
||||
|-------- CallTool(C) ------------->|
|
||||
| |
|
||||
|<------- Result(A) ----------------| <- Responses in order
|
||||
|<------- Result(B) ----------------|
|
||||
|<------- Result(C) ----------------|
|
||||
```
|
||||
|
||||
### Request IDs
|
||||
|
||||
Each request has a unique ID that ties it to its response:
|
||||
|
||||
```rust
|
||||
async fn pipeline_example(client: &mut Client) -> Result<Vec<ToolResult>> {
|
||||
// Send all requests without waiting
|
||||
let futures = vec![
|
||||
client.call_tool("read_file", json!({"path": "a.txt"})),
|
||||
client.call_tool("read_file", json!({"path": "b.txt"})),
|
||||
client.call_tool("read_file", json!({"path": "c.txt"})),
|
||||
];
|
||||
|
||||
// Wait for all responses
|
||||
let results = futures::future::join_all(futures).await;
|
||||
results.into_iter().collect()
|
||||
}
|
||||
```
|
||||
|
||||
### Out-of-Order Responses
|
||||
|
||||
While requests are processed in order by default, servers may return responses out of order for concurrent execution:
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- CallTool(A, slow) ------->|
|
||||
|-------- CallTool(B, fast) ------->|
|
||||
| |
|
||||
|<------- Result(B) ----------------| <- B finishes first
|
||||
|<------- Result(A) ----------------| <- A finishes later
|
||||
```
|
||||
|
||||
Match responses by `id`, not order:
|
||||
|
||||
```rust
|
||||
fn match_response(request_id: &str, response: &ToolResult) -> bool {
|
||||
response.id == request_id
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming
|
||||
|
||||
ZAP supports streaming for long-running operations (future extension):
|
||||
|
||||
### Server-Sent Events Pattern
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
|-------- CallTool(exec) ---------> |
|
||||
|<------- StreamStart(id) ---------|
|
||||
|<------- StreamChunk(stdout) -----|
|
||||
|<------- StreamChunk(stdout) -----|
|
||||
|<------- StreamChunk(stderr) -----|
|
||||
|<------- StreamEnd(result) -------|
|
||||
```
|
||||
|
||||
### Streaming Implementation (Future)
|
||||
|
||||
```rust
|
||||
// Subscribe to streaming responses
|
||||
let (result_rx, final_rx) = client.call_tool_streaming("exec", json!({
|
||||
"command": "cargo build"
|
||||
})).await?;
|
||||
|
||||
// Process streaming chunks
|
||||
while let Some(chunk) = result_rx.recv().await {
|
||||
match chunk {
|
||||
StreamChunk::Stdout(data) => print!("{}", data),
|
||||
StreamChunk::Stderr(data) => eprint!("{}", data),
|
||||
StreamChunk::Progress(pct) => update_progress(pct),
|
||||
}
|
||||
}
|
||||
|
||||
// Get final result
|
||||
let result = final_rx.await?;
|
||||
```
|
||||
|
||||
## Cancellation
|
||||
|
||||
Clients can cancel in-flight requests:
|
||||
|
||||
### Cancel Request (MsgType: `0xF0`)
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "req-42"
|
||||
}
|
||||
```
|
||||
|
||||
### Cancel Acknowledgment (MsgType: `0xF1`)
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "req-42",
|
||||
"cancelled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
let handle = client.call_tool_async("exec", json!({
|
||||
"command": "cargo build --release"
|
||||
}));
|
||||
|
||||
// Cancel after timeout
|
||||
tokio::select! {
|
||||
result = handle.await => {
|
||||
println!("Completed: {:?}", result);
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_secs(60)) => {
|
||||
handle.cancel().await?;
|
||||
println!("Cancelled due to timeout");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Batching
|
||||
|
||||
Multiple tool calls can be batched into a single request:
|
||||
|
||||
### Batch Request (MsgType: `0x14`)
|
||||
|
||||
```json
|
||||
{
|
||||
"requests": [
|
||||
{"id": "1", "name": "read_file", "arguments": {"path": "a.txt"}},
|
||||
{"id": "2", "name": "read_file", "arguments": {"path": "b.txt"}},
|
||||
{"id": "3", "name": "git_status", "arguments": {}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Response (MsgType: `0x15`)
|
||||
|
||||
```json
|
||||
{
|
||||
"responses": [
|
||||
{"id": "1", "content": "contents of a.txt"},
|
||||
{"id": "2", "content": "contents of b.txt"},
|
||||
{"id": "3", "content": {"branch": "main", "staged": []}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### When to Batch
|
||||
|
||||
- **Batch**: Multiple independent reads
|
||||
- **Don't batch**: Operations with dependencies
|
||||
- **Don't batch**: Operations that might fail and block others
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | Name | Description |
|
||||
|------|------|-------------|
|
||||
| -32700 | Parse error | Invalid JSON in request |
|
||||
| -32600 | Invalid request | Request structure invalid |
|
||||
| -32601 | Method not found | Tool does not exist |
|
||||
| -32602 | Invalid params | Arguments invalid |
|
||||
| -32603 | Internal error | Server-side error |
|
||||
| -32000 | Server error | Generic server error |
|
||||
| -32001 | Timeout | Request timed out |
|
||||
| -32002 | Permission denied | Not authorized |
|
||||
| -32003 | Cancelled | Request was cancelled |
|
||||
|
||||
### Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -32602,
|
||||
"message": "Invalid params: path is required",
|
||||
"data": {
|
||||
"field": "path",
|
||||
"type": "missing"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Errors
|
||||
|
||||
```rust
|
||||
match client.call_tool("read_file", args).await {
|
||||
Ok(result) => {
|
||||
if let Some(error) = result.error {
|
||||
// Tool-level error (file not found, etc.)
|
||||
eprintln!("Tool error: {}", error);
|
||||
} else {
|
||||
// Success
|
||||
process_content(result.content);
|
||||
}
|
||||
}
|
||||
Err(Error::Protocol(msg)) => {
|
||||
// Protocol error (connection issues, parse errors)
|
||||
eprintln!("Protocol error: {}", msg);
|
||||
}
|
||||
Err(Error::Timeout) => {
|
||||
// Request timed out
|
||||
eprintln!("Request timed out");
|
||||
}
|
||||
Err(e) => {
|
||||
// Other errors
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Partial Success (Batching)
|
||||
|
||||
With batched requests, some may succeed while others fail:
|
||||
|
||||
```json
|
||||
{
|
||||
"responses": [
|
||||
{"id": "1", "content": "success"},
|
||||
{"id": "2", "error": "File not found: b.txt"},
|
||||
{"id": "3", "content": {"branch": "main"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Timeouts
|
||||
|
||||
### Request Timeout
|
||||
|
||||
Set per-request timeout:
|
||||
|
||||
```rust
|
||||
let result = client.call_tool_with_timeout(
|
||||
"exec",
|
||||
json!({"command": "long_build"}),
|
||||
Duration::from_secs(300) // 5 minutes
|
||||
).await?;
|
||||
```
|
||||
|
||||
### Connection Timeout
|
||||
|
||||
Set connection-level timeout:
|
||||
|
||||
```rust
|
||||
let client = Client::connect_with_options("zap://localhost:9999", Options {
|
||||
connect_timeout: Duration::from_secs(10),
|
||||
request_timeout: Duration::from_secs(30),
|
||||
}).await?;
|
||||
```
|
||||
|
||||
### Server-Side Timeout
|
||||
|
||||
Servers may enforce their own timeouts:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -32001,
|
||||
"message": "Request timed out after 30000ms"
|
||||
}
|
||||
```
|
||||
|
||||
## Keep-Alive
|
||||
|
||||
ZAP connections support keep-alive pings:
|
||||
|
||||
### Ping (MsgType: `0xE0`)
|
||||
|
||||
```
|
||||
Empty payload
|
||||
```
|
||||
|
||||
### Pong (MsgType: `0xE1`)
|
||||
|
||||
```
|
||||
Empty payload
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
// Client sends periodic pings
|
||||
let ping_interval = Duration::from_secs(30);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(ping_interval).await;
|
||||
if client.ping().await.is_err() {
|
||||
// Connection lost
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Reconnection
|
||||
|
||||
Clients should handle disconnections gracefully:
|
||||
|
||||
```rust
|
||||
async fn resilient_client() -> Result<Client> {
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
match Client::connect("zap://localhost:9999").await {
|
||||
Ok(client) => return Ok(client),
|
||||
Err(e) => {
|
||||
attempts += 1;
|
||||
if attempts >= 5 {
|
||||
return Err(e);
|
||||
}
|
||||
let backoff = Duration::from_millis(100 * 2u64.pow(attempts));
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Version Negotiation
|
||||
|
||||
Future versions may negotiate capabilities during handshake:
|
||||
|
||||
```json
|
||||
// Init with version preferences
|
||||
{
|
||||
"name": "my-agent",
|
||||
"version": "1.0.0",
|
||||
"protocol_versions": ["1.0", "1.1"],
|
||||
"features": ["streaming", "batching"]
|
||||
}
|
||||
|
||||
// InitAck with negotiated version
|
||||
{
|
||||
"name": "zap-gateway",
|
||||
"version": "0.2.0",
|
||||
"protocol_version": "1.1",
|
||||
"features": ["streaming"] // Only streaming supported
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Check Response IDs
|
||||
|
||||
```rust
|
||||
// Good
|
||||
let response = client.recv_response().await?;
|
||||
assert_eq!(response.id, request.id);
|
||||
|
||||
// Bad - assuming order
|
||||
let response = client.recv_response().await?;
|
||||
// What if responses are out of order?
|
||||
```
|
||||
|
||||
### 2. Handle Partial Failures
|
||||
|
||||
```rust
|
||||
let batch_result = client.batch(requests).await?;
|
||||
for response in batch_result.responses {
|
||||
if let Some(error) = response.error {
|
||||
log::warn!("Request {} failed: {}", response.id, error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use Appropriate Timeouts
|
||||
|
||||
```rust
|
||||
// Short timeout for simple operations
|
||||
let status = client.call_tool_with_timeout(
|
||||
"git_status", json!({}),
|
||||
Duration::from_secs(5)
|
||||
).await?;
|
||||
|
||||
// Long timeout for builds
|
||||
let build = client.call_tool_with_timeout(
|
||||
"build", json!({"release": true}),
|
||||
Duration::from_secs(600)
|
||||
).await?;
|
||||
```
|
||||
|
||||
### 4. Pipeline Independent Requests
|
||||
|
||||
```rust
|
||||
// Good - parallel file reads
|
||||
let files = ["a.rs", "b.rs", "c.rs"];
|
||||
let futures: Vec<_> = files.iter()
|
||||
.map(|f| client.call_tool("read_file", json!({"path": f})))
|
||||
.collect();
|
||||
let results = futures::future::join_all(futures).await;
|
||||
|
||||
// Bad - sequential reads
|
||||
for file in files {
|
||||
let result = client.call_tool("read_file", json!({"path": file})).await?;
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Security](security.md): Permission checking and sandboxing
|
||||
- [Tool Reference](tools.md): Complete API documentation
|
||||
@@ -0,0 +1,492 @@
|
||||
# Schema Definition Language
|
||||
|
||||
ZAP uses typed schemas to define tool interfaces. Unlike JSON Schema (used by MCP), ZAP schemas are native to each language, providing compile-time type safety and zero-cost abstraction.
|
||||
|
||||
## Overview
|
||||
|
||||
Every ZAP tool has two associated types:
|
||||
- **Args**: Input parameters for the tool
|
||||
- **Result**: Output returned by the tool
|
||||
|
||||
These types are defined in the implementation language (Rust, Python, Go, etc.) and automatically generate compatible JSON Schema for MCP interoperability.
|
||||
|
||||
## Type System
|
||||
|
||||
### Primitive Types
|
||||
|
||||
| ZAP Type | Rust | Python | Go | JSON Schema |
|
||||
|----------|------|--------|-----|-------------|
|
||||
| `bool` | `bool` | `bool` | `bool` | `boolean` |
|
||||
| `u8` | `u8` | `int` | `uint8` | `integer` |
|
||||
| `u32` | `u32` | `int` | `uint32` | `integer` |
|
||||
| `u64` | `u64` | `int` | `uint64` | `integer` |
|
||||
| `i32` | `i32` | `int` | `int32` | `integer` |
|
||||
| `i64` | `i64` | `int` | `int64` | `integer` |
|
||||
| `f32` | `f32` | `float` | `float32` | `number` |
|
||||
| `f64` | `f64` | `float` | `float64` | `number` |
|
||||
| `string` | `String` | `str` | `string` | `string` |
|
||||
| `bytes` | `Vec<u8>` | `bytes` | `[]byte` | `string` (base64) |
|
||||
|
||||
### Compound Types
|
||||
|
||||
#### Optional Fields
|
||||
|
||||
Optional fields may be omitted from requests:
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ReadFileArgs {
|
||||
pub path: String, // Required
|
||||
pub offset: Option<u64>, // Optional
|
||||
pub limit: Option<u64>, // Optional
|
||||
pub encoding: Option<String>, // Optional
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# Python
|
||||
@dataclass
|
||||
class ReadFileArgs:
|
||||
path: str # Required
|
||||
offset: int | None = None # Optional
|
||||
limit: int | None = None # Optional
|
||||
encoding: str | None = None # Optional
|
||||
```
|
||||
|
||||
```go
|
||||
// Go
|
||||
type ReadFileArgs struct {
|
||||
Path string `json:"path"` // Required
|
||||
Offset *uint64 `json:"offset,omitempty"` // Optional
|
||||
Limit *uint64 `json:"limit,omitempty"` // Optional
|
||||
Encoding *string `json:"encoding,omitempty"` // Optional
|
||||
}
|
||||
```
|
||||
|
||||
#### Lists
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
pub struct GlobArgs {
|
||||
pub patterns: Vec<String>, // List of strings
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# Python
|
||||
@dataclass
|
||||
class GlobArgs:
|
||||
patterns: list[str] # List of strings
|
||||
```
|
||||
|
||||
#### Maps / Dictionaries
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
pub struct ExecArgs {
|
||||
pub command: String,
|
||||
pub env: Option<HashMap<String, String>>, // String -> String map
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# Python
|
||||
@dataclass
|
||||
class ExecArgs:
|
||||
command: str
|
||||
env: dict[str, str] | None = None # String -> String map
|
||||
```
|
||||
|
||||
#### Enumerations
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MouseButton {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MouseClickArgs {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub button: MouseButton,
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# Python
|
||||
from enum import Enum
|
||||
|
||||
class MouseButton(str, Enum):
|
||||
LEFT = "left"
|
||||
RIGHT = "right"
|
||||
MIDDLE = "middle"
|
||||
|
||||
@dataclass
|
||||
class MouseClickArgs:
|
||||
x: int
|
||||
y: int
|
||||
button: MouseButton
|
||||
```
|
||||
|
||||
#### Nested Structures
|
||||
|
||||
```rust
|
||||
// Rust
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Region {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ScreenshotArgs {
|
||||
pub region: Option<Region>,
|
||||
pub format: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### Union Types (Tagged)
|
||||
|
||||
```rust
|
||||
// Rust - Tagged union with serde
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum Transport {
|
||||
Stdio {
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
},
|
||||
Zap {
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
JSON representation:
|
||||
```json
|
||||
// Stdio variant
|
||||
{ "type": "stdio", "command": "mcp-server-fs", "args": ["/home"] }
|
||||
|
||||
// Http variant
|
||||
{ "type": "http", "url": "https://api.example.com" }
|
||||
|
||||
// Zap variant
|
||||
{ "type": "zap", "url": "zap://localhost:9999" }
|
||||
```
|
||||
|
||||
## Defining Tool Schemas
|
||||
|
||||
### Complete Tool Definition (Rust)
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Arguments for the read_file tool
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadFileArgs {
|
||||
/// Path to the file to read
|
||||
pub path: String,
|
||||
|
||||
/// Byte offset to start reading from (default: 0)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<u64>,
|
||||
|
||||
/// Maximum number of bytes to read (default: entire file)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u64>,
|
||||
|
||||
/// Character encoding (default: "utf-8")
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encoding: Option<String>,
|
||||
}
|
||||
|
||||
/// Result from the read_file tool
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadFileResult {
|
||||
/// File content as string (if text encoding)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
|
||||
/// File content as base64 (if binary)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<String>,
|
||||
|
||||
/// MIME type of the content
|
||||
pub mime_type: String,
|
||||
|
||||
/// Total file size in bytes
|
||||
pub size: u64,
|
||||
}
|
||||
```
|
||||
|
||||
### Complete Tool Definition (Python)
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class ReadFileArgs:
|
||||
"""Arguments for the read_file tool."""
|
||||
|
||||
path: str
|
||||
"""Path to the file to read."""
|
||||
|
||||
offset: Optional[int] = None
|
||||
"""Byte offset to start reading from (default: 0)."""
|
||||
|
||||
limit: Optional[int] = None
|
||||
"""Maximum number of bytes to read (default: entire file)."""
|
||||
|
||||
encoding: Optional[str] = None
|
||||
"""Character encoding (default: 'utf-8')."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReadFileResult:
|
||||
"""Result from the read_file tool."""
|
||||
|
||||
mime_type: str
|
||||
"""MIME type of the content."""
|
||||
|
||||
size: int
|
||||
"""Total file size in bytes."""
|
||||
|
||||
content: Optional[str] = None
|
||||
"""File content as string (if text encoding)."""
|
||||
|
||||
data: Optional[str] = None
|
||||
"""File content as base64 (if binary)."""
|
||||
```
|
||||
|
||||
## Schema Evolution
|
||||
|
||||
ZAP schemas are designed for forward and backward compatibility.
|
||||
|
||||
### Adding Optional Fields (Safe)
|
||||
|
||||
```rust
|
||||
// Version 1
|
||||
pub struct ReadFileArgs {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
// Version 2 - Added optional field (backward compatible)
|
||||
pub struct ReadFileArgs {
|
||||
pub path: String,
|
||||
pub encoding: Option<String>, // NEW: Old clients will omit this
|
||||
}
|
||||
```
|
||||
|
||||
Old clients continue to work because the new field is optional.
|
||||
|
||||
### Adding Required Fields (Breaking)
|
||||
|
||||
```rust
|
||||
// Version 1
|
||||
pub struct ReadFileArgs {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
// Version 2 - Added required field (BREAKING CHANGE)
|
||||
pub struct ReadFileArgs {
|
||||
pub path: String,
|
||||
pub workspace: String, // BREAKING: Old clients don't send this
|
||||
}
|
||||
```
|
||||
|
||||
**Mitigation**: Use `#[serde(default)]` with a sensible default:
|
||||
|
||||
```rust
|
||||
pub struct ReadFileArgs {
|
||||
pub path: String,
|
||||
#[serde(default = "default_workspace")]
|
||||
pub workspace: String,
|
||||
}
|
||||
|
||||
fn default_workspace() -> String {
|
||||
".".to_string()
|
||||
}
|
||||
```
|
||||
|
||||
### Removing Fields (Safe for Optional)
|
||||
|
||||
Removing optional fields is safe - old values are simply ignored.
|
||||
|
||||
### Renaming Fields (Breaking)
|
||||
|
||||
Use `#[serde(alias)]` for backward compatibility:
|
||||
|
||||
```rust
|
||||
pub struct ReadFileArgs {
|
||||
#[serde(alias = "file_path")] // Accept old name too
|
||||
pub path: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Changing Types (Breaking)
|
||||
|
||||
Type changes are always breaking. Use a new field instead:
|
||||
|
||||
```rust
|
||||
// Version 1
|
||||
pub struct LimitArgs {
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
// Version 2 - Support both for compatibility
|
||||
pub struct LimitArgs {
|
||||
#[serde(alias = "limit")]
|
||||
pub limit_bytes: u64, // Renamed with new type
|
||||
}
|
||||
```
|
||||
|
||||
## JSON Schema Generation
|
||||
|
||||
ZAP can generate MCP-compatible JSON Schema for interoperability:
|
||||
|
||||
```rust
|
||||
use schemars::JsonSchema;
|
||||
|
||||
#[derive(JsonSchema, Serialize, Deserialize)]
|
||||
pub struct ReadFileArgs {
|
||||
/// Path to the file to read
|
||||
pub path: String,
|
||||
/// Maximum bytes to read
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
// Generate JSON Schema
|
||||
let schema = schemars::schema_for!(ReadFileArgs);
|
||||
println!("{}", serde_json::to_string_pretty(&schema)?);
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "ReadFileArgs",
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to read"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0,
|
||||
"description": "Maximum bytes to read"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Categories
|
||||
|
||||
ZAP organizes tools into 14 categories:
|
||||
|
||||
| Category | Module | Description |
|
||||
|----------|--------|-------------|
|
||||
| Computer/OS | `window`, `input`, `screen`, `process`, `filesystem`, `packaging` | Desktop automation |
|
||||
| Browser | `browser` | Web automation |
|
||||
| Vision | `vision` | UI understanding, OCR |
|
||||
| LSP/IDE | `lsp`, `project`, `codemod` | Language services |
|
||||
| VCS | `vcs` | Git operations |
|
||||
| Build/Test | `build` | Compilation, testing |
|
||||
| Debug | `debug` | Debugging, profiling |
|
||||
| Containers | `container` | Docker, Kubernetes |
|
||||
| Cloud | `cloud` | Infrastructure |
|
||||
| Network | `network` | HTTP, SSH |
|
||||
| Data | `data` | Databases |
|
||||
| Security | `security` | Scanning, signing |
|
||||
| Knowledge | `knowledge` | Search, embeddings |
|
||||
| Plan | `plan` | Agent orchestration |
|
||||
|
||||
See [Tool Reference](tools.md) for complete API documentation.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Descriptive Field Names
|
||||
|
||||
```rust
|
||||
// Good
|
||||
pub struct GrepArgs {
|
||||
pub pattern: String,
|
||||
pub path: String,
|
||||
pub ignore_case: Option<bool>,
|
||||
}
|
||||
|
||||
// Bad
|
||||
pub struct GrepArgs {
|
||||
pub p: String,
|
||||
pub dir: String,
|
||||
pub i: Option<bool>,
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Provide Sensible Defaults
|
||||
|
||||
```rust
|
||||
#[derive(Default)]
|
||||
pub struct ExecArgs {
|
||||
pub command: String,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
30_000 // 30 seconds
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Use Enums for Fixed Choices
|
||||
|
||||
```rust
|
||||
// Good
|
||||
pub enum OutputFormat {
|
||||
Json,
|
||||
Yaml,
|
||||
Toml,
|
||||
}
|
||||
|
||||
// Bad
|
||||
pub format: String, // What values are valid?
|
||||
```
|
||||
|
||||
### 4. Document Everything
|
||||
|
||||
```rust
|
||||
/// Arguments for file search operations.
|
||||
///
|
||||
/// Searches for files matching a glob pattern within a directory tree.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GlobArgs {
|
||||
/// Glob pattern to match (e.g., "**/*.rs", "src/*.py")
|
||||
pub pattern: String,
|
||||
|
||||
/// Base directory for search (default: current working directory)
|
||||
pub base_path: Option<String>,
|
||||
|
||||
/// Maximum number of results to return (default: unlimited)
|
||||
pub max_results: Option<u32>,
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Wire Format](encoding.md): How schemas are encoded on the wire
|
||||
- [RPC Protocol](rpc.md): Request/response patterns
|
||||
- [Tool Reference](tools.md): All built-in tool schemas
|
||||
@@ -0,0 +1,575 @@
|
||||
# Security Model
|
||||
|
||||
ZAP provides a comprehensive security model with human-in-the-loop approval policies and sandboxing. This ensures AI agents operate within defined boundaries while remaining productive.
|
||||
|
||||
## Overview
|
||||
|
||||
ZAP security has two orthogonal dimensions:
|
||||
|
||||
1. **AskForApproval**: When to pause and ask a human for permission
|
||||
2. **SandboxPolicy**: What operations are physically allowed
|
||||
|
||||
```
|
||||
AskForApproval
|
||||
|
|
||||
Never -------- OnFailure -------- OnRequest -------- UnlessTrusted
|
||||
|
|
||||
|
|
||||
SandboxPolicy |
|
||||
----------------+---------------------------------->
|
||||
| |
|
||||
DangerFull WorkspaceWrite ReadOnly
|
||||
```
|
||||
|
||||
## AskForApproval Policies
|
||||
|
||||
The `AskForApproval` enum determines when the agent should pause and request human confirmation:
|
||||
|
||||
### Never
|
||||
|
||||
```rust
|
||||
AskForApproval::Never
|
||||
```
|
||||
|
||||
- **Behavior**: Never ask for approval
|
||||
- **Use case**: Fully trusted automation, CI/CD pipelines
|
||||
- **Risk**: High - agent has full autonomy
|
||||
|
||||
```rust
|
||||
let ctx = ExecutorContext {
|
||||
approval_policy: AskForApproval::Never,
|
||||
..Default::default()
|
||||
};
|
||||
```
|
||||
|
||||
### OnFailure
|
||||
|
||||
```rust
|
||||
AskForApproval::OnFailure
|
||||
```
|
||||
|
||||
- **Behavior**: Only ask when an operation fails
|
||||
- **Use case**: Semi-autonomous operation with human recovery
|
||||
- **Risk**: Medium - failures trigger review
|
||||
|
||||
### OnRequest
|
||||
|
||||
```rust
|
||||
AskForApproval::OnRequest
|
||||
```
|
||||
|
||||
- **Behavior**: Model decides when to ask based on operation risk
|
||||
- **Use case**: Interactive development with AI assistance
|
||||
- **Risk**: Low-medium - risky operations flagged
|
||||
|
||||
```rust
|
||||
// Read operations auto-approved
|
||||
ctx.check_approval("read_file") // -> Allowed
|
||||
|
||||
// Write operations may require approval
|
||||
ctx.check_approval("write_file") // -> RequiresApproval("Operation 'write_file' may require approval")
|
||||
```
|
||||
|
||||
### UnlessTrusted (Default)
|
||||
|
||||
```rust
|
||||
AskForApproval::UnlessTrusted
|
||||
```
|
||||
|
||||
- **Behavior**: Ask for everything except explicitly trusted operations
|
||||
- **Use case**: Maximum safety for untrusted code or sensitive environments
|
||||
- **Risk**: Lowest - human approves most actions
|
||||
|
||||
## SandboxPolicy Modes
|
||||
|
||||
The `SandboxPolicy` enum determines what operations are physically permitted:
|
||||
|
||||
### DangerFullAccess
|
||||
|
||||
```rust
|
||||
SandboxPolicy::DangerFullAccess
|
||||
```
|
||||
|
||||
- **Filesystem**: Full read/write to entire system
|
||||
- **Network**: Unrestricted
|
||||
- **Processes**: Can spawn any process
|
||||
- **Use case**: Only for fully trusted environments
|
||||
|
||||
**Warning**: This bypasses all filesystem restrictions.
|
||||
|
||||
### ReadOnly
|
||||
|
||||
```rust
|
||||
SandboxPolicy::ReadOnly
|
||||
```
|
||||
|
||||
- **Filesystem**: Read anywhere, write nowhere
|
||||
- **Network**: Typically blocked
|
||||
- **Processes**: Limited to query-only commands
|
||||
- **Use case**: Safe code exploration, documentation
|
||||
|
||||
```rust
|
||||
let ctx = ExecutorContext {
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Allowed
|
||||
ctx.is_path_writable(&Path::new("/etc/passwd")); // false
|
||||
dispatcher.execute("read_file", json!({"path": "/etc/passwd"}), &ctx).await; // OK
|
||||
|
||||
// Blocked
|
||||
dispatcher.execute("write_file", json!({"path": "/tmp/test.txt", "content": "x"}), &ctx).await;
|
||||
// Error: Permission denied
|
||||
```
|
||||
|
||||
### WorkspaceWrite (Default)
|
||||
|
||||
```rust
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![PathBuf::from("/home/user/project")],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
allow_git_writes: true,
|
||||
}
|
||||
```
|
||||
|
||||
- **Filesystem**: Read anywhere, write only to specified directories
|
||||
- **Network**: Configurable
|
||||
- **Processes**: Allowed with restrictions
|
||||
- **Use case**: Typical development workflow
|
||||
|
||||
```rust
|
||||
let ctx = ExecutorContext {
|
||||
cwd: Some("/home/user/project".to_string()),
|
||||
sandbox_policy: SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: true,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
allow_git_writes: true,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Allowed - within workspace
|
||||
ctx.is_path_writable(&Path::new("/home/user/project/src/main.rs")); // true
|
||||
|
||||
// Blocked - outside workspace
|
||||
ctx.is_path_writable(&Path::new("/etc/passwd")); // false
|
||||
```
|
||||
|
||||
## Permission Levels
|
||||
|
||||
ZAP classifies operations into permission levels:
|
||||
|
||||
| Level | Description | Examples |
|
||||
|-------|-------------|----------|
|
||||
| `Read` | Information retrieval | `read_file`, `git_status`, `glob` |
|
||||
| `Write` | Data modification | `write_file`, `edit_file`, `git_commit` |
|
||||
| `Execute` | Process execution | `exec`, `build`, `test` |
|
||||
| `Admin` | System changes | `kill_process`, destructive operations |
|
||||
|
||||
### Automatic Classification
|
||||
|
||||
```rust
|
||||
// From permissions.rs
|
||||
pub fn operation_level(tool_name: &str) -> PermissionLevel {
|
||||
match tool_name {
|
||||
// Read operations
|
||||
"read_file" | "glob" | "grep" | "list_dir" | "git_status" | "git_diff"
|
||||
| "git_log" | "git_blame" | "list_processes" | "get_env" | "fetch_url"
|
||||
| "port_check" | "dns_lookup" | "cache_lookup" => PermissionLevel::Read,
|
||||
|
||||
// Write operations
|
||||
"write_file" | "edit_file" | "git_commit" | "git_branch" => PermissionLevel::Write,
|
||||
|
||||
// Execute operations
|
||||
"exec" | "build" | "test" | "lint" | "typecheck" | "http_request" => PermissionLevel::Execute,
|
||||
|
||||
// Admin operations
|
||||
"kill_process" => PermissionLevel::Admin,
|
||||
|
||||
// Planning tools (generally safe)
|
||||
"plan_intent" | "plan_route" | "plan_compose" | "audit_log" => PermissionLevel::Read,
|
||||
|
||||
// Default to execute (requires approval)
|
||||
_ => PermissionLevel::Execute,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Permission Check Flow
|
||||
|
||||
```
|
||||
+----------------+
|
||||
| Tool Call |
|
||||
+----------------+
|
||||
|
|
||||
v
|
||||
+-------------------------+
|
||||
| Get Permission Level |
|
||||
+-------------------------+
|
||||
|
|
||||
v
|
||||
+-------------------------+
|
||||
| Check AskForApproval |
|
||||
+-------------------------+
|
||||
|
|
||||
+-------------------------+-------------------------+
|
||||
| | |
|
||||
v v v
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| Never | | OnRequest | |UnlessTrust|
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| | |
|
||||
v v v
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| Allowed | |Read=Allow | |Read=Allow |
|
||||
+-----------+ |Write=Ask | |Other=Ask |
|
||||
+-----------+ +-----------+
|
||||
| |
|
||||
v v
|
||||
+-------------------------+
|
||||
| Check SandboxPolicy |
|
||||
+-------------------------+
|
||||
|
|
||||
+-------------------------+-------------------------+
|
||||
| | |
|
||||
v v v
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| FullAccess| |WorkspaceW | | ReadOnly |
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| | |
|
||||
v v v
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| Execute | |Path check | |Read only |
|
||||
+-----------+ +-----------+ +-----------+
|
||||
```
|
||||
|
||||
## Implementing Approval Requests
|
||||
|
||||
When an operation requires approval, the agent should pause and request human confirmation:
|
||||
|
||||
### Example: Human-in-the-Loop Approval
|
||||
|
||||
```rust
|
||||
use hanzo_zap::{ExecutorContext, PermissionResult};
|
||||
|
||||
async fn execute_with_approval(
|
||||
dispatcher: &ToolDispatcher,
|
||||
tool: &str,
|
||||
args: Value,
|
||||
ctx: &ExecutorContext,
|
||||
) -> Result<ToolResult> {
|
||||
// Check if approval is needed
|
||||
match ctx.check_approval(tool) {
|
||||
PermissionResult::Allowed => {
|
||||
// Execute directly
|
||||
dispatcher.execute(tool, args, ctx).await
|
||||
}
|
||||
PermissionResult::RequiresApproval(reason) => {
|
||||
// Show approval dialog
|
||||
if request_human_approval(tool, &args, &reason).await? {
|
||||
dispatcher.execute(tool, args, ctx).await
|
||||
} else {
|
||||
Err(Error::PermissionDenied(format!(
|
||||
"User denied approval for {}", tool
|
||||
)))
|
||||
}
|
||||
}
|
||||
PermissionResult::Denied(reason) => {
|
||||
Err(Error::PermissionDenied(reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_human_approval(tool: &str, args: &Value, reason: &str) -> Result<bool> {
|
||||
println!("\n=== Approval Required ===");
|
||||
println!("Tool: {}", tool);
|
||||
println!("Arguments: {}", serde_json::to_string_pretty(args)?);
|
||||
println!("Reason: {}", reason);
|
||||
println!("\nApprove? [y/N]: ");
|
||||
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
|
||||
Ok(input.trim().eq_ignore_ascii_case("y"))
|
||||
}
|
||||
```
|
||||
|
||||
### Example: GUI Approval Dialog
|
||||
|
||||
```python
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
def request_approval(tool: str, args: dict, reason: str) -> bool:
|
||||
"""Show a GUI dialog for approval."""
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
|
||||
message = f"""
|
||||
Tool: {tool}
|
||||
Arguments: {json.dumps(args, indent=2)}
|
||||
Reason: {reason}
|
||||
|
||||
Do you approve this operation?
|
||||
"""
|
||||
|
||||
result = messagebox.askyesno("Approval Required", message)
|
||||
root.destroy()
|
||||
return result
|
||||
```
|
||||
|
||||
## Path Validation
|
||||
|
||||
ZAP validates paths against the sandbox policy:
|
||||
|
||||
```rust
|
||||
pub fn is_path_writable(path: &Path, sandbox: &SandboxPolicy, cwd: &Path) -> bool {
|
||||
match sandbox {
|
||||
SandboxPolicy::DangerFullAccess => true,
|
||||
SandboxPolicy::ReadOnly => false,
|
||||
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
|
||||
// Check if under cwd
|
||||
if path.starts_with(cwd) {
|
||||
return true;
|
||||
}
|
||||
// Check additional writable roots
|
||||
writable_roots.iter().any(|root| path.starts_with(root))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Path Traversal Protection
|
||||
|
||||
ZAP prevents path traversal attacks:
|
||||
|
||||
```rust
|
||||
// These are blocked:
|
||||
"/home/user/project/../../../etc/passwd" // Canonicalizes to /etc/passwd
|
||||
"../../../etc/passwd" // Outside workspace
|
||||
"./../../etc/passwd" // Outside workspace
|
||||
|
||||
// Implementation
|
||||
fn validate_path(path: &str, ctx: &ExecutorContext) -> Result<PathBuf> {
|
||||
let canonical = path.canonicalize()?;
|
||||
if !ctx.is_path_writable(&canonical) {
|
||||
return Err(Error::PermissionDenied(format!(
|
||||
"Path {} is outside writable roots", canonical.display()
|
||||
)));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
```
|
||||
|
||||
## Network Access Control
|
||||
|
||||
The `WorkspaceWrite` policy includes network access control:
|
||||
|
||||
```rust
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: false, // Block all network operations
|
||||
..
|
||||
}
|
||||
```
|
||||
|
||||
When `network_access` is `false`:
|
||||
- HTTP requests are blocked
|
||||
- SSH connections are blocked
|
||||
- Port scanning is blocked
|
||||
- DNS lookups may be allowed (for error messages)
|
||||
|
||||
## Command Execution Security
|
||||
|
||||
Command execution (`exec` tool) has additional security layers:
|
||||
|
||||
### Allowed Commands (Read-only context)
|
||||
|
||||
```rust
|
||||
const SAFE_READ_COMMANDS: &[&str] = &[
|
||||
"ls", "cat", "head", "tail", "wc",
|
||||
"git status", "git log", "git diff",
|
||||
"cargo check", "cargo clippy",
|
||||
"npm list", "pip list",
|
||||
];
|
||||
```
|
||||
|
||||
### Blocked Commands (Always)
|
||||
|
||||
```rust
|
||||
const DANGEROUS_COMMANDS: &[&str] = &[
|
||||
"rm -rf /",
|
||||
"dd if=/dev/zero",
|
||||
":(){ :|:& };:", // Fork bomb
|
||||
"mkfs",
|
||||
"chmod -R 777 /",
|
||||
];
|
||||
```
|
||||
|
||||
### Command Validation
|
||||
|
||||
```rust
|
||||
fn validate_command(command: &str, ctx: &ExecutorContext) -> Result<()> {
|
||||
// Check for shell injection
|
||||
if contains_shell_metacharacters(command) {
|
||||
return Err(Error::SecurityViolation("Shell metacharacters not allowed"));
|
||||
}
|
||||
|
||||
// Check sandbox policy
|
||||
match &ctx.sandbox_policy {
|
||||
SandboxPolicy::ReadOnly => {
|
||||
if !is_read_only_command(command) {
|
||||
return Err(Error::PermissionDenied("Only read-only commands allowed"));
|
||||
}
|
||||
}
|
||||
SandboxPolicy::WorkspaceWrite { network_access, .. } => {
|
||||
if !network_access && is_network_command(command) {
|
||||
return Err(Error::PermissionDenied("Network access not allowed"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Audit Logging
|
||||
|
||||
ZAP logs all security-relevant events:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuditEvent {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub session_id: String,
|
||||
pub operation: String,
|
||||
pub tool: String,
|
||||
pub args_hash: String, // Hash of arguments (not full args for privacy)
|
||||
pub permission_level: PermissionLevel,
|
||||
pub result: AuditResult,
|
||||
pub approval: Option<ApprovalInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum AuditResult {
|
||||
Allowed,
|
||||
Denied { reason: String },
|
||||
Approved { by: String },
|
||||
Failed { error: String },
|
||||
}
|
||||
```
|
||||
|
||||
### Audit Log Example
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"timestamp": "2025-01-15T10:30:00Z",
|
||||
"session_id": "agent-001",
|
||||
"operation": "call_tool",
|
||||
"tool": "read_file",
|
||||
"args_hash": "sha256:abc123...",
|
||||
"permission_level": "read",
|
||||
"result": "allowed"
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-01-15T10:30:01Z",
|
||||
"session_id": "agent-001",
|
||||
"operation": "call_tool",
|
||||
"tool": "write_file",
|
||||
"args_hash": "sha256:def456...",
|
||||
"permission_level": "write",
|
||||
"result": {
|
||||
"approved": {
|
||||
"by": "[email protected]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use the Least Permissive Policy
|
||||
|
||||
```rust
|
||||
// Good - minimal permissions
|
||||
let ctx = ExecutorContext {
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
network_access: false,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Bad - too permissive
|
||||
let ctx = ExecutorContext {
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
..Default::default()
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Validate All Paths
|
||||
|
||||
```rust
|
||||
async fn read_file(path: &str, ctx: &ExecutorContext) -> Result<String> {
|
||||
// Always validate paths before operations
|
||||
let canonical = validate_path(path, ctx)?;
|
||||
tokio::fs::read_to_string(canonical).await
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Log Security Events
|
||||
|
||||
```rust
|
||||
fn log_security_event(event: &AuditEvent) {
|
||||
// Always log to persistent storage
|
||||
audit_logger.log(event);
|
||||
|
||||
// Alert on denied operations
|
||||
if matches!(event.result, AuditResult::Denied { .. }) {
|
||||
alert_security_team(event);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Implement Timeouts
|
||||
|
||||
```rust
|
||||
// Prevent resource exhaustion
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
dispatcher.execute(tool, args, ctx)
|
||||
).await??;
|
||||
```
|
||||
|
||||
### 5. Sanitize Error Messages
|
||||
|
||||
```rust
|
||||
// Don't leak sensitive paths in errors
|
||||
fn sanitize_error(e: Error, ctx: &ExecutorContext) -> Error {
|
||||
match e {
|
||||
Error::Io(io_err) => {
|
||||
// Remove absolute paths from error message
|
||||
Error::Io(std::io::Error::new(
|
||||
io_err.kind(),
|
||||
"File operation failed"
|
||||
))
|
||||
}
|
||||
_ => e
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Language Bindings](languages.md): Implement security in your language
|
||||
- [Tool Reference](tools.md): Security annotations per tool
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user