Skip to content

03.2 Security Model

Nikolay Vyahhi edited this page Feb 19, 2026 · 3 revisions

Security Model

Relevant source files

The following files were used as context for generating this wiki page:

Purpose and Scope

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.


Security Architecture Overview

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]
Loading

Sources: README.md:380-407, src/gateway/mod.rs:1-9, src/agent/loop_.rs:25-77


Layer 1: Network Security

Localhost Binding

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
Loading

Sources: src/gateway/mod.rs:284-292, src/security/pairing.rs:224-230

Tunnel Enforcement

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


Layer 2: Authentication

Gateway Pairing

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
Loading

Sources: src/security/pairing.rs:26-151, src/gateway/mod.rs:544-604

Pairing Implementation Details

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

Channel Allowlists

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
Loading

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)
WhatsApp channels_config.whatsapp allowed_numbers E.164 phone number

Sources: README.md:395-434, src/channels/telegram.rs, src/channels/discord.rs

Webhook Secret Authentication

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


Layer 3: Authorization

Autonomy Levels

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
Loading

Sources: src/security/mod.rs, src/agent/loop_.rs

Workspace Scoping

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
Loading

Sources: README.md:389, src/tools/file.rs

Forbidden Paths

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

Command Allowlist

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


Layer 4: Execution Isolation

Runtime Adapters

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
Loading

Sources: README.md:538-547

Docker Sandbox Configuration

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


Layer 5: Data Protection

Secret Store Encryption

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
Loading

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

Credential Scrubbing

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
Loading

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

Token Hashing

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


Security Configuration Reference

Complete Security Configuration

# 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 verification

Sources: README.md:496-599, src/gateway/mod.rs:37-46

Security Checklist

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


Security Flow Summary

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
Loading

Sources: README.md:380-407, src/gateway/mod.rs:620-778, src/agent/loop_.rs:850-1007


Clone this wiki locally