Skip to main content

Overview

Security is Layer 0 in OneClaw—the foundation upon which all other layers are built. The design follows a deny-by-default philosophy: all actions are blocked unless explicitly authorized. From security/mod.rs:1:
//! Layer 0: Security Core — Immune System
//! Deny-by-default. Every action must be authorized.

Core Principles

Deny-by-Default

All actions are blocked unless explicitly allowed

Device Pairing

One-time codes pair devices before interaction

Per-Command Authorization

Each command checks its own authorization

Rate Limiting

Prevent DoS attacks with request throttling

SecurityCore Trait

Defined in security/traits.rs:75:
pub trait SecurityCore: Send + Sync {
    /// Authorize an action. Deny-by-default.
    fn authorize(&self, action: &Action) -> Result<Permit>;
    
    /// Check if a filesystem path is allowed
    fn check_path(&self, path: &std::path::Path) -> Result<()>;
    
    /// Generate a one-time pairing code
    fn generate_pairing_code(&self) -> Result<String>;
    
    /// Verify a pairing code and return device identity
    fn verify_pairing_code(&self, code: &str) -> Result<Identity>;
    
    /// List all paired devices
    fn list_devices(&self) -> Result<Vec<PairedDevice>>;
    
    /// Remove a paired device by device_id (exact or prefix match)
    fn remove_device(&self, device_id_prefix: &str) -> Result<PairedDevice>;
}

Action Model

Every operation is represented as an Action with: From security/traits.rs:6:
pub struct Action {
    pub kind: ActionKind,      // What is being done
    pub resource: String,      // What is being accessed
    pub actor: String,         // Who is doing it
}

pub enum ActionKind {
    Read,         // Read access to a resource
    Write,        // Write access to a resource
    Execute,      // Execute a command or tool
    Network,      // Network access
    PairDevice,   // Pair a new device
}

Authorization Example

From runtime.rs:263:
fn check_auth(
    &self,
    kind: ActionKind,
    resource: &str,
    actor: &str,
) -> Option<ProcessResult> {
    let action = Action {
        kind,
        resource: resource.into(),
        actor: actor.into(),
    };
    
    match self.security.authorize(&action) {
        Ok(permit) if permit.granted => {
            Metrics::inc(&self.metrics.messages_secured);
            None  // Allowed
        }
        Ok(permit) => {
            Metrics::inc(&self.metrics.messages_denied);
            Some(ProcessResult::Response(format!(
                "Access denied: {:?} on '{}' — {}. Use 'pair' + 'verify CODE' to pair device.",
                action.kind, resource, permit.reason
            )))
        }
        Err(e) => Some(ProcessResult::Response(format!("Security error: {}", e))),
    }
}
Every secured command calls check_auth() before execution:
// Example: memory write requires authorization
if content_lower.starts_with("remember ") {
    if let Some(denied) = self.check_auth(ActionKind::Execute, "memory:write", actor) {
        return denied;
    }
    // ... proceed with memory storage ...
}
See runtime.rs:745 for the actual implementation.

Device Pairing Flow

OneClaw uses a pairing flow to establish trust between the agent and external devices:

Step 1: Generate Pairing Code

From security/pairing.rs, the agent generates a 6-digit OTP:
> pair
Pairing code: 582341 (valid 5 minutes)
Implementation in security/default.rs:187:
fn generate_pairing_code(&self) -> Result<String> {
    self.pairing.generate()  // 6-digit numeric code
}
The code is:
  • Cryptographically random (from ring::rand::SystemRandom)
  • Time-limited (default: 300 seconds)
  • One-time use (consumed on verification)

Step 2: Verify Pairing Code

Client submits the code within TTL:
> verify 582341
Device paired successfully!
  Device ID: f3a2b1c0-8a4e-4d9f-b2c7-1e8f9a0b3c4d
  Paired at: 2026-03-02 14:23:45 UTC
  You can now interact with the agent.
Implementation in security/default.rs:116:
fn verify_and_grant(&self, code: &str) -> Result<Identity> {
    // Step 1: Atomic verify (within PairingManager lock — marks code as used)
    let identity = self.pairing.verify(code)?;
    
    // Step 2: Grant access (with poison recovery — must not fail after code consumed)
    {
        let mut devices = self.paired_devices.lock()
            .unwrap_or_else(|e| e.into_inner());
        devices.insert(identity.device_id.clone());
    }
    
    // Step 3: Persist to SQLite if configured
    if let Some(ref store) = self.persistence {
        let device = PairedDevice::from_identity(&identity);
        if let Err(e) = store.store_device(&device) {
            tracing::warn!("Failed to persist device to SQLite: {}", e);
        }
    }
    
    // Step 3b: Legacy flat-file persistence (backward compat)
    self.persist_registry();
    
    Ok(identity)
}
Atomicity guarantee: Once pairing.verify() succeeds, the code is marked as used before the device is granted access. This prevents double-spending of codes, even if a panic occurs during the grant phase.

