-
Notifications
You must be signed in to change notification settings - Fork 4.4k
03.2 Security Model
Relevant source files
The following files were used as context for generating this wiki page:
This document describes ZeroClaw's defense-in-depth security architecture, including network isolation, authentication mechanisms, authorization policies, execution sandboxing, and data protection. For gateway-specific API security, see Gateway API Reference. For runtime configuration of security policies, see Configuration File Reference.
ZeroClaw implements security at five distinct layers, ensuring that even if one layer is bypassed, subsequent defenses prevent exploitation.
ZeroClaw's security model follows a defense-in-depth strategy with five independent security layers. Each layer enforces specific controls, and all layers must be satisfied before an action is executed.
graph TB
Request[Incoming Request]
subgraph Layer1["Layer 1: Network Security"]
Bind["Bind Address Check<br/>localhost by default"]
Tunnel["Tunnel Requirement<br/>is_public_bind()"]
end
subgraph Layer2["Layer 2: Authentication"]
Pairing["PairingGuard<br/>try_pair()"]
Bearer["Bearer Token<br/>is_authenticated()"]
Allowlist["Channel Allowlist<br/>sender validation"]
end
subgraph Layer3["Layer 3: Authorization"]
Autonomy["SecurityPolicy<br/>can_act()"]
Workspace["workspace_only<br/>is_path_allowed()"]
Forbidden["forbidden_paths<br/>14 system dirs"]
Symlink["Symlink Detection<br/>canonicalize check"]
Commands["allowed_commands"]
end
subgraph Layer4["Layer 4: Execution Isolation"]
Runtime["RuntimeAdapter<br/>Native vs Docker"]
Docker["Docker Sandbox<br/>network=none<br/>read_only_rootfs"]
end
subgraph Layer5["Layer 5: Data Protection"]
SecretStore["SecretStore<br/>ChaCha20Poly1305"]
Scrub["scrub_credentials()<br/>SENSITIVE_KV_REGEX"]
Hash["SHA-256 Token Hash"]
end
Request --> Bind
Bind --> Tunnel
Tunnel --> Pairing
Pairing --> Bearer
Bearer --> Allowlist
Allowlist --> Autonomy
Autonomy --> Workspace
Workspace --> Forbidden
Forbidden --> Symlink
Symlink --> Commands
Commands --> Runtime
Runtime --> Docker
Docker --> SecretStore
SecretStore --> Scrub
Scrub --> Hash
Hash --> Execute[Execute Action]
Sources: README.md:380-407, src/gateway/mod.rs:1-9, src/agent/loop_.rs:25-77
The gateway binds to 127.0.0.1 by default, preventing direct internet exposure. Public binds (0.0.0.0) are rejected unless a tunnel is configured or explicitly allowed.
| Configuration | Default | Description |
|---|---|---|
gateway.host |
"127.0.0.1" |
Host address to bind |
gateway.allow_public_bind |
false |
Allow binding to public addresses without tunnel |
The is_public_bind() function validates the host address:
flowchart TD
Start["Gateway Start"]
CheckHost{"is_public_bind(host)?"}
CheckTunnel{"tunnel.provider<br/>== 'none'?"}
CheckFlag{"allow_public_bind<br/>== true?"}
Accept["Accept Bind"]
Reject["Reject with Error"]
Start --> CheckHost
CheckHost -->|"localhost"| Accept
CheckHost -->|"public"| CheckTunnel
CheckTunnel -->|"yes"| CheckFlag
CheckTunnel -->|"no"| Accept
CheckFlag -->|"yes"| Accept
CheckFlag -->|"no"| Reject
Sources: src/gateway/mod.rs:284-292, src/security/pairing.rs:224-230
When binding to a public address, a tunnel must be configured. Supported tunnel providers include:
-
Cloudflare Tunnel (
tunnel.provider = "cloudflare") -
Tailscale Funnel (
tunnel.provider = "tailscale") -
ngrok (
tunnel.provider = "ngrok") -
Custom (
tunnel.provider = "custom")
Sources: src/gateway/mod.rs:415-431, README.md:390-391
The gateway generates a one-time 6-digit pairing code on startup when no paired tokens exist. Clients must exchange this code for a bearer token via POST /pair.
sequenceDiagram
participant Client
participant Gateway
participant PairingGuard
participant Config
Gateway->>PairingGuard: new(require_pairing, tokens)
PairingGuard->>PairingGuard: generate_code()
Gateway->>Client: Display pairing code in terminal
Client->>Gateway: POST /pair<br/>X-Pairing-Code: 123456
Gateway->>PairingGuard: try_pair(code)
PairingGuard->>PairingGuard: constant_time_eq(code, expected)
PairingGuard->>PairingGuard: generate_token()
PairingGuard->>PairingGuard: hash_token(token)
PairingGuard-->>Gateway: Ok(Some(token))
Gateway->>Config: Persist hashed token
Gateway-->>Client: {"token": "zc_..."}
Client->>Gateway: POST /webhook<br/>Authorization: Bearer zc_...
Gateway->>PairingGuard: is_authenticated(token)
PairingGuard->>PairingGuard: hash_token(token)
PairingGuard->>PairingGuard: Check hash in paired_tokens
PairingGuard-->>Gateway: true
Gateway-->>Client: Process request
Sources: src/security/pairing.rs:26-151, src/gateway/mod.rs:544-604
| Component | Implementation | Purpose |
|---|---|---|
| Pairing Code Generation |
generate_code() using rejection sampling |
Generates 6-digit code with uniform distribution |
| Token Generation |
generate_token() with 32-byte random payload |
256-bit entropy bearer token |
| Token Storage | SHA-256 hash | Prevents plaintext exposure in config files |
| Brute Force Protection | Lockout after 5 failed attempts for 5 minutes | Prevents code guessing attacks |
| Comparison | constant_time_eq() |
Prevents timing attacks |
Sources: src/security/pairing.rs:154-175, src/security/pairing.rs:177-188, src/security/pairing.rs:205-221
All communication channels enforce deny-by-default allowlists. Empty allowlist = deny all messages.
flowchart TD
Message["Channel Message"]
CheckAllowlist{"allowed_users<br/>empty?"}
DenyAll["Deny: Empty allowlist"]
CheckWildcard{"allowed_users<br/>contains '*'?"}
AcceptAll["Accept: Wildcard"]
CheckSender{"sender in<br/>allowed_users?"}
Accept["Accept Message"]
Deny["Deny: Not in allowlist"]
Message --> CheckAllowlist
CheckAllowlist -->|"yes"| DenyAll
CheckAllowlist -->|"no"| CheckWildcard
CheckWildcard -->|"yes"| AcceptAll
CheckWildcard -->|"no"| CheckSender
CheckSender -->|"yes"| Accept
CheckSender -->|"no"| Deny
Channel-specific allowlist configuration:
| Channel | Config Key | Allowlist Field | Identity Format |
|---|---|---|---|
| Telegram | channels_config.telegram |
allowed_users |
Username (no @) or numeric user ID |
| Discord | channels_config.discord |
allowed_users |
Discord user ID |
| Slack | channels_config.slack |
allowed_users |
Slack member ID (starts with U) |
channels_config.whatsapp |
allowed_numbers |
E.164 phone number |
Sources: README.md:395-434, src/channels/telegram.rs, src/channels/discord.rs
The gateway supports an optional X-Webhook-Secret header for webhook endpoints. Secrets are SHA-256 hashed before comparison to prevent timing attacks.
Sources: src/gateway/mod.rs:655-670, src/gateway/mod.rs:56-61
The SecurityPolicy enforces three autonomy levels that control agent behavior:
| Level | Writes Allowed | Tool Execution | Approval Required |
|---|---|---|---|
readonly |
❌ | Read-only tools only | N/A |
supervised |
✅ | All tools | ✅ (via approval callback) |
full |
✅ | All tools | ❌ |
flowchart TD
ToolCall["Tool Call Request"]
CheckLevel{"autonomy.level"}
ReadOnly["readonly"]
Supervised["supervised"]
Full["full"]
IsWrite{"Tool modifies<br/>state?"}
DenyWrite["Deny: Read-only mode"]
NeedsApproval{"approval<br/>callback exists?"}
RequestApproval["Request Approval"]
ApprovalResponse{"Approved?"}
Execute["Execute Tool"]
Deny["Deny Execution"]
ToolCall --> CheckLevel
CheckLevel --> ReadOnly
CheckLevel --> Supervised
CheckLevel --> Full
ReadOnly --> IsWrite
IsWrite -->|"yes"| DenyWrite
IsWrite -->|"no"| Execute
Supervised --> NeedsApproval
NeedsApproval -->|"yes"| RequestApproval
NeedsApproval -->|"no"| Execute
RequestApproval --> ApprovalResponse
ApprovalResponse -->|"yes"| Execute
ApprovalResponse -->|"no"| Deny
Full --> Execute
Sources: src/security/mod.rs, src/agent/loop_.rs
When workspace_only = true, all file operations are restricted to the workspace directory. Path validation uses canonicalization to prevent directory traversal and symlink escape attacks.
flowchart TD
FilePath["File Path Request"]
Canonical["canonicalize(path)"]
CheckPrefix{"resolved_path<br/>starts_with<br/>workspace_dir?"}
CheckForbidden{"Path in<br/>forbidden_paths?"}
CheckSymlink{"Symlink escape<br/>detected?"}
Allow["Allow Access"]
DenyScope["Deny: Outside workspace"]
DenyForbidden["Deny: Forbidden directory"]
DenySymlink["Deny: Symlink escape"]
FilePath --> Canonical
Canonical --> CheckPrefix
CheckPrefix -->|"yes"| CheckForbidden
CheckPrefix -->|"no"| DenyScope
CheckForbidden -->|"no"| CheckSymlink
CheckForbidden -->|"yes"| DenyForbidden
CheckSymlink -->|"no"| Allow
CheckSymlink -->|"yes"| DenySymlink
Sources: README.md:389, src/tools/file.rs
ZeroClaw blocks access to 14 system directories and 4 sensitive dotfiles regardless of workspace scoping:
| Category | Paths |
|---|---|
| System Directories |
/etc, /root, /proc, /sys, /dev, /boot, /var/lib, /var/run, /usr/sbin, /usr/bin, /sbin, /bin, /lib, /lib64
|
| Sensitive Dotfiles |
~/.ssh, ~/.gnupg, ~/.aws, ~/.config/gcloud
|
Sources: README.md:389
When allowed_commands is configured, the shell tool only permits execution of listed commands:
[autonomy]
allowed_commands = ["git", "npm", "cargo", "ls", "cat", "grep"]Sources: README.md:534
ZeroClaw supports two execution runtimes with different isolation guarantees:
graph LR
ToolExec["Tool Execution"]
RuntimeChoice{"runtime.kind"}
Native["NativeRuntime<br/>Direct subprocess"]
Docker["DockerRuntime<br/>Container isolation"]
NativeExec["std::process::Command"]
DockerExec["docker run<br/>--network=none<br/>--read-only"]
ToolExec --> RuntimeChoice
RuntimeChoice -->|"'native'"| Native
RuntimeChoice -->|"'docker'"| Docker
Native --> NativeExec
Docker --> DockerExec
Sources: README.md:538-547
The Docker runtime provides strong isolation with the following default constraints:
| Parameter | Default | Purpose |
|---|---|---|
runtime.docker.image |
"alpine:3.20" |
Minimal container base image |
runtime.docker.network |
"none" |
Network isolation (prevents SSRF) |
runtime.docker.read_only_rootfs |
true |
Immutable root filesystem |
runtime.docker.memory_limit_mb |
512 |
Memory limit |
runtime.docker.cpu_limit |
1.0 |
CPU cores limit |
runtime.docker.mount_workspace |
true |
Mount workspace at /workspace
|
Sources: README.md:540-547
The SecretStore encrypts API keys and tokens using ChaCha20-Poly1305 AEAD (Authenticated Encryption with Associated Data). Secrets are never stored in plaintext in configuration files.
flowchart TD
PlaintextSecret["Plaintext Secret"]
CheckEnabled{"secrets.encrypt<br/>== true?"}
ReturnPlaintext["Return as-is"]
LoadKey["load_or_create_key()"]
KeyExists{"Key file<br/>exists?"}
GenerateKey["Generate 32-byte<br/>random key"]
WriteKey["Write to<br/>.secret_key"]
SetPerms["chmod 0600"]
ReadKey["Read existing key"]
GenerateNonce["ChaCha20Poly1305<br/>generate_nonce()"]
Encrypt["cipher.encrypt(nonce, plaintext)"]
PrependNonce["Prepend nonce to ciphertext"]
HexEncode["hex_encode(blob)"]
AddPrefix["Add 'enc2:' prefix"]
Store["Store in config.toml"]
PlaintextSecret --> CheckEnabled
CheckEnabled -->|"no"| ReturnPlaintext
CheckEnabled -->|"yes"| LoadKey
LoadKey --> KeyExists
KeyExists -->|"no"| GenerateKey
KeyExists -->|"yes"| ReadKey
GenerateKey --> WriteKey
WriteKey --> SetPerms
SetPerms --> GenerateNonce
ReadKey --> GenerateNonce
GenerateNonce --> Encrypt
Encrypt --> PrependNonce
PrependNonce --> HexEncode
HexEncode --> AddPrefix
AddPrefix --> Store
Encryption Format: enc2:<hex(nonce ‖ ciphertext ‖ tag)>
- Nonce: 12 bytes (unique per encryption)
- Ciphertext: Variable length
- Authentication Tag: 16 bytes (Poly1305)
Sources: src/security/secrets.rs:1-227, src/security/secrets.rs:53-76
Tool output is automatically scrubbed before being sent to the LLM to prevent accidental credential leakage. The scrub_credentials() function detects and redacts sensitive patterns:
flowchart TD
ToolOutput["Tool Output"]
ScanPatterns["SENSITIVE_KV_REGEX<br/>scan for key=value patterns"]
MatchFound{"Pattern<br/>matches?"}
ExtractKey["Extract key name"]
ExtractValue["Extract value"]
CheckLength{"value.len()<br/>> 4?"}
TakePrefix["prefix = value[..4]"]
NoPrefix["prefix = ''"]
BuildRedacted["Build redacted string:<br/>key: prefix*[REDACTED]"]
Replace["Replace in output"]
Continue{"More<br/>matches?"}
Return["Return scrubbed output"]
ToolOutput --> ScanPatterns
ScanPatterns --> MatchFound
MatchFound -->|"yes"| ExtractKey
MatchFound -->|"no"| Return
ExtractKey --> ExtractValue
ExtractValue --> CheckLength
CheckLength -->|"yes"| TakePrefix
CheckLength -->|"no"| NoPrefix
TakePrefix --> BuildRedacted
NoPrefix --> BuildRedacted
BuildRedacted --> Replace
Replace --> Continue
Continue -->|"yes"| MatchFound
Continue -->|"no"| Return
Redaction Patterns:
| Pattern | Regex | Example Redaction |
|---|---|---|
| Token | (?i)token\s*[:=]\s*"([^"]{8,})" |
"token": "sk-a*[REDACTED]" |
| API Key | (?i)api[_-]?key\s*[:=]\s*"([^"]{8,})" |
"api_key": "AIza*[REDACTED]" |
| Password | (?i)password\s*[:=]\s*"([^"]{8,})" |
"password": "pass*[REDACTED]" |
| Secret | (?i)secret\s*[:=]\s*"([^"]{8,})" |
"secret": "sec_*[REDACTED]" |
| Bearer | (?i)bearer\s*[:=]\s*"([^"]{8,})" |
"bearer": "eyjh*[REDACTED]" |
Sources: src/agent/loop_.rs:25-77, src/agent/loop_.rs:38-40
Bearer tokens and pairing secrets are stored as SHA-256 hashes to prevent plaintext exposure. The gateway never stores raw tokens in configuration files.
Sources: src/security/pairing.rs:191-193, src/gateway/mod.rs:606-612
# Gateway Security
[gateway]
port = 3000
host = "127.0.0.1" # Localhost-only binding
require_pairing = true # Require pairing code exchange
allow_public_bind = false # Deny public bind without tunnel
trust_forwarded_headers = false # Trust X-Forwarded-For headers
pair_rate_limit_per_minute = 10 # Rate limit for pairing attempts
webhook_rate_limit_per_minute = 60 # Rate limit for webhook requests
rate_limit_max_keys = 10000 # Max distinct IPs tracked
idempotency_ttl_secs = 3600 # Idempotency key TTL
idempotency_max_keys = 10000 # Max idempotency keys tracked
paired_tokens = [] # SHA-256 hashed bearer tokens
# Authorization
[autonomy]
level = "supervised" # "readonly", "supervised", "full"
workspace_only = true # Restrict file ops to workspace
allowed_commands = ["git", "npm", "cargo"]
forbidden_paths = ["/etc", "/root"]
# Execution Isolation
[runtime]
kind = "docker" # "native" or "docker"
[runtime.docker]
image = "alpine:3.20"
network = "none" # No network access
memory_limit_mb = 512
cpu_limit = 1.0
read_only_rootfs = true # Immutable container filesystem
mount_workspace = true
# Data Protection
[secrets]
encrypt = true # ChaCha20-Poly1305 encryption
# Tunnel (required for public bind)
[tunnel]
provider = "none" # "cloudflare", "tailscale", "ngrok", "custom"
# Channel Security
[channels_config.telegram]
allowed_users = [] # Deny-by-default (empty = deny all)
[channels_config.webhook]
secret = "webhook_secret_here" # SHA-256 hashed for comparison
[channels_config.whatsapp]
app_secret = "meta_app_secret" # For X-Hub-Signature-256 verificationSources: README.md:496-599, src/gateway/mod.rs:37-46
ZeroClaw passes all items from the community security checklist:
| # | Item | Status | Implementation |
|---|---|---|---|
| 1 | Gateway not publicly exposed | ✅ |
is_public_bind() validation |
| 2 | Pairing required | ✅ |
PairingGuard with 6-digit code |
| 3 | Filesystem scoped | ✅ |
workspace_only + 14 blocked dirs |
| 4 | Access via tunnel only | ✅ | Tunnel enforcement on public bind |
Sources: README.md:384-392
The complete security flow from request to execution:
sequenceDiagram
participant User
participant Gateway
participant PairingGuard
participant SecurityPolicy
participant RuntimeAdapter
participant SecretStore
Note over User,SecretStore: Layer 1: Network Security
User->>Gateway: Request (localhost only)
Gateway->>Gateway: is_public_bind(host)?
Note over User,SecretStore: Layer 2: Authentication
Gateway->>PairingGuard: is_authenticated(token)
PairingGuard->>PairingGuard: constant_time_eq(hash(token))
PairingGuard-->>Gateway: Authenticated
Note over User,SecretStore: Layer 3: Authorization
Gateway->>SecurityPolicy: can_act(action)
SecurityPolicy->>SecurityPolicy: Check autonomy level
SecurityPolicy->>SecurityPolicy: Validate workspace_only
SecurityPolicy->>SecurityPolicy: Check forbidden_paths
SecurityPolicy->>SecurityPolicy: Detect symlink escape
SecurityPolicy-->>Gateway: Authorized
Note over User,SecretStore: Layer 4: Execution Isolation
Gateway->>RuntimeAdapter: execute(command)
RuntimeAdapter->>RuntimeAdapter: Choose native vs docker
RuntimeAdapter-->>Gateway: Result (stdout/stderr)
Note over User,SecretStore: Layer 5: Data Protection
Gateway->>Gateway: scrub_credentials(output)
Gateway->>SecretStore: decrypt(env_vars)
SecretStore->>SecretStore: ChaCha20Poly1305 decrypt
SecretStore-->>Gateway: Plaintext secrets
Gateway-->>User: Safe response
Sources: README.md:380-407, src/gateway/mod.rs:620-778, src/agent/loop_.rs:850-1007