Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/nullclaw/nullclaw/llms.txt

Use this file to discover all available pages before exploring further.

Overview

NullClaw enforces security at every layer of the stack:
  1. Gateway pairing — One-time code authentication
  2. Channel allowlists — Sender validation
  3. Workspace scoping — Filesystem boundaries
  4. Sandbox isolation — OS-level containment
  5. Encrypted secrets — ChaCha20-Poly1305 AEAD
  6. Audit logging — Signed event trail
  7. Resource limits — Memory, CPU, disk quotas

Security Table

#ItemStatusHow
1Gateway not publicly exposedDoneBinds 127.0.0.1 by default. Refuses 0.0.0.0 without tunnel or explicit allow_public_bind.
2Pairing requiredDone6-digit one-time code on startup. Exchange via POST /pair for bearer token.
3Filesystem scopedDoneworkspace_only = true by default. Null byte injection blocked. Symlink escape detection.
4Access via tunnel onlyDoneGateway refuses public bind without active tunnel. Supports Tailscale, Cloudflare, ngrok, or custom.
5Sandbox isolationDoneAuto-detects best backend: Landlock, Firejail, Bubblewrap, or Docker.
6Encrypted secretsDoneAPI keys encrypted with ChaCha20-Poly1305 using local key file.
7Resource limitsDoneConfigurable memory, CPU, disk, and subprocess limits.
8Audit loggingDoneSigned event trail with configurable retention.

1. Gateway Pairing

How It Works

Configuration

{
  "gateway": {
    "port": 3000,
    "require_pairing": true,
    "allow_public_bind": false,
    "pairing_code_ttl_secs": 300  // 5 minutes
  }
}

Pairing Flow

  1. Start gateway:
    nullclaw gateway
    # → Pairing code: 123456
    
  2. Client pairs:
    curl -X POST http://127.0.0.1:3000/pair \
      -H "X-Pairing-Code: 123456"
    # → {"token": "tok_abc123..."}
    
  3. Use bearer token:
    curl http://127.0.0.1:3000/webhook \
      -H "Authorization: Bearer tok_abc123..." \
      -d '{"message": "Hello"}'
    
Pairing codes are single-use and expire after 5 minutes (configurable). Bearer tokens persist until revoked.

2. Channel Allowlists

Every channel enforces a sender allowlist:
{
  "channels": {
    "telegram": {
      "accounts": {
        "main": {
          "allow_from": ["alice", "bob"]
        }
      }
    }
  }
}

Allowlist Rules

  • Empty allowlist ([]) → Deny all inbound messages
  • Wildcard (["*"]) → Allow all (explicit opt-in)
  • Exact match → Case-insensitive username/ID match

Special Cases

Nostr

The owner_pubkey is always allowed regardless of dm_allowed_pubkeys:
{
  "channels": {
    "nostr": {
      "owner_pubkey": "npub1abc...",
      "dm_allowed_pubkeys": ["npub1def...", "npub1ghi..."]
    }
  }
}

Signal

Supports phone numbers and UUIDs:
{
  "channels": {
    "signal": {
      "allow_from": ["+1234567890", "uuid:a1b2c3d4-..."]
    }
  }
}

3. Workspace Scoping

Default Behavior

All file operations are restricted to ~/.nullclaw/workspace/ by default:
{
  "autonomy": {
    "workspace_only": true,
    "allowed_paths": []  // Additional paths outside workspace
  }
}

Path Validation

pub fn validatePath(
    path: []const u8,
    workspace_dir: []const u8,
    allowed_paths: []const []const u8,
    workspace_only: bool,
) !void {
    // 1. Null byte injection blocked
    if (std.mem.indexOfScalar(u8, path, 0) != null) {
        return error.InvalidPath;
    }

    // 2. Resolve to absolute path
    const abs_path = try std.fs.realpathAlloc(allocator, path);
    defer allocator.free(abs_path);

    // 3. Check workspace boundary
    if (workspace_only) {
        if (!std.mem.startsWith(u8, abs_path, workspace_dir)) {
            // Check additional allowed paths
            for (allowed_paths) |allowed| {
                if (std.mem.startsWith(u8, abs_path, allowed)) {
                    return; // Allowed
                }
            }
            return error.PathOutsideWorkspace;
        }
    }

    // 4. Symlink escape detection (handled by realpathAlloc)
}

Expanding Access

Allow specific paths outside workspace:
{
  "autonomy": {
    "workspace_only": true,
    "allowed_paths": [
      "/home/user/projects/myproject",
      "/var/log/app"
    ]
  }
}
Or disable entirely (not recommended):
{
  "autonomy": {
    "workspace_only": false
  }
}
Disabling workspace scoping allows the agent to read/write any file the process user has access to. Use with caution.

4. Sandbox Isolation

Supported Backends

BackendOSMethodCapabilities
LandlockLinux 5.13+Kernel LSMFilesystem access control, syscall filtering
FirejailLinuxSeccomp + namespacesFull process isolation, network blocking
BubblewrapLinuxUser namespacesLightweight containerization
DockerLinux/macOSContainer runtimeFull isolation, custom images

Configuration

{
  "security": {
    "sandbox": {
      "backend": "auto"  // auto | landlock | firejail | bubblewrap | docker | none
    }
  }
}
Auto-detection order: Landlock → Firejail → Bubblewrap → Docker → None (fallback)