Step 3: Authorization

Once paired, the device can perform actions: From security/default.rs:146:
fn authorize(&self, action: &Action) -> Result<Permit> {
    // Deny-by-default: check pairing first (skip for PairDevice actions)
    if self.pairing_required && !matches!(action.kind, ActionKind::PairDevice) {
        let devices = self.paired_devices.lock()
            .unwrap_or_else(|e| e.into_inner());
        if !devices.contains(&action.actor) && action.actor != "system" {
            return Ok(Permit {
                granted: false,
                reason: format!("Device '{}' not paired. Pair first.", action.actor),
            });
        }
    }
    
    // Action-specific checks
    match &action.kind {
        ActionKind::PairDevice => {
            Ok(Permit { granted: true, reason: "Pairing action allowed".into() })
        }
        ActionKind::Read | ActionKind::Write => {
            let path = std::path::Path::new(&action.resource);
            match self.path_guard.check(path) {
                Ok(()) => Ok(Permit { granted: true, reason: "Path check passed".into() }),
                Err(e) => Ok(Permit { granted: false, reason: format!("{}", e) }),
            }
        }
        ActionKind::Execute => {
            Ok(Permit { granted: true, reason: "Execution allowed for paired device".into() })
        }
        ActionKind::Network => {
            Ok(Permit { granted: true, reason: "Network allowed for paired device".into() })
        }
    }
}

Paired Device Management

Listing Devices

> devices
Paired Devices (2):
  f3a2b1c0 | paired: 2026-03-02 14:23 | seen: 2026-03-02 15:30
  8e7d2c4f | paired: 2026-03-01 09:15 | seen: 2026-03-02 15:25 | Raspberry Pi
From security/traits.rs:49:
pub struct PairedDevice {
    pub device_id: String,
    pub paired_at: DateTime<Utc>,
    pub label: String,           // Optional human-readable label
    pub last_seen: DateTime<Utc>,
}

Removing Devices

Supports prefix matching for convenience:
> unpair f3a2
Device unpaired: f3a2b1c0-8a4e-4d9f-b2c7-1e8f9a0b3c4d
  Was paired since: 2026-03-02 14:23:45 UTC
From security/default.rs:211, prefix matching with ambiguity detection:
fn remove_device(&self, device_id_prefix: &str) -> Result<PairedDevice> {
    if let Some(ref store) = self.persistence {
        let matches = store.find_by_prefix(device_id_prefix)?;
        match matches.len() {
            0 => Err(OneClawError::Security(
                format!("No device matching '{}'", device_id_prefix)
            )),
            1 => {
                let device = matches.into_iter().next().unwrap();
                store.remove_device(&device.device_id)?;
                // Also remove from in-memory cache
                if let Ok(mut devices) = self.paired_devices.lock() {
                    devices.remove(&device.device_id);
                }
                Ok(device)
            }
            n => Err(OneClawError::Security(format!(
                "Ambiguous: '{}' matches {} devices. Use longer prefix.",
                device_id_prefix, n
            ))),
        }
    } else {
        // Fallback: search in-memory
        // ...
    }
}

Filesystem Scoping

OneClaw restricts filesystem access to a workspace directory via PathGuard. From security/path_guard.rs:
pub struct PathGuard {
    workspace: PathBuf,
    workspace_only: bool,
}

impl PathGuard {
    pub fn check(&self, path: &Path) -> Result<()> {
        let canonical = path.canonicalize()
            .map_err(|e| OneClawError::Security(
                format!("Invalid path: {}", e)
            ))?;
        
        if self.workspace_only && !canonical.starts_with(&self.workspace) {
            return Err(OneClawError::Security(format!(
                "Path outside workspace: {}",
                canonical.display()
            )));
        }
        
        Ok(())
    }
}

Example: Blocked Path Access

From security/default.rs:330 (test):
#[test]
fn test_blocked_path_denied_even_for_paired() {
    let sec = test_security();
    
    // Pair a device
    let code = sec.generate_pairing_code().unwrap();
    let identity = sec.verify_pairing_code(&code).unwrap();
    
    // Try to access /etc/passwd
    let action = Action {
        kind: ActionKind::Read,
        resource: "/etc/passwd".into(),
        actor: identity.device_id,
    };
    let permit = sec.authorize(&action).unwrap();
    assert!(!permit.granted);  // Blocked: outside workspace
}

