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:
2026-01-25 11:45:13 -08:00
parent 60a87c0680
commit 5eaeb1b2df
6 changed files with 3826 additions and 0 deletions
+400
View File
@@ -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
+763
View File
@@ -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
+543
View File
@@ -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
+492
View File
@@ -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
+575
View File
@@ -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