Docker Sandbox

{
  "runtime": {
    "kind": "docker",
    "docker": {
      "image": "alpine:3.20",
      "network": "none",
      "memory_limit_mb": 512,
      "read_only_rootfs": true
    }
  }
}
Restrictions:
  • No network by default
  • Read-only rootfs (workspace mounted as writable volume)
  • Memory limit enforced
  • Auto-removed on exit

Landlock Example

const sandbox = try LandlockSandbox.init(allocator, .{
    .allowed_read_dirs = &.{ workspace_dir },
    .allowed_write_dirs = &.{ workspace_dir },
});
defer sandbox.deinit();

try sandbox.enable();
// Now process is restricted to workspace_dir only

5. Encrypted Secrets

SecretStore

API keys are encrypted at rest with ChaCha20-Poly1305 AEAD:
const SecretStore = struct {
    key_file_path: []const u8,

    pub fn encrypt(
        self: SecretStore,
        allocator: std.mem.Allocator,
        plaintext: []const u8,
    ) ![]const u8 {
        // Returns "enc2:<hex-ciphertext>"
    }

    pub fn decrypt(
        self: SecretStore,
        allocator: std.mem.Allocator,
        ciphertext: []const u8,
    ) ![]const u8 {
        // Parses "enc2:..." prefix, decrypts, verifies tag
    }
};

Configuration

{
  "secrets": {
    "encrypt": true,
    "key_file": "~/.nullclaw/secret.key"  // Auto-generated if missing
  }
}

Key Management

Key file format: 32-byte random key (256-bit)
# Auto-generated on first run
ls -l ~/.nullclaw/secret.key
# -rw------- 1 user user 32 Mar 1 12:34 secret.key
Do NOT commit secret.key to git. Backup securely. Loss of this file means encrypted secrets cannot be decrypted.

Encrypted Config Fields

{
  "models": {
    "providers": {
      "openai": {
        "api_key": "enc2:a1b2c3d4..."
      }
    }
  },
  "channels": {
    "nostr": {
      "private_key": "enc2:e5f6g7h8..."
    }
  }
}
Decrypted at runtime, never written back in plaintext.

6. Audit Logging

AuditEvent Structure

pub const AuditEvent = struct {
    timestamp: i64,
    event_type: AuditEventType,
    actor: Actor,
    action: Action,
    result: ExecutionResult,
    context: SecurityContext,
    signature: ?[]const u8 = null,  // HMAC-SHA256 signature
};

Configuration

{
  "security": {
    "audit": {
      "enabled": true,
      "retention_days": 90,
      "log_file": "~/.nullclaw/audit.log",
      "sign_events": true
    }
  }
}

Example Log Entry

{
  "timestamp": 1709299200,
  "event_type": "command_execution",
  "actor": {
    "user_id": "alice",
    "channel": "telegram",
    "session_id": "sess_abc123"
  },
  "action": {
    "type": "shell",
    "command": "ls -la",
    "workspace_dir": "/home/user/.nullclaw/workspace"
  },
  "result": {
    "success": true,
    "exit_code": 0
  },
  "signature": "hmac-sha256:a1b2c3..."
}

Querying Logs

# Show recent audit events
tail -f ~/.nullclaw/audit.log | jq

# Filter by event type
jq 'select(.event_type == "command_execution")' ~/.nullclaw/audit.log

# Filter by actor
jq 'select(.actor.user_id == "alice")' ~/.nullclaw/audit.log

7. Resource Limits

Configuration

{
  "security": {
    "resources": {
      "max_memory_mb": 512,
      "max_cpu_percent": 80,
      "max_disk_usage_mb": 1024,
      "max_subprocesses": 10
    }
  }
}

Tool-Level Limits

{
  "tools": {
    "shell": {
      "timeout_secs": 60,
      "max_output_bytes": 100000
    },
    "file_read": {
      "max_file_size_bytes": 10485760  // 10 MB
    },
    "http_request": {
      "timeout_secs": 30,
      "max_response_size": 1000000  // 1 MB
    }
  }
}

Enforcement

  • Shell timeout: Subprocess killed after N seconds
  • Output truncation: STDOUT/STDERR capped at max_output_bytes
  • File size: file_read fails if file > max_file_size_bytes
  • HTTP response: Downloads stopped after max_response_size bytes

Command Risk Classification

Risk Levels

pub const CommandRiskLevel = enum {
    low,     // ls, cat, echo, git status
    medium,  // curl, wget, chmod +x, npm install
    high,    // rm -rf, dd, mkfs, shutdown, reboot
};

High-Risk Commands

Blocked by default (configurable):
  • rm -rf
  • dd if=/dev/zero
  • mkfs
  • shutdown
  • reboot
  • :(){ :|:& };: (fork bomb)

Configuration

{
  "autonomy": {
    "level": "supervised",  // full | supervised | restricted
    "allowed_commands": ["git", "npm", "zig"],  // Prefix matching
    "block_high_risk_commands": true,
    "require_approval_for_medium_risk": false
  }
}
Autonomy levels:
  • full — Allow all commands (ignores allowed_commands)
  • supervised — Allow allowed_commands, block high-risk
  • restricted — Allow allowed_commands only, require approval for medium-risk

Next Steps

Configuration

Full security configuration reference

Architecture

Learn about the security-first design