Rate Limiting

OneClaw includes a RateLimiter to prevent DoS attacks. From security/rate_limit.rs:
pub struct RateLimiter {
    max_per_minute: u32,
    window: Mutex<Window>,
}

struct Window {
    start: Instant,
    count: u32,
}

impl RateLimiter {
    pub fn new(max_per_minute: u32) -> Self {
        Self {
            max_per_minute,
            window: Mutex::new(Window {
                start: Instant::now(),
                count: 0,
            }),
        }
    }
    
    pub fn check(&self) -> bool {
        let mut window = self.window.lock().unwrap();
        
        // Reset window after 60 seconds
        if window.start.elapsed() > Duration::from_secs(60) {
            window.start = Instant::now();
            window.count = 0;
        }
        
        window.count += 1;
        window.count <= self.max_per_minute
    }
}
Used in runtime.rs:507:
// Rate limit check (after open commands, before authorization)
if !self.rate_limiter.check() {
    Metrics::inc(&self.metrics.messages_rate_limited);
    return ProcessResult::Response(
        "Too many requests. Please wait a moment.".into()
    );
}
Default: 60 requests per minute (from runtime.rs:78).

Always-Open Commands

Certain commands bypass authorization to allow initial interaction: From runtime.rs:448:
// === ALWAYS OPEN (no security check) ===

if content_lower == "exit" || content_lower == "quit" || content_lower == "q" {
    return ProcessResult::Exit("Goodbye!".into());
}

if content_lower == "help" {
    return ProcessResult::Response("...".into());
}

if content_lower == "pair" {
    let response = match self.security.generate_pairing_code() {
        Ok(code) => format!("Pairing code: {} (valid 5 minutes)", code),
        Err(e) => format!("Failed to generate pairing code: {}", e),
    };
    return ProcessResult::Response(response);
}

if content_lower.starts_with("verify ") {
    let code = message.content.trim()[7..].trim();
    let response = match self.security.verify_pairing_code(code) {
        Ok(identity) => {
            // Map channel source to device ID
            if let Ok(mut map) = self.source_device_map.lock() {
                map.insert(message.source.clone(), identity.device_id.clone());
            }
            format!("Device paired successfully!\n  Device ID: {}\n  ...", identity.device_id)
        }
        Err(e) => format!("Pairing failed: {}", e),
    };
    return ProcessResult::Response(response);
}
Why always-open? Users must be able to pair their device before authorization can work. These four commands (exit, help, pair, verify) form the bootstrap protocol.

Per-Command Authorization

Unlike session-based authentication, OneClaw checks authorization for every command. From runtime.rs:527:
async fn dispatch_secured_command(
    &self,
    message: &IncomingMessage,
    content_lower: &str,
    actor: &str,
) -> ProcessResult {
    // Every command checks its own authorization
    
    if content_lower == "metrics" {
        if let Some(denied) = self.check_auth(ActionKind::Execute, "system:metrics", actor) {
            return denied;
        }
        return ProcessResult::Response(self.metrics.report());
    }
    
    if content_lower.starts_with("remember ") {
        if let Some(denied) = self.check_auth(ActionKind::Execute, "memory:write", actor) {
            return denied;
        }
        // ... store memory ...
    }
    
    if content_lower.starts_with("tool ") {
        let tool_name = /* ... */;
        let resource = format!("tool:{}", tool_name);
        if let Some(denied) = self.check_auth(ActionKind::Execute, &resource, actor) {
            return denied;
        }
        // ... execute tool ...
    }
    
    // Default: LLM pipeline
    if let Some(denied) = self.check_auth(ActionKind::Execute, "llm", actor) {
        return denied;
    }
    ProcessResult::Response(self.process_with_llm(&message.content))
}
This provides fine-grained control:
  • Block memory writes but allow reads
  • Allow specific tools only
  • Disable LLM access but allow metrics

Security Configuration

Production Mode

From security/default.rs:52:
pub fn production(workspace: impl Into<PathBuf>) -> Self {
    Self::new(
        workspace,
        true,   // workspace_only: true
        true,   // pairing_required: true
        300,    // pairing_code_ttl_seconds: 5 minutes
    )
}
Configuration in config/oneclaw.toml:
[security]
deny_by_default = true
workspace = "/opt/oneclaw/workspace"  # Filesystem scope

[security.pairing]
required = true
code_ttl_seconds = 300

[security.persistence]
type = "sqlite"
path = "/opt/oneclaw/security.db"

Development Mode

From security/default.rs:58:
pub fn development(workspace: impl Into<PathBuf>) -> Self {
    Self::new(
        workspace,
        true,    // workspace_only: true (still scoped)
        false,   // pairing_required: false (no pairing)
        3600,    // pairing_code_ttl_seconds: 1 hour
    )
}
Development mode disables pairing, allowing any device to interact. Use only in trusted environments.

Persistent Device Registry

Paired devices survive restarts via SQLite persistence. From security/persistence.rs:
pub struct SqliteSecurityStore {
    conn: Mutex<Connection>,
}

impl SqliteSecurityStore {
    pub fn new(path: &str) -> Result<Self> {
        let conn = Connection::open(path)?;
        conn.execute(
            "CREATE TABLE IF NOT EXISTS paired_devices (
                device_id TEXT PRIMARY KEY,
                paired_at TEXT NOT NULL,
                label TEXT NOT NULL,
                last_seen TEXT NOT NULL
            )",
            [],
        )?;
        Ok(Self { conn: Mutex::new(conn) })
    }
    
    pub fn store_device(&self, device: &PairedDevice) -> Result<()> { /* ... */ }
    pub fn load_device_ids(&self) -> Result<Vec<String>> { /* ... */ }
    pub fn find_by_prefix(&self, prefix: &str) -> Result<Vec<PairedDevice>> { /* ... */ }
    pub fn remove_device(&self, device_id: &str) -> Result<()> { /* ... */ }
}
From security/default.rs:84, devices are loaded on boot:
pub fn with_persistence(mut self, store: SqliteSecurityStore) -> Self {
    // Load existing paired devices from SQLite into in-memory cache
    if let Ok(ids) = store.load_device_ids()
        && let Ok(mut devices) = self.paired_devices.lock()
    {
        for id in &ids {
            devices.insert(id.clone());
        }
        if !ids.is_empty() {
            tracing::info!(count = ids.len(), "Loaded paired devices from SQLite");
        }
    }
    self.persistence = Some(store);
    self
}

Security Testing

From security/default.rs:262 (tests):
#[test]
fn test_unpaired_device_denied() {
    let sec = test_security();
    let action = Action {
        kind: ActionKind::Read,
        resource: ".".into(),
        actor: "unknown-device".into(),
    };
    let permit = sec.authorize(&action).unwrap();
    assert!(!permit.granted);
    assert!(permit.reason.contains("not paired"));
}

#[test]
fn test_full_pairing_flow() {
    let sec = test_security();
    
    // 1. Generate code
    let code = sec.generate_pairing_code().unwrap();
    assert_eq!(code.len(), 6);
    
    // 2. Verify code -> get identity
    let identity = sec.verify_pairing_code(&code).unwrap();
    
    // 3. Now device can perform actions
    let action = Action {
        kind: ActionKind::Read,
        resource: env::current_dir().unwrap().join("Cargo.toml").to_string_lossy().into(),
        actor: identity.device_id.clone(),
    };
    let permit = sec.authorize(&action).unwrap();
    assert!(permit.granted);
}

#[test]
fn test_system_actor_bypasses_pairing() {
    let sec = test_security();
    let action = Action {
        kind: ActionKind::Execute,
        resource: "internal".into(),
        actor: "system".into(),
    };
    let permit = sec.authorize(&action).unwrap();
    assert!(permit.granted);
}

Security Metrics

From metrics.rs, security operations are tracked:
pub struct Metrics {
    pub messages_secured: AtomicU64,       // Authorized messages
    pub messages_denied: AtomicU64,        // Blocked by security
    pub messages_rate_limited: AtomicU64,  // Throttled
    // ...
}
View with metrics command:
> metrics
Operational Metrics:
  Messages:
    Total: 1,234
    Secured: 1,180
    Denied: 42
    Rate Limited: 12
  ...

Security Checklist

When deploying OneClaw in production:
1

Enable Production Security

Use DefaultSecurity::production(workspace) or set deny_by_default = true in config.
2

Configure Workspace Scope

Set workspace to a dedicated directory (e.g., /opt/oneclaw/workspace).
3

Enable Device Pairing

Set pairing_required = true in [security] config.
4

Persist Paired Devices

Use SQLite persistence with SqliteSecurityStore to survive restarts.
5

Set Rate Limits

Configure RateLimiter based on expected load (default: 60/min).
6

Monitor Security Metrics

Check messages_denied and messages_rate_limited regularly.
7

Review Paired Devices

Use devices command to audit paired devices. Remove stale devices with unpair.

Next Steps

Architecture

How security fits into the 6-layer architecture

Layer Details

Deep dive into all layers including L0

Deployment Guide

Deploying OneClaw securely on edge devices

Configuration

Security configuration reference

Build docs developers (and LLMs) love