diff --git a/Cargo.lock b/Cargo.lock index f39c66f..92cf77e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -545,6 +545,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -743,6 +763,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.14.5" @@ -1137,6 +1163,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "landlock" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fefd6652c57d68aaa32544a4c0e642929725bdc1fd929367cdeb673ab81088" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.18", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3200,10 +3237,12 @@ dependencies = [ "dialoguer", "directories", "futures-util", + "glob", "hex", "hmac", "hostname", "http-body-util", + "landlock", "lettre", "mail-parser", "opentelemetry", diff --git a/Cargo.toml b/Cargo.toml index 6ead2f0..51d89ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ hmac = "0.12" sha2 = "0.10" hex = "0.4" +# Landlock (Linux sandbox) - optional dependency +landlock = { version = "0.4", optional = true } + # Async traits async-trait = "0.1" @@ -66,6 +69,9 @@ cron = "0.12" dialoguer = { version = "0.11", features = ["fuzzy-select"] } console = "0.15" +# Hardware discovery (device path globbing) +glob = "0.3" + # Discord WebSocket gateway tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = { version = "0.3", default-features = false, features = ["sink"] } @@ -88,6 +94,20 @@ opentelemetry = { version = "0.31", default-features = false, features = ["trace opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"] } opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-blocking-client"] } +[features] +default = [] + +# Sandbox backends (platform-specific, opt-in) +sandbox-landlock = ["landlock"] # Linux kernel LSM +sandbox-bubblewrap = [] # User namespaces (Linux/macOS) + +# Full security suite +security-full = ["sandbox-landlock"] + +[[bin]] +name = "zeroclaw" +path = "src/main.rs" + [profile.release] opt-level = "z" # Optimize for size lto = true # Link-time optimization diff --git a/docs/agnostic-security.md b/docs/agnostic-security.md new file mode 100644 index 0000000..7ed0273 --- /dev/null +++ b/docs/agnostic-security.md @@ -0,0 +1,348 @@ +# Agnostic Security: Zero Impact on Portability + +## Core Question: Will security features break... +1. ❓ Fast cross-compilation builds? +2. ❓ Pluggable architecture (swap anything)? +3. ❓ Hardware agnosticism (ARM, x86, RISC-V)? +4. ❓ Small hardware support (<5MB RAM, $10 boards)? + +**Answer: NO to all** — Security is designed as **optional feature flags** with **platform-specific conditional compilation**. + +--- + +## 1. Build Speed: Feature-Gated Security + +### Cargo.toml: Security Features Behind Features + +```toml +[features] +default = ["basic-security"] + +# Basic security (always on, zero overhead) +basic-security = [] + +# Platform-specific sandboxing (opt-in per platform) +sandbox-landlock = [] # Linux only +sandbox-firejail = [] # Linux only +sandbox-bubblewrap = []# macOS/Linux +sandbox-docker = [] # All platforms (heavy) + +# Full security suite (for production builds) +security-full = [ + "basic-security", + "sandbox-landlock", + "resource-monitoring", + "audit-logging", +] + +# Resource & audit monitoring +resource-monitoring = [] +audit-logging = [] + +# Development builds (fastest, no extra deps) +dev = [] +``` + +### Build Commands (Choose Your Profile) + +```bash +# Ultra-fast dev build (no security extras) +cargo build --profile dev + +# Release build with basic security (default) +cargo build --release +# → Includes: allowlist, path blocking, injection protection +# → Excludes: Landlock, Firejail, audit logging + +# Production build with full security +cargo build --release --features security-full +# → Includes: Everything + +# Platform-specific sandbox only +cargo build --release --features sandbox-landlock # Linux +cargo build --release --features sandbox-docker # All platforms +``` + +### Conditional Compilation: Zero Overhead When Disabled + +```rust +// src/security/mod.rs + +#[cfg(feature = "sandbox-landlock")] +mod landlock; +#[cfg(feature = "sandbox-landlock")] +pub use landlock::LandlockSandbox; + +#[cfg(feature = "sandbox-firejail")] +mod firejail; +#[cfg(feature = "sandbox-firejail")] +pub use firejail::FirejailSandbox; + +// Always-include basic security (no feature flag) +pub mod policy; // allowlist, path blocking, injection protection +``` + +**Result**: When features are disabled, the code isn't even compiled — **zero binary bloat**. + +--- + +## 2. Pluggable Architecture: Security Is a Trait Too + +### Security Backend Trait (Swappable Like Everything Else) + +```rust +// src/security/traits.rs + +#[async_trait] +pub trait Sandbox: Send + Sync { + /// Wrap a command with sandbox protection + fn wrap_command(&self, cmd: &mut std::process::Command) -> std::io::Result<()>; + + /// Check if sandbox is available on this platform + fn is_available(&self) -> bool; + + /// Human-readable name + fn name(&self) -> &str; +} + +// No-op sandbox (always available) +pub struct NoopSandbox; + +impl Sandbox for NoopSandbox { + fn wrap_command(&self, _cmd: &mut std::process::Command) -> std::io::Result<()> { + Ok(()) // Pass through unchanged + } + + fn is_available(&self) -> bool { true } + fn name(&self) -> &str { "none" } +} +``` + +### Factory Pattern: Auto-Select Based on Features + +```rust +// src/security/factory.rs + +pub fn create_sandbox() -> Box { + #[cfg(feature = "sandbox-landlock")] + { + if LandlockSandbox::is_available() { + return Box::new(LandlockSandbox::new()); + } + } + + #[cfg(feature = "sandbox-firejail")] + { + if FirejailSandbox::is_available() { + return Box::new(FirejailSandbox::new()); + } + } + + #[cfg(feature = "sandbox-bubblewrap")] + { + if BubblewrapSandbox::is_available() { + return Box::new(BubblewrapSandbox::new()); + } + } + + #[cfg(feature = "sandbox-docker")] + { + if DockerSandbox::is_available() { + return Box::new(DockerSandbox::new()); + } + } + + // Fallback: always available + Box::new(NoopSandbox) +} +``` + +**Just like providers, channels, and memory — security is pluggable!** + +--- + +## 3. Hardware Agnosticism: Same Binary, Different Platforms + +### Cross-Platform Behavior Matrix + +| Platform | Builds On | Runtime Behavior | +|----------|-----------|------------------| +| **Linux ARM** (Raspberry Pi) | ✅ Yes | Landlock → None (graceful) | +| **Linux x86_64** | ✅ Yes | Landlock → Firejail → None | +| **macOS ARM** (M1/M2) | ✅ Yes | Bubblewrap → None | +| **macOS x86_64** | ✅ Yes | Bubblewrap → None | +| **Windows ARM** | ✅ Yes | None (app-layer) | +| **Windows x86_64** | ✅ Yes | None (app-layer) | +| **RISC-V Linux** | ✅ Yes | Landlock → None | + +### How It Works: Runtime Detection + +```rust +// src/security/detect.rs + +impl SandboxingStrategy { + /// Choose best available sandbox AT RUNTIME + pub fn detect() -> SandboxingStrategy { + #[cfg(target_os = "linux")] + { + // Try Landlock first (kernel feature detection) + if Self::probe_landlock() { + return SandboxingStrategy::Landlock; + } + + // Try Firejail (user-space tool detection) + if Self::probe_firejail() { + return SandboxingStrategy::Firejail; + } + } + + #[cfg(target_os = "macos")] + { + if Self::probe_bubblewrap() { + return SandboxingStrategy::Bubblewrap; + } + } + + // Always available fallback + SandboxingStrategy::ApplicationLayer + } +} +``` + +**Same binary runs everywhere** — it just adapts its protection level based on what's available. + +--- + +## 4. Small Hardware: Memory Impact Analysis + +### Binary Size Impact (Estimated) + +| Feature | Code Size | RAM Overhead | Status | +|---------|-----------|--------------|--------| +| **Base ZeroClaw** | 3.4MB | <5MB | ✅ Current | +| **+ Landlock** | +50KB | +100KB | ✅ Linux 5.13+ | +| **+ Firejail wrapper** | +20KB | +0KB (external) | ✅ Linux + firejail | +| **+ Memory monitoring** | +30KB | +50KB | ✅ All platforms | +| **+ Audit logging** | +40KB | +200KB (buffered) | ✅ All platforms | +| **Full security** | +140KB | +350KB | ✅ Still <6MB total | + +### $10 Hardware Compatibility + +| Hardware | RAM | ZeroClaw (base) | ZeroClaw (full security) | Status | +|----------|-----|-----------------|--------------------------|--------| +| **Raspberry Pi Zero** | 512MB | ✅ 2% | ✅ 2.5% | Works | +| **Orange Pi Zero** | 512MB | ✅ 2% | ✅ 2.5% | Works | +| **NanoPi NEO** | 256MB | ✅ 4% | ✅ 5% | Works | +| **C.H.I.P.** | 512MB | ✅ 2% | ✅ 2.5% | Works | +| **Rock64** | 1GB | ✅ 1% | ✅ 1.2% | Works | + +**Even with full security, ZeroClaw uses <5% of RAM on $10 boards.** + +--- + +## 5. Agnostic Swaps: Everything Remains Pluggable + +### ZeroClaw's Core Promise: Swap Anything + +```rust +// Providers (already pluggable) +Box + +// Channels (already pluggable) +Box + +// Memory (already pluggable) +Box + +// Tunnels (already pluggable) +Box + +// NOW ALSO: Security (newly pluggable) +Box +Box +Box +``` + +### Swap Security Backends via Config + +```toml +# Use no sandbox (fastest, app-layer only) +[security.sandbox] +backend = "none" + +# Use Landlock (Linux kernel LSM, native) +[security.sandbox] +backend = "landlock" + +# Use Firejail (user-space, needs firejail installed) +[security.sandbox] +backend = "firejail" + +# Use Docker (heaviest, most isolated) +[security.sandbox] +backend = "docker" +``` + +**Just like swapping OpenAI for Gemini, or SQLite for PostgreSQL.** + +--- + +## 6. Dependency Impact: Minimal New Deps + +### Current Dependencies (for context) +``` +reqwest, tokio, serde, anyhow, uuid, chrono, rusqlite, +axum, tracing, opentelemetry, ... +``` + +### Security Feature Dependencies + +| Feature | New Dependencies | Platform | +|---------|------------------|----------| +| **Landlock** | `landlock` crate (pure Rust) | Linux only | +| **Firejail** | None (external binary) | Linux only | +| **Bubblewrap** | None (external binary) | macOS/Linux | +| **Docker** | `bollard` crate (Docker API) | All platforms | +| **Memory monitoring** | None (std::alloc) | All platforms | +| **Audit logging** | None (already have hmac/sha2) | All platforms | + +**Result**: Most features add **zero new Rust dependencies** — they either: +1. Use pure-Rust crates (landlock) +2. Wrap external binaries (Firejail, Bubblewrap) +3. Use existing deps (hmac, sha2 already in Cargo.toml) + +--- + +## Summary: Core Value Propositions Preserved + +| Value Prop | Before | After (with security) | Status | +|------------|--------|----------------------|--------| +| **<5MB RAM** | ✅ <5MB | ✅ <6MB (worst case) | ✅ Preserved | +| **<10ms startup** | ✅ <10ms | ✅ <15ms (detection) | ✅ Preserved | +| **3.4MB binary** | ✅ 3.4MB | ✅ 3.5MB (with all features) | ✅ Preserved | +| **ARM + x86 + RISC-V** | ✅ All | ✅ All | ✅ Preserved | +| **$10 hardware** | ✅ Works | ✅ Works | ✅ Preserved | +| **Pluggable everything** | ✅ Yes | ✅ Yes (security too) | ✅ Enhanced | +| **Cross-platform** | ✅ Yes | ✅ Yes | ✅ Preserved | + +--- + +## The Key: Feature Flags + Conditional Compilation + +```bash +# Developer build (fastest, no extra features) +cargo build --profile dev + +# Standard release (your current build) +cargo build --release + +# Production with full security +cargo build --release --features security-full + +# Target specific hardware +cargo build --release --target aarch64-unknown-linux-gnu # Raspberry Pi +cargo build --release --target riscv64gc-unknown-linux-gnu # RISC-V +cargo build --release --target armv7-unknown-linux-gnueabihf # ARMv7 +``` + +**Every target, every platform, every use case — still fast, still small, still agnostic.** diff --git a/docs/audit-logging.md b/docs/audit-logging.md new file mode 100644 index 0000000..8871adb --- /dev/null +++ b/docs/audit-logging.md @@ -0,0 +1,186 @@ +# Audit Logging for ZeroClaw + +## Problem +ZeroClaw logs actions but lacks tamper-evident audit trails for: +- Who executed what command +- When and from which channel +- What resources were accessed +- Whether security policies were triggered + +--- + +## Proposed Audit Log Format + +```json +{ + "timestamp": "2026-02-16T12:34:56Z", + "event_id": "evt_1a2b3c4d", + "event_type": "command_execution", + "actor": { + "channel": "telegram", + "user_id": "123456789", + "username": "@alice" + }, + "action": { + "command": "ls -la", + "risk_level": "low", + "approved": false, + "allowed": true + }, + "result": { + "success": true, + "exit_code": 0, + "duration_ms": 15 + }, + "security": { + "policy_violation": false, + "rate_limit_remaining": 19 + }, + "signature": "SHA256:abc123..." // HMAC for tamper evidence +} +``` + +--- + +## Implementation + +```rust +// src/security/audit.rs +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEvent { + pub timestamp: String, + pub event_id: String, + pub event_type: AuditEventType, + pub actor: Actor, + pub action: Action, + pub result: ExecutionResult, + pub security: SecurityContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AuditEventType { + CommandExecution, + FileAccess, + ConfigurationChange, + AuthSuccess, + AuthFailure, + PolicyViolation, +} + +pub struct AuditLogger { + log_path: PathBuf, + signing_key: Option>, +} + +impl AuditLogger { + pub fn log(&self, event: &AuditEvent) -> anyhow::Result<()> { + let mut line = serde_json::to_string(event)?; + + // Add HMAC signature if key configured + if let Some(ref key) = self.signing_key { + let signature = compute_hmac(key, line.as_bytes()); + line.push_str(&format!("\n\"signature\": \"{}\"", signature)); + } + + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.log_path)?; + + writeln!(file, "{}", line)?; + file.sync_all()?; // Force flush for durability + Ok(()) + } + + pub fn search(&self, filter: AuditFilter) -> Vec { + // Search log file by filter criteria + todo!() + } +} +``` + +--- + +## Config Schema + +```toml +[security.audit] +enabled = true +log_path = "~/.config/zeroclaw/audit.log" +max_size_mb = 100 +rotate = "daily" # daily | weekly | size + +# Tamper evidence +sign_events = true +signing_key_path = "~/.config/zeroclaw/audit.key" + +# What to log +log_commands = true +log_file_access = true +log_auth_events = true +log_policy_violations = true +``` + +--- + +## Audit Query CLI + +```bash +# Show all commands executed by @alice +zeroclaw audit --user @alice + +# Show all high-risk commands +zeroclaw audit --risk high + +# Show violations from last 24 hours +zeroclaw audit --since 24h --violations-only + +# Export to JSON for analysis +zeroclaw audit --format json --output audit.json + +# Verify log integrity +zeroclaw audit --verify-signatures +``` + +--- + +## Log Rotation + +```rust +pub fn rotate_audit_log(log_path: &PathBuf, max_size: u64) -> anyhow::Result<()> { + let metadata = std::fs::metadata(log_path)?; + if metadata.len() < max_size { + return Ok(()); + } + + // Rotate: audit.log -> audit.log.1 -> audit.log.2 -> ... + let stem = log_path.file_stem().unwrap_or_default(); + let extension = log_path.extension().and_then(|s| s.to_str()).unwrap_or("log"); + + for i in (1..10).rev() { + let old_name = format!("{}.{}.{}", stem, i, extension); + let new_name = format!("{}.{}.{}", stem, i + 1, extension); + let _ = std::fs::rename(old_name, new_name); + } + + let rotated = format!("{}.1.{}", stem, extension); + std::fs::rename(log_path, &rotated)?; + + Ok(()) +} +``` + +--- + +## Implementation Priority + +| Phase | Feature | Effort | Security Value | +|-------|---------|--------|----------------| +| **P0** | Basic event logging | Low | Medium | +| **P1** | Query CLI | Medium | Medium | +| **P2** | HMAC signing | Medium | High | +| **P3** | Log rotation + archival | Low | Medium | diff --git a/docs/frictionless-security.md b/docs/frictionless-security.md new file mode 100644 index 0000000..d23dbfc --- /dev/null +++ b/docs/frictionless-security.md @@ -0,0 +1,312 @@ +# Frictionless Security: Zero Impact on Wizard + +## Core Principle +> **"Security features should be like airbags — present, protective, and invisible until needed."** + +## Design: Silent Auto-Detection + +### 1. No New Wizard Steps (Stays 9 Steps, < 60 Seconds) + +```rust +// Wizard remains UNCHANGED +// Security features auto-detect in background + +pub fn run_wizard() -> Result { + // ... existing 9 steps, no changes ... + + let config = Config { + // ... existing fields ... + + // NEW: Auto-detected security (not shown in wizard) + security: SecurityConfig::autodetect(), // Silent! + }; + + config.save()?; + Ok(config) +} +``` + +### 2. Auto-Detection Logic (Runs Once at First Start) + +```rust +// src/security/detect.rs + +impl SecurityConfig { + /// Detect available sandboxing and enable automatically + /// Returns smart defaults based on platform + available tools + pub fn autodetect() -> Self { + Self { + // Sandbox: prefer Landlock (native), then Firejail, then none + sandbox: SandboxConfig::autodetect(), + + // Resource limits: always enable monitoring + resources: ResourceLimits::default(), + + // Audit: enable by default, log to config dir + audit: AuditConfig::default(), + + // Everything else: safe defaults + ..SecurityConfig::default() + } + } +} + +impl SandboxConfig { + pub fn autodetect() -> Self { + #[cfg(target_os = "linux")] + { + // Prefer Landlock (native, no dependency) + if Self::probe_landlock() { + return Self { + enabled: true, + backend: SandboxBackend::Landlock, + ..Self::default() + }; + } + + // Fallback: Firejail if installed + if Self::probe_firejail() { + return Self { + enabled: true, + backend: SandboxBackend::Firejail, + ..Self::default() + }; + } + } + + #[cfg(target_os = "macos")] + { + // Try Bubblewrap on macOS + if Self::probe_bubblewrap() { + return Self { + enabled: true, + backend: SandboxBackend::Bubblewrap, + ..Self::default() + }; + } + } + + // Fallback: disabled (but still has application-layer security) + Self { + enabled: false, + backend: SandboxBackend::None, + ..Self::default() + } + } + + #[cfg(target_os = "linux")] + fn probe_landlock() -> bool { + // Try creating a minimal Landlock ruleset + // If it works, kernel supports Landlock + landlock::Ruleset::new() + .set_access_fs(landlock::AccessFS::read_file) + .add_path(Path::new("/tmp"), landlock::AccessFS::read_file) + .map(|ruleset| ruleset.restrict_self().is_ok()) + .unwrap_or(false) + } + + fn probe_firejail() -> bool { + // Check if firejail command exists + std::process::Command::new("firejail") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } +} +``` + +### 3. First Run: Silent Logging + +```bash +$ zeroclaw agent -m "hello" + +# First time: silent detection +[INFO] Detecting security features... +[INFO] ✓ Landlock sandbox enabled (kernel 6.2+) +[INFO] ✓ Memory monitoring active (512MB limit) +[INFO] ✓ Audit logging enabled (~/.config/zeroclaw/audit.log) + +# Subsequent runs: quiet +$ zeroclaw agent -m "hello" +[agent] Thinking... +``` + +### 4. Config File: All Defaults Hidden + +```toml +# ~/.config/zeroclaw/config.toml + +# These sections are NOT written unless user customizes +# [security.sandbox] +# enabled = true # (default, auto-detected) +# backend = "landlock" # (default, auto-detected) + +# [security.resources] +# max_memory_mb = 512 # (default) + +# [security.audit] +# enabled = true # (default) +``` + +Only when user changes something: +```toml +[security.sandbox] +enabled = false # User explicitly disabled + +[security.resources] +max_memory_mb = 1024 # User increased limit +``` + +### 5. Advanced Users: Explicit Control + +```bash +# Check what's active +$ zeroclaw security --status +Security Status: + ✓ Sandbox: Landlock (Linux kernel 6.2) + ✓ Memory monitoring: 512MB limit + ✓ Audit logging: ~/.config/zeroclaw/audit.log + → 47 events logged today + +# Disable sandbox explicitly (writes to config) +$ zeroclaw config set security.sandbox.enabled false + +# Enable specific backend +$ zeroclaw config set security.sandbox.backend firejail + +# Adjust limits +$ zeroclaw config set security.resources.max_memory_mb 2048 +``` + +### 6. Graceful Degradation + +| Platform | Best Available | Fallback | Worst Case | +|----------|---------------|----------|------------| +| **Linux 5.13+** | Landlock | None | App-layer only | +| **Linux (any)** | Firejail | Landlock | App-layer only | +| **macOS** | Bubblewrap | None | App-layer only | +| **Windows** | None | - | App-layer only | + +**App-layer security is always present** — this is the existing allowlist/path blocking/injection protection that's already comprehensive. + +--- + +## Config Schema Extension + +```rust +// src/config/schema.rs + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityConfig { + /// Sandbox configuration (auto-detected if not set) + #[serde(default)] + pub sandbox: SandboxConfig, + + /// Resource limits (defaults applied if not set) + #[serde(default)] + pub resources: ResourceLimits, + + /// Audit logging (enabled by default) + #[serde(default)] + pub audit: AuditConfig, +} + +impl Default for SecurityConfig { + fn default() -> Self { + Self { + sandbox: SandboxConfig::autodetect(), // Silent detection! + resources: ResourceLimits::default(), + audit: AuditConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxConfig { + /// Enable sandboxing (default: auto-detected) + #[serde(default)] + pub enabled: Option, // None = auto-detect + + /// Sandbox backend (default: auto-detect) + #[serde(default)] + pub backend: SandboxBackend, + + /// Custom Firejail args (optional) + #[serde(default)] + pub firejail_args: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SandboxBackend { + Auto, // Auto-detect (default) + Landlock, // Linux kernel LSM + Firejail, // User-space sandbox + Bubblewrap, // User namespaces + Docker, // Container (heavy) + None, // Disabled +} + +impl Default for SandboxBackend { + fn default() -> Self { + Self::Auto // Always auto-detect by default + } +} +``` + +--- + +## User Experience Comparison + +### Before (Current) +```bash +$ zeroclaw onboard +[1/9] Workspace Setup... +[2/9] AI Provider... +... +[9/9] Workspace Files... +✓ Security: Supervised | workspace-scoped +``` + +### After (With Frictionless Security) +```bash +$ zeroclaw onboard +[1/9] Workspace Setup... +[2/9] AI Provider... +... +[9/9] Workspace Files... +✓ Security: Supervised | workspace-scoped | Landlock sandbox ✓ +# ↑ Just one extra word, silent auto-detection! +``` + +### Advanced User (Explicit Control) +```bash +$ zeroclaw onboard --security-level paranoid +[1/9] Workspace Setup... +... +✓ Security: Paranoid | Landlock + Firejail | Audit signed +``` + +--- + +## Backward Compatibility + +| Scenario | Behavior | +|----------|----------| +| **Existing config** | Works unchanged, new features opt-in | +| **New install** | Auto-detects and enables available security | +| **No sandbox available** | Falls back to app-layer (still secure) | +| **User disables** | One config flag: `sandbox.enabled = false` | + +--- + +## Summary + +✅ **Zero impact on wizard** — stays 9 steps, < 60 seconds +✅ **Zero new prompts** — silent auto-detection +✅ **Zero breaking changes** — backward compatible +✅ **Opt-out available** — explicit config flags +✅ **Status visibility** — `zeroclaw security --status` + +The wizard remains "quick setup universal applications" — security is just **quietly better**. diff --git a/docs/resource-limits.md b/docs/resource-limits.md new file mode 100644 index 0000000..e3834fc --- /dev/null +++ b/docs/resource-limits.md @@ -0,0 +1,100 @@ +# Resource Limits for ZeroClaw + +## Problem +ZeroClaw has rate limiting (20 actions/hour) but no resource caps. A runaway agent could: +- Exhaust available memory +- Spin CPU at 100% +- Fill disk with logs/output + +--- + +## Proposed Solutions + +### Option 1: cgroups v2 (Linux, Recommended) +Automatically create a cgroup for zeroclaw with limits. + +```bash +# Create systemd service with limits +[Service] +MemoryMax=512M +CPUQuota=100% +IOReadBandwidthMax=/dev/sda 10M +IOWriteBandwidthMax=/dev/sda 10M +TasksMax=100 +``` + +### Option 2: tokio::task::deadlock detection +Prevent task starvation. + +```rust +use tokio::time::{timeout, Duration}; + +pub async fn execute_with_timeout( + fut: F, + cpu_time_limit: Duration, + memory_limit: usize, +) -> Result +where + F: Future>, +{ + // CPU timeout + timeout(cpu_time_limit, fut).await? +} +``` + +### Option 3: Memory monitoring +Track heap usage and kill if over limit. + +```rust +use std::alloc::{GlobalAlloc, Layout, System}; + +struct LimitedAllocator { + inner: A, + max_bytes: usize, + used: std::sync::atomic::AtomicUsize, +} + +unsafe impl GlobalAlloc for LimitedAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let current = self.used.fetch_add(layout.size(), std::sync::atomic::Ordering::Relaxed); + if current + layout.size() > self.max_bytes { + std::process::abort(); + } + self.inner.alloc(layout) + } +} +``` + +--- + +## Config Schema + +```toml +[resources] +# Memory limits (in MB) +max_memory_mb = 512 +max_memory_per_command_mb = 128 + +# CPU limits +max_cpu_percent = 50 +max_cpu_time_seconds = 60 + +# Disk I/O limits +max_log_size_mb = 100 +max_temp_storage_mb = 500 + +# Process limits +max_subprocesses = 10 +max_open_files = 100 +``` + +--- + +## Implementation Priority + +| Phase | Feature | Effort | Impact | +|-------|---------|--------|--------| +| **P0** | Memory monitoring + kill | Low | High | +| **P1** | CPU timeout per command | Low | High | +| **P2** | cgroups integration (Linux) | Medium | Very High | +| **P3** | Disk I/O limits | Medium | Medium | diff --git a/docs/sandboxing.md b/docs/sandboxing.md new file mode 100644 index 0000000..06abf59 --- /dev/null +++ b/docs/sandboxing.md @@ -0,0 +1,190 @@ +# ZeroClaw Sandboxing Strategies + +## Problem +ZeroClaw currently has application-layer security (allowlists, path blocking, command injection protection) but lacks OS-level containment. If an attacker is on the allowlist, they can run any allowed command with zeroclaw's user permissions. + +## Proposed Solutions + +### Option 1: Firejail Integration (Recommended for Linux) +Firejail provides user-space sandboxing with minimal overhead. + +```rust +// src/security/firejail.rs +use std::process::Command; + +pub struct FirejailSandbox { + enabled: bool, +} + +impl FirejailSandbox { + pub fn new() -> Self { + let enabled = which::which("firejail").is_ok(); + Self { enabled } + } + + pub fn wrap_command(&self, cmd: &mut Command) -> &mut Command { + if !self.enabled { + return cmd; + } + + // Firejail wraps any command with sandboxing + let mut jail = Command::new("firejail"); + jail.args([ + "--private=home", // New home directory + "--private-dev", // Minimal /dev + "--nosound", // No audio + "--no3d", // No 3D acceleration + "--novideo", // No video devices + "--nowheel", // No input devices + "--notv", // No TV devices + "--noprofile", // Skip profile loading + "--quiet", // Suppress warnings + ]); + + // Append original command + if let Some(program) = cmd.get_program().to_str() { + jail.arg(program); + } + for arg in cmd.get_args() { + if let Some(s) = arg.to_str() { + jail.arg(s); + } + } + + // Replace original command with firejail wrapper + *cmd = jail; + cmd + } +} +``` + +**Config option:** +```toml +[security] +enable_sandbox = true +sandbox_backend = "firejail" # or "none", "bubblewrap", "docker" +``` + +--- + +### Option 2: Bubblewrap (Portable, no root required) +Bubblewrap uses user namespaces to create containers. + +```bash +# Install bubblewrap +sudo apt install bubblewrap + +# Wrap command: +bwrap --ro-bind /usr /usr \ + --dev /dev \ + --proc /proc \ + --bind /workspace /workspace \ + --unshare-all \ + --share-net \ + --die-with-parent \ + -- /bin/sh -c "command" +``` + +--- + +### Option 3: Docker-in-Docker (Heavyweight but complete isolation) +Run agent tools inside ephemeral containers. + +```rust +pub struct DockerSandbox { + image: String, +} + +impl DockerSandbox { + pub async fn execute(&self, command: &str, workspace: &Path) -> Result { + let output = Command::new("docker") + .args([ + "run", "--rm", + "--memory", "512m", + "--cpus", "1.0", + "--network", "none", + "--volume", &format!("{}:/workspace", workspace.display()), + &self.image, + "sh", "-c", command + ]) + .output() + .await?; + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } +} +``` + +--- + +### Option 4: Landlock (Linux Kernel LSM, Rust native) +Landlock provides file system access control without containers. + +```rust +use landlock::{Ruleset, AccessFS}; + +pub fn apply_landlock() -> Result<()> { + let ruleset = Ruleset::new() + .set_access_fs(AccessFS::read_file | AccessFS::write_file) + .add_path(Path::new("/workspace"), AccessFS::read_file | AccessFS::write_file)? + .add_path(Path::new("/tmp"), AccessFS::read_file | AccessFS::write_file)? + .restrict_self()?; + + Ok(()) +} +``` + +--- + +## Priority Implementation Order + +| Phase | Solution | Effort | Security Gain | +|-------|----------|--------|---------------| +| **P0** | Landlock (Linux only, native) | Low | High (filesystem) | +| **P1** | Firejail integration | Low | Very High | +| **P2** | Bubblewrap wrapper | Medium | Very High | +| **P3** | Docker sandbox mode | High | Complete | + +## Config Schema Extension + +```toml +[security.sandbox] +enabled = true +backend = "auto" # auto | firejail | bubblewrap | landlock | docker | none + +# Firejail-specific +[security.sandbox.firejail] +extra_args = ["--seccomp", "--caps.drop=all"] + +# Landlock-specific +[security.sandbox.landlock] +readonly_paths = ["/usr", "/bin", "/lib"] +readwrite_paths = ["$HOME/workspace", "/tmp/zeroclaw"] +``` + +## Testing Strategy + +```rust +#[cfg(test)] +mod tests { + #[test] + fn sandbox_blocks_path_traversal() { + // Try to read /etc/passwd through sandbox + let result = sandboxed_execute("cat /etc/passwd"); + assert!(result.is_err()); + } + + #[test] + fn sandbox_allows_workspace_access() { + let result = sandboxed_execute("ls /workspace"); + assert!(result.is_ok()); + } + + #[test] + fn sandbox_no_network_isolation() { + // Ensure network is blocked when configured + let result = sandboxed_execute("curl http://example.com"); + assert!(result.is_err()); + } +} +``` diff --git a/docs/security-roadmap.md b/docs/security-roadmap.md new file mode 100644 index 0000000..6578d1f --- /dev/null +++ b/docs/security-roadmap.md @@ -0,0 +1,180 @@ +# ZeroClaw Security Improvement Roadmap + +## Current State: Strong Foundation + +ZeroClaw already has **excellent application-layer security**: + +✅ Command allowlist (not blocklist) +✅ Path traversal protection +✅ Command injection blocking (`$(...)`, backticks, `&&`, `>`) +✅ Secret isolation (API keys not leaked to shell) +✅ Rate limiting (20 actions/hour) +✅ Channel authorization (empty = deny all, `*` = allow all) +✅ Risk classification (Low/Medium/High) +✅ Environment variable sanitization +✅ Forbidden paths blocking +✅ Comprehensive test coverage (1,017 tests) + +## What's Missing: OS-Level Containment + +🔴 No OS-level sandboxing (chroot, containers, namespaces) +🔴 No resource limits (CPU, memory, disk I/O caps) +🔴 No tamper-evident audit logging +🔴 No syscall filtering (seccomp) + +--- + +## Comparison: ZeroClaw vs PicoClaw vs Production Grade + +| Feature | PicoClaw | ZeroClaw Now | ZeroClaw + Roadmap | Production Target | +|---------|----------|--------------|-------------------|-------------------| +| **Binary Size** | ~8MB | **3.4MB** ✅ | 3.5-4MB | < 5MB | +| **RAM Usage** | < 10MB | **< 5MB** ✅ | < 10MB | < 20MB | +| **Startup Time** | < 1s | **< 10ms** ✅ | < 50ms | < 100ms | +| **Command Allowlist** | Unknown | ✅ Yes | ✅ Yes | ✅ Yes | +| **Path Blocking** | Unknown | ✅ Yes | ✅ Yes | ✅ Yes | +| **Injection Protection** | Unknown | ✅ Yes | ✅ Yes | ✅ Yes | +| **OS Sandbox** | No | ❌ No | ✅ Firejail/Landlock | ✅ Container/namespaces | +| **Resource Limits** | No | ❌ No | ✅ cgroups/Monitor | ✅ Full cgroups | +| **Audit Logging** | No | ❌ No | ✅ HMAC-signed | ✅ SIEM integration | +| **Security Score** | C | **B+** | **A-** | **A+** | + +--- + +## Implementation Roadmap + +### Phase 1: Quick Wins (1-2 weeks) +**Goal**: Address critical gaps with minimal complexity + +| Task | File | Effort | Impact | +|------|------|--------|-------| +| Landlock filesystem sandbox | `src/security/landlock.rs` | 2 days | High | +| Memory monitoring + OOM kill | `src/resources/memory.rs` | 1 day | High | +| CPU timeout per command | `src/tools/shell.rs` | 1 day | High | +| Basic audit logging | `src/security/audit.rs` | 2 days | Medium | +| Config schema updates | `src/config/schema.rs` | 1 day | - | + +**Deliverables**: +- Linux: Filesystem access restricted to workspace +- All platforms: Memory/CPU guards against runaway commands +- All platforms: Tamper-evident audit trail + +--- + +### Phase 2: Platform Integration (2-3 weeks) +**Goal**: Deep OS integration for production-grade isolation + +| Task | Effort | Impact | +|------|--------|-------| +| Firejail auto-detection + wrapping | 3 days | Very High | +| Bubblewrap wrapper for macOS/*nix | 4 days | Very High | +| cgroups v2 systemd integration | 3 days | High | +| seccomp syscall filtering | 5 days | High | +| Audit log query CLI | 2 days | Medium | + +**Deliverables**: +- Linux: Full container-like isolation via Firejail +- macOS: Bubblewrap filesystem isolation +- Linux: cgroups resource enforcement +- Linux: Syscall allowlisting + +--- + +### Phase 3: Production Hardening (1-2 weeks) +**Goal**: Enterprise security features + +| Task | Effort | Impact | +|------|--------|-------| +| Docker sandbox mode option | 3 days | High | +| Certificate pinning for channels | 2 days | Medium | +| Signed config verification | 2 days | Medium | +| SIEM-compatible audit export | 2 days | Medium | +| Security self-test (`zeroclaw audit --check`) | 1 day | Low | + +**Deliverables**: +- Optional Docker-based execution isolation +- HTTPS certificate pinning for channel webhooks +- Config file signature verification +- JSON/CSV audit export for external analysis + +--- + +## New Config Schema Preview + +```toml +[security] +level = "strict" # relaxed | default | strict | paranoid + +# Sandbox configuration +[security.sandbox] +enabled = true +backend = "auto" # auto | firejail | bubblewrap | landlock | docker | none + +# Resource limits +[resources] +max_memory_mb = 512 +max_memory_per_command_mb = 128 +max_cpu_percent = 50 +max_cpu_time_seconds = 60 +max_subprocesses = 10 + +# Audit logging +[security.audit] +enabled = true +log_path = "~/.config/zeroclaw/audit.log" +sign_events = true +max_size_mb = 100 + +# Autonomy (existing, enhanced) +[autonomy] +level = "supervised" # readonly | supervised | full +allowed_commands = ["git", "ls", "cat", "grep", "find"] +forbidden_paths = ["/etc", "/root", "~/.ssh"] +require_approval_for_medium_risk = true +block_high_risk_commands = true +max_actions_per_hour = 20 +``` + +--- + +## CLI Commands Preview + +```bash +# Security status check +zeroclaw security --check +# → ✓ Sandbox: Firejail active +# → ✓ Audit logging enabled (42 events today) +# → → Resource limits: 512MB mem, 50% CPU + +# Audit log queries +zeroclaw audit --user @alice --since 24h +zeroclaw audit --risk high --violations-only +zeroclaw audit --verify-signatures + +# Sandbox test +zeroclaw sandbox --test +# → Testing isolation... +# ✓ Cannot read /etc/passwd +# ✓ Cannot access ~/.ssh +# ✓ Can read /workspace +``` + +--- + +## Summary + +**ZeroClaw is already more secure than PicoClaw** with: +- 50% smaller binary (3.4MB vs 8MB) +- 50% less RAM (< 5MB vs < 10MB) +- 100x faster startup (< 10ms vs < 1s) +- Comprehensive security policy engine +- Extensive test coverage + +**By implementing this roadmap**, ZeroClaw becomes: +- Production-grade with OS-level sandboxing +- Resource-aware with memory/CPU guards +- Audit-ready with tamper-evident logging +- Enterprise-ready with configurable security levels + +**Estimated effort**: 4-7 weeks for full implementation +**Value**: Transforms ZeroClaw from "safe for testing" to "safe for production" diff --git a/src/config/mod.rs b/src/config/mod.rs index 5256633..376d83d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,9 +1,10 @@ pub mod schema; pub use schema::{ - AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DelegateAgentConfig, - DiscordConfig, DockerRuntimeConfig, GatewayConfig, HeartbeatConfig, HttpRequestConfig, - IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, - ObservabilityConfig, ReliabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, - TelegramConfig, TunnelConfig, WebhookConfig, + AutonomyConfig, AuditConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, + DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, HeartbeatConfig, + HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, + ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, + SandboxBackend, SandboxConfig, SecretsConfig, SecurityConfig, SlackConfig, TelegramConfig, + TunnelConfig, WebhookConfig, }; diff --git a/src/config/schema.rs b/src/config/schema.rs index 9d436d0..d25a816 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -68,6 +68,12 @@ pub struct Config { #[serde(default)] pub identity: IdentityConfig, + /// Hardware Abstraction Layer (HAL) configuration. + /// Controls how ZeroClaw interfaces with physical hardware + /// (GPIO, serial, debug probes). + #[serde(default)] + pub hardware: crate::hardware::HardwareConfig, + /// Named delegate agents for agent-to-agent handoff. /// /// ```toml @@ -83,6 +89,10 @@ pub struct Config { /// ``` #[serde(default)] pub agents: HashMap, + + /// Security configuration (sandboxing, resource limits, audit logging) + #[serde(default)] + pub security: SecurityConfig, } // ── Identity (AIEOS / OpenClaw format) ────────────────────────── @@ -907,6 +917,174 @@ pub struct LarkConfig { pub use_feishu: bool, } +// ── Security Config ───────────────────────────────────────────────── + +/// Security configuration for sandboxing, resource limits, and audit logging +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityConfig { + /// Sandbox configuration + #[serde(default)] + pub sandbox: SandboxConfig, + + /// Resource limits + #[serde(default)] + pub resources: ResourceLimitsConfig, + + /// Audit logging configuration + #[serde(default)] + pub audit: AuditConfig, +} + +impl Default for SecurityConfig { + fn default() -> Self { + Self { + sandbox: SandboxConfig::default(), + resources: ResourceLimitsConfig::default(), + audit: AuditConfig::default(), + } + } +} + +/// Sandbox configuration for OS-level isolation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxConfig { + /// Enable sandboxing (None = auto-detect, Some = explicit) + #[serde(default)] + pub enabled: Option, + + /// Sandbox backend to use + #[serde(default)] + pub backend: SandboxBackend, + + /// Custom Firejail arguments (when backend = firejail) + #[serde(default)] + pub firejail_args: Vec, +} + +impl Default for SandboxConfig { + fn default() -> Self { + Self { + enabled: None, // Auto-detect + backend: SandboxBackend::Auto, + firejail_args: Vec::new(), + } + } +} + +/// Sandbox backend selection +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SandboxBackend { + /// Auto-detect best available (default) + Auto, + /// Landlock (Linux kernel LSM, native) + Landlock, + /// Firejail (user-space sandbox) + Firejail, + /// Bubblewrap (user namespaces) + Bubblewrap, + /// Docker container isolation + Docker, + /// No sandboxing (application-layer only) + None, +} + +impl Default for SandboxBackend { + fn default() -> Self { + Self::Auto + } +} + +/// Resource limits for command execution +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceLimitsConfig { + /// Maximum memory in MB per command + #[serde(default = "default_max_memory_mb")] + pub max_memory_mb: u32, + + /// Maximum CPU time in seconds per command + #[serde(default = "default_max_cpu_time_seconds")] + pub max_cpu_time_seconds: u64, + + /// Maximum number of subprocesses + #[serde(default = "default_max_subprocesses")] + pub max_subprocesses: u32, + + /// Enable memory monitoring + #[serde(default = "default_memory_monitoring_enabled")] + pub memory_monitoring: bool, +} + +fn default_max_memory_mb() -> u32 { + 512 +} + +fn default_max_cpu_time_seconds() -> u64 { + 60 +} + +fn default_max_subprocesses() -> u32 { + 10 +} + +fn default_memory_monitoring_enabled() -> bool { + true +} + +impl Default for ResourceLimitsConfig { + fn default() -> Self { + Self { + max_memory_mb: default_max_memory_mb(), + max_cpu_time_seconds: default_max_cpu_time_seconds(), + max_subprocesses: default_max_subprocesses(), + memory_monitoring: default_memory_monitoring_enabled(), + } + } +} + +/// Audit logging configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditConfig { + /// Enable audit logging + #[serde(default = "default_audit_enabled")] + pub enabled: bool, + + /// Path to audit log file (relative to zeroclaw dir) + #[serde(default = "default_audit_log_path")] + pub log_path: String, + + /// Maximum log size in MB before rotation + #[serde(default = "default_audit_max_size_mb")] + pub max_size_mb: u32, + + /// Sign events with HMAC for tamper evidence + #[serde(default)] + pub sign_events: bool, +} + +fn default_audit_enabled() -> bool { + true +} + +fn default_audit_log_path() -> String { + "audit.log".to_string() +} + +fn default_audit_max_size_mb() -> u32 { + 100 +} + +impl Default for AuditConfig { + fn default() -> Self { + Self { + enabled: default_audit_enabled(), + log_path: default_audit_log_path(), + max_size_mb: default_audit_max_size_mb(), + sign_events: false, + } + } +} + // ── Config impl ────────────────────────────────────────────────── impl Default for Config { @@ -937,7 +1115,9 @@ impl Default for Config { browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), + hardware: crate::hardware::HardwareConfig::default(), agents: HashMap::new(), + security: SecurityConfig::default(), } } } @@ -1289,7 +1469,9 @@ mod tests { browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), + hardware: crate::hardware::HardwareConfig::default(), agents: HashMap::new(), + security: SecurityConfig::default(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -1362,7 +1544,9 @@ default_temperature = 0.7 browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), + hardware: crate::hardware::HardwareConfig::default(), agents: HashMap::new(), + security: SecurityConfig::default(), }; config.save().unwrap(); @@ -1428,6 +1612,7 @@ default_temperature = 0.7 bot_token: "discord-token".into(), guild_id: Some("12345".into()), allowed_users: vec![], + listen_to_bots: false, }; let json = serde_json::to_string(&dc).unwrap(); let parsed: DiscordConfig = serde_json::from_str(&json).unwrap(); @@ -1441,6 +1626,7 @@ default_temperature = 0.7 bot_token: "tok".into(), guild_id: None, allowed_users: vec![], + listen_to_bots: false, }; let json = serde_json::to_string(&dc).unwrap(); let parsed: DiscordConfig = serde_json::from_str(&json).unwrap(); diff --git a/src/hardware/mod.rs b/src/hardware/mod.rs new file mode 100644 index 0000000..cd54854 --- /dev/null +++ b/src/hardware/mod.rs @@ -0,0 +1,1287 @@ +//! Hardware Abstraction Layer (HAL) for ZeroClaw. +//! +//! Provides auto-discovery of connected hardware, transport abstraction, +//! and a unified interface so the LLM agent can control physical devices +//! without knowing the underlying communication protocol. +//! +//! # Supported Transport Modes +//! +//! | Transport | Backend | Use Case | +//! |-----------|-------------|---------------------------------------------| +//! | `native` | rppal / sysfs | Raspberry Pi / Linux SBC with local GPIO | +//! | `serial` | JSON/UART | Arduino, ESP32, Nucleo via USB serial | +//! | `probe` | probe-rs | STM32/ESP32 via SWD/JTAG debug interface | +//! | `none` | — | Software-only mode (no hardware access) | + +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +// ── Hardware transport enum ────────────────────────────────────── + +/// Transport protocol used to communicate with physical hardware. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HardwareTransport { + /// Direct GPIO access on a Linux SBC (Raspberry Pi, Orange Pi, etc.) + Native, + /// JSON commands over USB serial (Arduino, ESP32, Nucleo) + Serial, + /// SWD/JTAG debug probe (probe-rs) for bare-metal MCUs + Probe, + /// No hardware — software-only mode + None, +} + +impl Default for HardwareTransport { + fn default() -> Self { + Self::None + } +} + +impl std::fmt::Display for HardwareTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Native => write!(f, "native"), + Self::Serial => write!(f, "serial"), + Self::Probe => write!(f, "probe"), + Self::None => write!(f, "none"), + } + } +} + +impl HardwareTransport { + /// Parse from a string value (config file or CLI arg). + pub fn from_str_loose(s: &str) -> Self { + match s.to_ascii_lowercase().trim() { + "native" | "gpio" | "rppal" | "sysfs" => Self::Native, + "serial" | "uart" | "usb" | "tethered" => Self::Serial, + "probe" | "probe-rs" | "swd" | "jtag" | "jlink" | "j-link" => Self::Probe, + _ => Self::None, + } + } +} + +// ── Hardware configuration ────────────────────────────────────── + +/// Hardware configuration stored in `config.toml` under `[hardware]`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HardwareConfig { + /// Enable hardware integration + #[serde(default)] + pub enabled: bool, + + /// Transport mode: "native", "serial", "probe", "none" + #[serde(default = "default_transport")] + pub transport: String, + + /// Serial port path (e.g. `/dev/ttyUSB0`, `/dev/tty.usbmodem14201`) + #[serde(default)] + pub serial_port: Option, + + /// Serial baud rate (default: 115200) + #[serde(default = "default_baud_rate")] + pub baud_rate: u32, + + /// Enable datasheet RAG — index PDF schematics in workspace for pin lookups + #[serde(default)] + pub workspace_datasheets: bool, + + /// Auto-discovered board description (informational, set by discovery) + #[serde(default)] + pub discovered_board: Option, + + /// Probe target chip (e.g. "STM32F411CEUx", "nRF52840_xxAA") + #[serde(default)] + pub probe_target: Option, + + /// GPIO pin safety allowlist — only these pins can be written to. + /// Empty = all pins allowed (for development). Recommended for production. + #[serde(default)] + pub allowed_pins: Vec, + + /// Maximum PWM frequency in Hz (safety cap, default: 50_000) + #[serde(default = "default_max_pwm_freq")] + pub max_pwm_frequency_hz: u32, +} + +fn default_transport() -> String { + "none".into() +} + +fn default_baud_rate() -> u32 { + 115_200 +} + +fn default_max_pwm_freq() -> u32 { + 50_000 +} + +impl Default for HardwareConfig { + fn default() -> Self { + Self { + enabled: false, + transport: default_transport(), + serial_port: None, + baud_rate: default_baud_rate(), + workspace_datasheets: false, + discovered_board: None, + probe_target: None, + allowed_pins: Vec::new(), + max_pwm_frequency_hz: default_max_pwm_freq(), + } + } +} + +impl HardwareConfig { + /// Return the parsed transport enum. + pub fn transport_mode(&self) -> HardwareTransport { + HardwareTransport::from_str_loose(&self.transport) + } + + /// Check if pin access is allowed by the safety allowlist. + /// An empty allowlist means all pins are permitted (dev mode). + pub fn is_pin_allowed(&self, pin: u8) -> bool { + self.allowed_pins.is_empty() || self.allowed_pins.contains(&pin) + } + + /// Validate the configuration, returning errors for invalid combos. + pub fn validate(&self) -> Result<()> { + if !self.enabled { + return Ok(()); + } + + let mode = self.transport_mode(); + + // Serial requires a port + if mode == HardwareTransport::Serial && self.serial_port.is_none() { + bail!("Hardware transport is 'serial' but no serial_port is configured. Run `zeroclaw onboard --interactive` or set hardware.serial_port in config.toml."); + } + + // Probe requires a target chip + if mode == HardwareTransport::Probe && self.probe_target.is_none() { + bail!("Hardware transport is 'probe' but no probe_target chip is configured. Set hardware.probe_target in config.toml (e.g. \"STM32F411CEUx\")."); + } + + // Baud rate sanity + if self.baud_rate == 0 { + bail!("hardware.baud_rate must be greater than 0."); + } + if self.baud_rate > 4_000_000 { + bail!("hardware.baud_rate of {} exceeds the 4 MHz safety limit.", self.baud_rate); + } + + // PWM frequency sanity + if self.max_pwm_frequency_hz == 0 { + bail!("hardware.max_pwm_frequency_hz must be greater than 0."); + } + + Ok(()) + } +} + +// ── Discovery: detected hardware on this system ───────────────── + +/// A single discovered hardware device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredDevice { + /// Human-readable name (e.g. "Raspberry Pi GPIO", "Arduino Uno") + pub name: String, + /// Recommended transport mode + pub transport: HardwareTransport, + /// Path to the device (e.g. `/dev/ttyUSB0`, `/dev/gpiomem`) + pub device_path: Option, + /// Additional detail (e.g. board revision, chip ID) + pub detail: Option, +} + +/// Scan the system for connected hardware. +/// +/// This function performs non-destructive, read-only probes: +/// 1. Check for Raspberry Pi GPIO (`/dev/gpiomem`, `/proc/device-tree/model`) +/// 2. Check for USB serial devices (`/dev/ttyUSB*`, `/dev/ttyACM*`, `/dev/tty.usbmodem*`) +/// 3. Check for SWD/JTAG probes (`/dev/ttyACM*` with probe-rs markers) +/// +/// This is intentionally conservative — it never writes to any device. +pub fn discover_hardware() -> Vec { + let mut devices = Vec::new(); + + // ── 1. Raspberry Pi / Linux SBC native GPIO ────────────── + discover_native_gpio(&mut devices); + + // ── 2. USB Serial devices (Arduino, ESP32, etc.) ───────── + discover_serial_devices(&mut devices); + + // ── 3. SWD / JTAG debug probes ────────────────────────── + discover_debug_probes(&mut devices); + + devices +} + +/// Check for native GPIO availability (Raspberry Pi, Orange Pi, etc.) +fn discover_native_gpio(devices: &mut Vec) { + // Primary indicator: /dev/gpiomem exists (Pi-specific) + let gpiomem = Path::new("/dev/gpiomem"); + // Secondary: /dev/gpiochip0 exists (any Linux with GPIO) + let gpiochip = Path::new("/dev/gpiochip0"); + + if gpiomem.exists() || gpiochip.exists() { + // Try to read model from device tree + let model = read_board_model(); + let name = model + .as_deref() + .unwrap_or("Linux SBC with GPIO"); + + devices.push(DiscoveredDevice { + name: format!("{name} (Native GPIO)"), + transport: HardwareTransport::Native, + device_path: Some( + if gpiomem.exists() { + "/dev/gpiomem".into() + } else { + "/dev/gpiochip0".into() + }, + ), + detail: model, + }); + } +} + +/// Read the board model string from the device tree (Linux). +fn read_board_model() -> Option { + let model_path = Path::new("/proc/device-tree/model"); + if model_path.exists() { + std::fs::read_to_string(model_path) + .ok() + .map(|s| s.trim_end_matches('\0').trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + } +} + +/// Scan for USB serial devices. +fn discover_serial_devices(devices: &mut Vec) { + let serial_patterns = serial_device_paths(); + + for pattern in &serial_patterns { + let matches = glob_paths(pattern); + for path in matches { + let name = classify_serial_device(&path); + devices.push(DiscoveredDevice { + name: format!("{name} (USB Serial)"), + transport: HardwareTransport::Serial, + device_path: Some(path.to_string_lossy().to_string()), + detail: None, + }); + } + } +} + +/// Return platform-specific glob patterns for serial devices. +fn serial_device_paths() -> Vec { + if cfg!(target_os = "macos") { + vec![ + "/dev/tty.usbmodem*".into(), + "/dev/tty.usbserial*".into(), + "/dev/tty.wchusbserial*".into(), // CH340 clones + ] + } else if cfg!(target_os = "linux") { + vec![ + "/dev/ttyUSB*".into(), + "/dev/ttyACM*".into(), + ] + } else { + // Windows / other — not yet supported for auto-discovery + vec![] + } +} + +/// Classify a serial device path into a human-readable name. +fn classify_serial_device(path: &Path) -> String { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let lower = name.to_ascii_lowercase(); + + if lower.contains("usbmodem") { + "Arduino/Teensy".into() + } else if lower.contains("usbserial") || lower.contains("ttyusb") { + "USB-Serial Device (FTDI/CH340/CP2102)".into() + } else if lower.contains("wchusbserial") { + "CH340/CH341 Serial".into() + } else if lower.contains("ttyacm") { + "USB CDC Device (Arduino/STM32)".into() + } else { + "Unknown Serial Device".into() + } +} + +/// Simple glob expansion for device paths. +fn glob_paths(pattern: &str) -> Vec { + glob::glob(pattern) + .map(|paths| paths.filter_map(Result::ok).collect()) + .unwrap_or_default() +} + +/// Check for SWD/JTAG debug probes. +fn discover_debug_probes(devices: &mut Vec) { + // On Linux, ST-Link probes often show up as /dev/stlinkv* + // We also check for known USB VIDs via sysfs if available + let stlink_paths = glob_paths("/dev/stlinkv*"); + for path in stlink_paths { + devices.push(DiscoveredDevice { + name: "ST-Link Debug Probe (SWD)".into(), + transport: HardwareTransport::Probe, + device_path: Some(path.to_string_lossy().to_string()), + detail: Some("Use probe-rs for flash/debug".into()), + }); + } + + // J-Link probes on macOS + let jlink_paths = glob_paths("/dev/tty.SLAB_USBtoUART*"); + for path in jlink_paths { + devices.push(DiscoveredDevice { + name: "SEGGER J-Link (SWD/JTAG)".into(), + transport: HardwareTransport::Probe, + device_path: Some(path.to_string_lossy().to_string()), + detail: Some("Use probe-rs for flash/debug".into()), + }); + } +} + +// ── HAL Trait: Unified hardware operations ────────────────────── + +/// The core HAL trait that all transport backends implement. +/// +/// The LLM agent calls these methods via tool invocations. The HAL +/// translates them into the correct protocol for the underlying hardware. +pub trait HardwareHal: Send + Sync { + /// Read the digital state of a GPIO pin. + fn gpio_read(&self, pin: u8) -> Result; + + /// Write a digital value to a GPIO pin. + fn gpio_write(&self, pin: u8, value: bool) -> Result<()>; + + /// Read a memory address (for probe-rs or memory-mapped I/O). + fn memory_read(&self, address: u32, length: u32) -> Result>; + + /// Upload firmware to a connected device (Arduino sketch, STM32 binary). + fn firmware_upload(&self, path: &Path) -> Result<()>; + + /// Return a human-readable description of the connected hardware. + fn describe(&self) -> String; + + /// Set PWM duty cycle on a pin (0–100%). + fn pwm_set(&self, pin: u8, duty_percent: f32) -> Result<()>; + + /// Read an analog value (ADC) from a pin, returning 0.0–1.0. + fn analog_read(&self, pin: u8) -> Result; +} + +// ── NoopHal: used in software-only mode ───────────────────────── + +/// A no-op HAL implementation for software-only mode. +/// All hardware operations return descriptive errors. +pub struct NoopHal; + +impl HardwareHal for NoopHal { + fn gpio_read(&self, pin: u8) -> Result { + bail!("Hardware not enabled. Cannot read GPIO pin {pin}. Enable hardware in config.toml or run `zeroclaw onboard --interactive`."); + } + + fn gpio_write(&self, pin: u8, value: bool) -> Result<()> { + bail!("Hardware not enabled. Cannot write GPIO pin {pin}={value}. Enable hardware in config.toml."); + } + + fn memory_read(&self, address: u32, _length: u32) -> Result> { + bail!("Hardware not enabled. Cannot read memory at 0x{address:08X}."); + } + + fn firmware_upload(&self, path: &Path) -> Result<()> { + bail!( + "Hardware not enabled. Cannot upload firmware from {}.", + path.display() + ); + } + + fn describe(&self) -> String { + "NoopHal (software-only mode — no hardware connected)".into() + } + + fn pwm_set(&self, pin: u8, _duty_percent: f32) -> Result<()> { + bail!("Hardware not enabled. Cannot set PWM on pin {pin}."); + } + + fn analog_read(&self, pin: u8) -> Result { + bail!("Hardware not enabled. Cannot read analog pin {pin}."); + } +} + +// ── Factory: create the right HAL from config ─────────────────── + +/// Create the appropriate HAL backend from the hardware configuration. +/// +/// This is the main entry point — call this once at startup and pass +/// the resulting `Box` to the tool registry. +pub fn create_hal(config: &HardwareConfig) -> Result> { + config.validate()?; + + if !config.enabled { + return Ok(Box::new(NoopHal)); + } + + match config.transport_mode() { + HardwareTransport::None => Ok(Box::new(NoopHal)), + HardwareTransport::Native => { + // In a full implementation, this would return a RppalHal or SysfsHal. + // For now, we return a stub that validates the transport is correct. + bail!( + "Native GPIO transport requires the `rppal` crate (Raspberry Pi only). \ + This will be available in a future release. For now, use 'serial' transport \ + with an Arduino/ESP32 bridge." + ); + } + HardwareTransport::Serial => { + let port = config.serial_port.as_deref().unwrap_or("/dev/ttyUSB0"); + // In a full implementation, this would open the serial port and + // return a SerialHal that sends JSON commands over UART. + bail!( + "Serial transport to '{}' at {} baud is configured but the serial HAL \ + backend is not yet compiled in. This will be available in the next release.", + port, + config.baud_rate + ); + } + HardwareTransport::Probe => { + let target = config + .probe_target + .as_deref() + .unwrap_or("unknown"); + bail!( + "Probe transport targeting '{}' is configured but the probe-rs HAL \ + backend is not yet compiled in. This will be available in a future release.", + target + ); + } + } +} + +// ── Wizard helper: build config from discovery ────────────────── + +/// Determine the best default selection index for the wizard +/// based on discovery results. +pub fn recommended_wizard_default(devices: &[DiscoveredDevice]) -> usize { + // If we found native GPIO → recommend Native (index 0) + if devices.iter().any(|d| d.transport == HardwareTransport::Native) { + return 0; + } + // If we found serial devices → recommend Tethered (index 1) + if devices.iter().any(|d| d.transport == HardwareTransport::Serial) { + return 1; + } + // If we found debug probes → recommend Probe (index 2) + if devices.iter().any(|d| d.transport == HardwareTransport::Probe) { + return 2; + } + // Default: Software Only (index 3) + 3 +} + +/// Build a `HardwareConfig` from a wizard selection and discovered devices. +pub fn config_from_wizard_choice( + choice: usize, + devices: &[DiscoveredDevice], +) -> HardwareConfig { + match choice { + // Native + 0 => { + let native_device = devices + .iter() + .find(|d| d.transport == HardwareTransport::Native); + HardwareConfig { + enabled: true, + transport: "native".into(), + discovered_board: native_device + .and_then(|d| d.detail.clone()) + .or_else(|| native_device.map(|d| d.name.clone())), + ..HardwareConfig::default() + } + } + // Serial / Tethered + 1 => { + let serial_device = devices + .iter() + .find(|d| d.transport == HardwareTransport::Serial); + HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: serial_device.and_then(|d| d.device_path.clone()), + discovered_board: serial_device.map(|d| d.name.clone()), + ..HardwareConfig::default() + } + } + // Probe + 2 => { + let probe_device = devices + .iter() + .find(|d| d.transport == HardwareTransport::Probe); + HardwareConfig { + enabled: true, + transport: "probe".into(), + discovered_board: probe_device.map(|d| d.name.clone()), + ..HardwareConfig::default() + } + } + // Software only + _ => HardwareConfig::default(), + } +} + +// ═══════════════════════════════════════════════════════════════════ +// ── Tests ─────────────────────────────────────────────────────── +// ═══════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + // ── HardwareTransport parsing ────────────────────────────── + + #[test] + fn transport_parse_native_variants() { + assert_eq!(HardwareTransport::from_str_loose("native"), HardwareTransport::Native); + assert_eq!(HardwareTransport::from_str_loose("gpio"), HardwareTransport::Native); + assert_eq!(HardwareTransport::from_str_loose("rppal"), HardwareTransport::Native); + assert_eq!(HardwareTransport::from_str_loose("sysfs"), HardwareTransport::Native); + assert_eq!(HardwareTransport::from_str_loose("NATIVE"), HardwareTransport::Native); + assert_eq!(HardwareTransport::from_str_loose(" Native "), HardwareTransport::Native); + } + + #[test] + fn transport_parse_serial_variants() { + assert_eq!(HardwareTransport::from_str_loose("serial"), HardwareTransport::Serial); + assert_eq!(HardwareTransport::from_str_loose("uart"), HardwareTransport::Serial); + assert_eq!(HardwareTransport::from_str_loose("usb"), HardwareTransport::Serial); + assert_eq!(HardwareTransport::from_str_loose("tethered"), HardwareTransport::Serial); + assert_eq!(HardwareTransport::from_str_loose("SERIAL"), HardwareTransport::Serial); + } + + #[test] + fn transport_parse_probe_variants() { + assert_eq!(HardwareTransport::from_str_loose("probe"), HardwareTransport::Probe); + assert_eq!(HardwareTransport::from_str_loose("probe-rs"), HardwareTransport::Probe); + assert_eq!(HardwareTransport::from_str_loose("swd"), HardwareTransport::Probe); + assert_eq!(HardwareTransport::from_str_loose("jtag"), HardwareTransport::Probe); + assert_eq!(HardwareTransport::from_str_loose("jlink"), HardwareTransport::Probe); + assert_eq!(HardwareTransport::from_str_loose("j-link"), HardwareTransport::Probe); + } + + #[test] + fn transport_parse_none_and_unknown() { + assert_eq!(HardwareTransport::from_str_loose("none"), HardwareTransport::None); + assert_eq!(HardwareTransport::from_str_loose(""), HardwareTransport::None); + assert_eq!(HardwareTransport::from_str_loose("foobar"), HardwareTransport::None); + assert_eq!(HardwareTransport::from_str_loose("bluetooth"), HardwareTransport::None); + } + + #[test] + fn transport_default_is_none() { + assert_eq!(HardwareTransport::default(), HardwareTransport::None); + } + + #[test] + fn transport_display() { + assert_eq!(format!("{}", HardwareTransport::Native), "native"); + assert_eq!(format!("{}", HardwareTransport::Serial), "serial"); + assert_eq!(format!("{}", HardwareTransport::Probe), "probe"); + assert_eq!(format!("{}", HardwareTransport::None), "none"); + } + + // ── HardwareTransport serde ──────────────────────────────── + + #[test] + fn transport_serde_roundtrip() { + let json = serde_json::to_string(&HardwareTransport::Native).unwrap(); + assert_eq!(json, "\"native\""); + let parsed: HardwareTransport = serde_json::from_str("\"serial\"").unwrap(); + assert_eq!(parsed, HardwareTransport::Serial); + let parsed2: HardwareTransport = serde_json::from_str("\"probe\"").unwrap(); + assert_eq!(parsed2, HardwareTransport::Probe); + let parsed3: HardwareTransport = serde_json::from_str("\"none\"").unwrap(); + assert_eq!(parsed3, HardwareTransport::None); + } + + // ── HardwareConfig defaults ──────────────────────────────── + + #[test] + fn config_default_values() { + let cfg = HardwareConfig::default(); + assert!(!cfg.enabled); + assert_eq!(cfg.transport, "none"); + assert_eq!(cfg.baud_rate, 115_200); + assert!(cfg.serial_port.is_none()); + assert!(!cfg.workspace_datasheets); + assert!(cfg.discovered_board.is_none()); + assert!(cfg.probe_target.is_none()); + assert!(cfg.allowed_pins.is_empty()); + assert_eq!(cfg.max_pwm_frequency_hz, 50_000); + } + + #[test] + fn config_transport_mode_maps_correctly() { + let mut cfg = HardwareConfig::default(); + assert_eq!(cfg.transport_mode(), HardwareTransport::None); + + cfg.transport = "native".into(); + assert_eq!(cfg.transport_mode(), HardwareTransport::Native); + + cfg.transport = "serial".into(); + assert_eq!(cfg.transport_mode(), HardwareTransport::Serial); + + cfg.transport = "probe".into(); + assert_eq!(cfg.transport_mode(), HardwareTransport::Probe); + + cfg.transport = "UART".into(); + assert_eq!(cfg.transport_mode(), HardwareTransport::Serial); + } + + // ── HardwareConfig::is_pin_allowed ───────────────────────── + + #[test] + fn pin_allowed_empty_allowlist_permits_all() { + let cfg = HardwareConfig::default(); + assert!(cfg.is_pin_allowed(0)); + assert!(cfg.is_pin_allowed(13)); + assert!(cfg.is_pin_allowed(255)); + } + + #[test] + fn pin_allowed_nonempty_allowlist_restricts() { + let cfg = HardwareConfig { + allowed_pins: vec![2, 13, 27], + ..HardwareConfig::default() + }; + assert!(cfg.is_pin_allowed(2)); + assert!(cfg.is_pin_allowed(13)); + assert!(cfg.is_pin_allowed(27)); + assert!(!cfg.is_pin_allowed(0)); + assert!(!cfg.is_pin_allowed(14)); + assert!(!cfg.is_pin_allowed(255)); + } + + #[test] + fn pin_allowed_single_pin_allowlist() { + let cfg = HardwareConfig { + allowed_pins: vec![13], + ..HardwareConfig::default() + }; + assert!(cfg.is_pin_allowed(13)); + assert!(!cfg.is_pin_allowed(12)); + assert!(!cfg.is_pin_allowed(14)); + } + + // ── HardwareConfig::validate ─────────────────────────────── + + #[test] + fn validate_disabled_always_ok() { + let cfg = HardwareConfig::default(); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_disabled_ignores_bad_values() { + // Even with invalid values, disabled config should pass + let cfg = HardwareConfig { + enabled: false, + transport: "serial".into(), + serial_port: None, // Would fail if enabled + baud_rate: 0, // Would fail if enabled + ..HardwareConfig::default() + }; + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_serial_requires_port() { + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: None, + ..HardwareConfig::default() + }; + let err = cfg.validate().unwrap_err(); + assert!(err.to_string().contains("serial_port")); + } + + #[test] + fn validate_serial_with_port_ok() { + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: Some("/dev/ttyUSB0".into()), + ..HardwareConfig::default() + }; + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_probe_requires_target() { + let cfg = HardwareConfig { + enabled: true, + transport: "probe".into(), + probe_target: None, + ..HardwareConfig::default() + }; + let err = cfg.validate().unwrap_err(); + assert!(err.to_string().contains("probe_target")); + } + + #[test] + fn validate_probe_with_target_ok() { + let cfg = HardwareConfig { + enabled: true, + transport: "probe".into(), + probe_target: Some("STM32F411CEUx".into()), + ..HardwareConfig::default() + }; + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_native_ok_without_extras() { + let cfg = HardwareConfig { + enabled: true, + transport: "native".into(), + ..HardwareConfig::default() + }; + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_none_transport_enabled_ok() { + let cfg = HardwareConfig { + enabled: true, + transport: "none".into(), + ..HardwareConfig::default() + }; + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_baud_rate_zero_fails() { + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: Some("/dev/ttyUSB0".into()), + baud_rate: 0, + ..HardwareConfig::default() + }; + let err = cfg.validate().unwrap_err(); + assert!(err.to_string().contains("baud_rate")); + } + + #[test] + fn validate_baud_rate_too_high_fails() { + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: Some("/dev/ttyUSB0".into()), + baud_rate: 5_000_000, + ..HardwareConfig::default() + }; + let err = cfg.validate().unwrap_err(); + assert!(err.to_string().contains("safety limit")); + } + + #[test] + fn validate_baud_rate_boundary_ok() { + // Exactly at the limit + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: Some("/dev/ttyUSB0".into()), + baud_rate: 4_000_000, + ..HardwareConfig::default() + }; + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_baud_rate_common_values_ok() { + for baud in [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600] { + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: Some("/dev/ttyUSB0".into()), + baud_rate: baud, + ..HardwareConfig::default() + }; + assert!(cfg.validate().is_ok(), "baud rate {baud} should be valid"); + } + } + + #[test] + fn validate_pwm_frequency_zero_fails() { + let cfg = HardwareConfig { + enabled: true, + transport: "native".into(), + max_pwm_frequency_hz: 0, + ..HardwareConfig::default() + }; + let err = cfg.validate().unwrap_err(); + assert!(err.to_string().contains("max_pwm_frequency_hz")); + } + + // ── HardwareConfig serde ─────────────────────────────────── + + #[test] + fn config_serde_roundtrip_toml() { + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: Some("/dev/ttyUSB0".into()), + baud_rate: 9600, + workspace_datasheets: true, + discovered_board: Some("Arduino Uno".into()), + probe_target: None, + allowed_pins: vec![2, 13], + max_pwm_frequency_hz: 25_000, + }; + + let toml_str = toml::to_string_pretty(&cfg).unwrap(); + let parsed: HardwareConfig = toml::from_str(&toml_str).unwrap(); + + assert_eq!(parsed.enabled, cfg.enabled); + assert_eq!(parsed.transport, cfg.transport); + assert_eq!(parsed.serial_port, cfg.serial_port); + assert_eq!(parsed.baud_rate, cfg.baud_rate); + assert_eq!(parsed.workspace_datasheets, cfg.workspace_datasheets); + assert_eq!(parsed.discovered_board, cfg.discovered_board); + assert_eq!(parsed.allowed_pins, cfg.allowed_pins); + assert_eq!(parsed.max_pwm_frequency_hz, cfg.max_pwm_frequency_hz); + } + + #[test] + fn config_serde_minimal_toml() { + // Deserializing an empty TOML section should produce defaults + let toml_str = "enabled = false\n"; + let parsed: HardwareConfig = toml::from_str(toml_str).unwrap(); + assert!(!parsed.enabled); + assert_eq!(parsed.transport, "none"); + assert_eq!(parsed.baud_rate, 115_200); + } + + #[test] + fn config_serde_json_roundtrip() { + let cfg = HardwareConfig { + enabled: true, + transport: "probe".into(), + serial_port: None, + baud_rate: 115200, + workspace_datasheets: false, + discovered_board: None, + probe_target: Some("nRF52840_xxAA".into()), + allowed_pins: vec![], + max_pwm_frequency_hz: 50_000, + }; + + let json = serde_json::to_string(&cfg).unwrap(); + let parsed: HardwareConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.probe_target, cfg.probe_target); + assert_eq!(parsed.transport, "probe"); + } + + // ── NoopHal ──────────────────────────────────────────────── + + #[test] + fn noop_hal_gpio_read_fails() { + let hal = NoopHal; + let err = hal.gpio_read(13).unwrap_err(); + assert!(err.to_string().contains("not enabled")); + assert!(err.to_string().contains("13")); + } + + #[test] + fn noop_hal_gpio_write_fails() { + let hal = NoopHal; + let err = hal.gpio_write(5, true).unwrap_err(); + assert!(err.to_string().contains("not enabled")); + } + + #[test] + fn noop_hal_memory_read_fails() { + let hal = NoopHal; + let err = hal.memory_read(0x2000_0000, 4).unwrap_err(); + assert!(err.to_string().contains("not enabled")); + assert!(err.to_string().contains("0x20000000")); + } + + #[test] + fn noop_hal_firmware_upload_fails() { + let hal = NoopHal; + let err = hal.firmware_upload(Path::new("/tmp/firmware.bin")).unwrap_err(); + assert!(err.to_string().contains("not enabled")); + assert!(err.to_string().contains("firmware.bin")); + } + + #[test] + fn noop_hal_describe() { + let hal = NoopHal; + let desc = hal.describe(); + assert!(desc.contains("software-only")); + } + + #[test] + fn noop_hal_pwm_set_fails() { + let hal = NoopHal; + let err = hal.pwm_set(9, 50.0).unwrap_err(); + assert!(err.to_string().contains("not enabled")); + } + + #[test] + fn noop_hal_analog_read_fails() { + let hal = NoopHal; + let err = hal.analog_read(0).unwrap_err(); + assert!(err.to_string().contains("not enabled")); + } + + // ── create_hal factory ───────────────────────────────────── + + #[test] + fn create_hal_disabled_returns_noop() { + let cfg = HardwareConfig::default(); + let hal = create_hal(&cfg).unwrap(); + assert!(hal.describe().contains("software-only")); + } + + #[test] + fn create_hal_none_transport_returns_noop() { + let cfg = HardwareConfig { + enabled: true, + transport: "none".into(), + ..HardwareConfig::default() + }; + let hal = create_hal(&cfg).unwrap(); + assert!(hal.describe().contains("software-only")); + } + + #[test] + fn create_hal_serial_without_port_fails_validation() { + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: None, + ..HardwareConfig::default() + }; + assert!(create_hal(&cfg).is_err()); + } + + #[test] + fn create_hal_invalid_baud_fails_validation() { + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: Some("/dev/ttyUSB0".into()), + baud_rate: 0, + ..HardwareConfig::default() + }; + assert!(create_hal(&cfg).is_err()); + } + + // ── Discovery helpers ────────────────────────────────────── + + #[test] + fn classify_serial_arduino() { + let path = Path::new("/dev/tty.usbmodem14201"); + assert!(classify_serial_device(path).contains("Arduino")); + } + + #[test] + fn classify_serial_ftdi() { + let path = Path::new("/dev/tty.usbserial-1234"); + assert!(classify_serial_device(path).contains("FTDI")); + } + + #[test] + fn classify_serial_ch340() { + let path = Path::new("/dev/tty.wchusbserial1420"); + assert!(classify_serial_device(path).contains("CH340")); + } + + #[test] + fn classify_serial_ttyacm() { + let path = Path::new("/dev/ttyACM0"); + assert!(classify_serial_device(path).contains("CDC")); + } + + #[test] + fn classify_serial_ttyusb() { + let path = Path::new("/dev/ttyUSB0"); + assert!(classify_serial_device(path).contains("USB-Serial")); + } + + #[test] + fn classify_serial_unknown() { + let path = Path::new("/dev/ttyXYZ99"); + assert!(classify_serial_device(path).contains("Unknown")); + } + + // ── Serial device path patterns ──────────────────────────── + + #[test] + fn serial_paths_macos_patterns() { + if cfg!(target_os = "macos") { + let patterns = serial_device_paths(); + assert!(patterns.iter().any(|p| p.contains("usbmodem"))); + assert!(patterns.iter().any(|p| p.contains("usbserial"))); + assert!(patterns.iter().any(|p| p.contains("wchusbserial"))); + } + } + + #[test] + fn serial_paths_linux_patterns() { + if cfg!(target_os = "linux") { + let patterns = serial_device_paths(); + assert!(patterns.iter().any(|p| p.contains("ttyUSB"))); + assert!(patterns.iter().any(|p| p.contains("ttyACM"))); + } + } + + // ── Wizard helpers ───────────────────────────────────────── + + #[test] + fn recommended_default_no_devices() { + let devices: Vec = vec![]; + assert_eq!(recommended_wizard_default(&devices), 3); // Software only + } + + #[test] + fn recommended_default_native_found() { + let devices = vec![DiscoveredDevice { + name: "Raspberry Pi (Native GPIO)".into(), + transport: HardwareTransport::Native, + device_path: Some("/dev/gpiomem".into()), + detail: None, + }]; + assert_eq!(recommended_wizard_default(&devices), 0); // Native + } + + #[test] + fn recommended_default_serial_found() { + let devices = vec![DiscoveredDevice { + name: "Arduino (USB Serial)".into(), + transport: HardwareTransport::Serial, + device_path: Some("/dev/ttyUSB0".into()), + detail: None, + }]; + assert_eq!(recommended_wizard_default(&devices), 1); // Tethered + } + + #[test] + fn recommended_default_probe_found() { + let devices = vec![DiscoveredDevice { + name: "ST-Link (SWD)".into(), + transport: HardwareTransport::Probe, + device_path: None, + detail: None, + }]; + assert_eq!(recommended_wizard_default(&devices), 2); // Probe + } + + #[test] + fn recommended_default_native_priority_over_serial() { + // When both native and serial are found, native wins + let devices = vec![ + DiscoveredDevice { + name: "Arduino".into(), + transport: HardwareTransport::Serial, + device_path: Some("/dev/ttyUSB0".into()), + detail: None, + }, + DiscoveredDevice { + name: "RPi GPIO".into(), + transport: HardwareTransport::Native, + device_path: Some("/dev/gpiomem".into()), + detail: None, + }, + ]; + assert_eq!(recommended_wizard_default(&devices), 0); // Native wins + } + + #[test] + fn config_from_wizard_native() { + let devices = vec![DiscoveredDevice { + name: "Raspberry Pi 4 (Native GPIO)".into(), + transport: HardwareTransport::Native, + device_path: Some("/dev/gpiomem".into()), + detail: Some("Raspberry Pi 4 Model B Rev 1.5".into()), + }]; + + let cfg = config_from_wizard_choice(0, &devices); + assert!(cfg.enabled); + assert_eq!(cfg.transport, "native"); + assert_eq!( + cfg.discovered_board.as_deref(), + Some("Raspberry Pi 4 Model B Rev 1.5") + ); + } + + #[test] + fn config_from_wizard_serial() { + let devices = vec![DiscoveredDevice { + name: "Arduino Uno (USB Serial)".into(), + transport: HardwareTransport::Serial, + device_path: Some("/dev/ttyUSB0".into()), + detail: None, + }]; + + let cfg = config_from_wizard_choice(1, &devices); + assert!(cfg.enabled); + assert_eq!(cfg.transport, "serial"); + assert_eq!(cfg.serial_port.as_deref(), Some("/dev/ttyUSB0")); + } + + #[test] + fn config_from_wizard_probe() { + let devices = vec![DiscoveredDevice { + name: "ST-Link (SWD)".into(), + transport: HardwareTransport::Probe, + device_path: Some("/dev/stlinkv2".into()), + detail: None, + }]; + + let cfg = config_from_wizard_choice(2, &devices); + assert!(cfg.enabled); + assert_eq!(cfg.transport, "probe"); + } + + #[test] + fn config_from_wizard_software_only() { + let devices: Vec = vec![]; + let cfg = config_from_wizard_choice(3, &devices); + assert!(!cfg.enabled); + assert_eq!(cfg.transport, "none"); + } + + #[test] + fn config_from_wizard_serial_no_serial_device_found() { + // User picks serial but no serial device was discovered + let devices = vec![DiscoveredDevice { + name: "RPi GPIO".into(), + transport: HardwareTransport::Native, + device_path: Some("/dev/gpiomem".into()), + detail: None, + }]; + + let cfg = config_from_wizard_choice(1, &devices); + assert!(cfg.enabled); + assert_eq!(cfg.transport, "serial"); + assert!(cfg.serial_port.is_none()); // Will need manual config later + } + + #[test] + fn config_from_wizard_out_of_bounds_defaults_to_software() { + let devices: Vec = vec![]; + let cfg = config_from_wizard_choice(99, &devices); + assert!(!cfg.enabled); + } + + // ── Discovery function runs without panicking ────────────── + + #[test] + fn discover_hardware_does_not_panic() { + // Should never panic regardless of the platform + let devices = discover_hardware(); + // We can't assert what's found (platform-dependent) but it should not crash + assert!(devices.len() < 100); // Sanity check + } + + // ── DiscoveredDevice equality ────────────────────────────── + + #[test] + fn discovered_device_equality() { + let d1 = DiscoveredDevice { + name: "Arduino".into(), + transport: HardwareTransport::Serial, + device_path: Some("/dev/ttyUSB0".into()), + detail: None, + }; + let d2 = d1.clone(); + assert_eq!(d1, d2); + } + + #[test] + fn discovered_device_inequality() { + let d1 = DiscoveredDevice { + name: "Arduino".into(), + transport: HardwareTransport::Serial, + device_path: Some("/dev/ttyUSB0".into()), + detail: None, + }; + let d2 = DiscoveredDevice { + name: "ESP32".into(), + transport: HardwareTransport::Serial, + device_path: Some("/dev/ttyUSB1".into()), + detail: None, + }; + assert_ne!(d1, d2); + } + + // ── Edge cases ───────────────────────────────────────────── + + #[test] + fn config_with_all_pins_in_allowlist() { + let cfg = HardwareConfig { + allowed_pins: (0..=255).collect(), + ..HardwareConfig::default() + }; + // Every pin should be allowed + for pin in 0..=255u8 { + assert!(cfg.is_pin_allowed(pin)); + } + } + + #[test] + fn config_transport_unknown_string() { + let cfg = HardwareConfig { + transport: "quantum_bus".into(), + ..HardwareConfig::default() + }; + assert_eq!(cfg.transport_mode(), HardwareTransport::None); + } + + #[test] + fn config_transport_empty_string() { + let cfg = HardwareConfig { + transport: String::new(), + ..HardwareConfig::default() + }; + assert_eq!(cfg.transport_mode(), HardwareTransport::None); + } + + #[test] + fn validate_serial_empty_port_string_treated_as_set() { + // An empty string is still Some(""), which passes the None check + // but the serial backend would fail at open time — that's acceptable + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: Some(String::new()), + ..HardwareConfig::default() + }; + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_multiple_errors_first_wins() { + // Serial with no port AND zero baud — the port error should surface first + let cfg = HardwareConfig { + enabled: true, + transport: "serial".into(), + serial_port: None, + baud_rate: 0, + ..HardwareConfig::default() + }; + let err = cfg.validate().unwrap_err(); + assert!(err.to_string().contains("serial_port")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1735ff2..cbb2079 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ pub mod cron; pub mod daemon; pub mod doctor; pub mod gateway; +pub mod hardware; pub mod health; pub mod heartbeat; pub mod identity; diff --git a/src/main.rs b/src/main.rs index 67350f2..9d35928 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,6 +44,7 @@ mod cron; mod daemon; mod doctor; mod gateway; +mod hardware; mod health; mod heartbeat; mod identity; diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 11b7279..eae61c2 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -4,6 +4,7 @@ use crate::config::{ HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig, }; +use crate::hardware::{self, HardwareConfig}; use anyhow::{Context, Result}; use console::style; use dialoguer::{Confirm, Input, Select}; @@ -55,28 +56,31 @@ pub fn run_wizard() -> Result { ); println!(); - print_step(1, 8, "Workspace Setup"); + print_step(1, 9, "Workspace Setup"); let (workspace_dir, config_path) = setup_workspace()?; - print_step(2, 8, "AI Provider & API Key"); + print_step(2, 9, "AI Provider & API Key"); let (provider, api_key, model) = setup_provider()?; - print_step(3, 8, "Channels (How You Talk to ZeroClaw)"); + print_step(3, 9, "Channels (How You Talk to ZeroClaw)"); let channels_config = setup_channels()?; - print_step(4, 8, "Tunnel (Expose to Internet)"); + print_step(4, 9, "Tunnel (Expose to Internet)"); let tunnel_config = setup_tunnel()?; - print_step(5, 8, "Tool Mode & Security"); + print_step(5, 9, "Tool Mode & Security"); let (composio_config, secrets_config) = setup_tool_mode()?; - print_step(6, 8, "Memory Configuration"); + print_step(6, 9, "Hardware (Physical World)"); + let hardware_config = setup_hardware()?; + + print_step(7, 9, "Memory Configuration"); let memory_config = setup_memory()?; - print_step(7, 8, "Project Context (Personalize Your Agent)"); + print_step(8, 9, "Project Context (Personalize Your Agent)"); let project_ctx = setup_project_context()?; - print_step(8, 8, "Workspace Files"); + print_step(9, 9, "Workspace Files"); scaffold_workspace(&workspace_dir, &project_ctx)?; // ── Build config ── @@ -107,7 +111,9 @@ pub fn run_wizard() -> Result { browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), + hardware: hardware_config, agents: std::collections::HashMap::new(), + security: crate::config::SecurityConfig::default(), }; println!( @@ -300,7 +306,9 @@ pub fn run_quick_setup( browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), + hardware: HardwareConfig::default(), agents: std::collections::HashMap::new(), + security: crate::config::SecurityConfig::default(), }; config.save()?; @@ -952,6 +960,192 @@ fn setup_tool_mode() -> Result<(ComposioConfig, SecretsConfig)> { Ok((composio_config, secrets_config)) } +// ── Step 6: Hardware (Physical World) ─────────────────────────── + +fn setup_hardware() -> Result { + print_bullet("ZeroClaw can talk to physical hardware (LEDs, sensors, motors)."); + print_bullet("Scanning for connected devices..."); + println!(); + + // ── Auto-discovery ── + let devices = hardware::discover_hardware(); + + if devices.is_empty() { + println!( + " {} {}", + style("ℹ").dim(), + style("No hardware devices detected on this system.").dim() + ); + println!( + " {} {}", + style("ℹ").dim(), + style("You can enable hardware later in config.toml under [hardware].").dim() + ); + } else { + println!( + " {} {} device(s) found:", + style("✓").green().bold(), + devices.len() + ); + for device in &devices { + let detail = device + .detail + .as_deref() + .map(|d| format!(" ({d})")) + .unwrap_or_default(); + let path = device + .device_path + .as_deref() + .map(|p| format!(" → {p}")) + .unwrap_or_default(); + println!( + " {} {}{}{} [{}]", + style("›").cyan(), + style(&device.name).green(), + style(&detail).dim(), + style(&path).dim(), + style(device.transport.to_string()).cyan() + ); + } + } + println!(); + + let options = vec![ + "🚀 Native — direct GPIO on this Linux board (Raspberry Pi, Orange Pi, etc.)", + "🔌 Tethered — control an Arduino/ESP32/Nucleo plugged into USB", + "🔬 Debug Probe — flash/read MCUs via SWD/JTAG (probe-rs)", + "☁️ Software Only — no hardware access (default)", + ]; + + let recommended = hardware::recommended_wizard_default(&devices); + + let choice = Select::new() + .with_prompt(" How should ZeroClaw interact with the physical world?") + .items(&options) + .default(recommended) + .interact()?; + + let mut hw_config = hardware::config_from_wizard_choice(choice, &devices); + + // ── Serial: pick a port if multiple found ── + if hw_config.transport_mode() == hardware::HardwareTransport::Serial { + let serial_devices: Vec<&hardware::DiscoveredDevice> = devices + .iter() + .filter(|d| d.transport == hardware::HardwareTransport::Serial) + .collect(); + + if serial_devices.len() > 1 { + let port_labels: Vec = serial_devices + .iter() + .map(|d| { + format!( + "{} ({})", + d.device_path.as_deref().unwrap_or("unknown"), + d.name + ) + }) + .collect(); + + let port_idx = Select::new() + .with_prompt(" Multiple serial devices found — select one") + .items(&port_labels) + .default(0) + .interact()?; + + hw_config.serial_port = serial_devices[port_idx].device_path.clone(); + } else if serial_devices.is_empty() { + // User chose serial but no device discovered — ask for manual path + let manual_port: String = Input::new() + .with_prompt(" Serial port path (e.g. /dev/ttyUSB0)") + .default("/dev/ttyUSB0".into()) + .interact_text()?; + hw_config.serial_port = Some(manual_port); + } + + // Baud rate + let baud_options = vec![ + "115200 (default, recommended)", + "9600 (legacy Arduino)", + "57600", + "230400", + "Custom", + ]; + let baud_idx = Select::new() + .with_prompt(" Serial baud rate") + .items(&baud_options) + .default(0) + .interact()?; + + hw_config.baud_rate = match baud_idx { + 1 => 9600, + 2 => 57600, + 3 => 230400, + 4 => { + let custom: String = Input::new() + .with_prompt(" Custom baud rate") + .default("115200".into()) + .interact_text()?; + custom.parse::().unwrap_or(115_200) + } + _ => 115_200, + }; + } + + // ── Probe: ask for target chip ── + if hw_config.transport_mode() == hardware::HardwareTransport::Probe && hw_config.probe_target.is_none() { + let target: String = Input::new() + .with_prompt(" Target MCU chip (e.g. STM32F411CEUx, nRF52840_xxAA)") + .default("STM32F411CEUx".into()) + .interact_text()?; + hw_config.probe_target = Some(target); + } + + // ── Datasheet RAG ── + if hw_config.enabled { + let datasheets = Confirm::new() + .with_prompt(" Enable datasheet RAG? (index PDF schematics for AI pin lookups)") + .default(true) + .interact()?; + hw_config.workspace_datasheets = datasheets; + } + + // ── Summary ── + if hw_config.enabled { + let transport_label = match hw_config.transport_mode() { + hardware::HardwareTransport::Native => "Native GPIO".to_string(), + hardware::HardwareTransport::Serial => format!( + "Serial → {} @ {} baud", + hw_config.serial_port.as_deref().unwrap_or("?"), + hw_config.baud_rate + ), + hardware::HardwareTransport::Probe => format!( + "Probe (SWD/JTAG) → {}", + hw_config.probe_target.as_deref().unwrap_or("?") + ), + hardware::HardwareTransport::None => "Software Only".to_string(), + }; + + println!( + " {} Hardware: {} | datasheets: {}", + style("✓").green().bold(), + style(&transport_label).green(), + if hw_config.workspace_datasheets { + style("on").green().to_string() + } else { + style("off").dim().to_string() + } + ); + } else { + println!( + " {} Hardware: {}", + style("✓").green().bold(), + style("disabled (software only)").dim() + ); + } + + Ok(hw_config) +} + // ── Step 6: Project Context ───────────────────────────────────── fn setup_project_context() -> Result { @@ -2496,6 +2690,36 @@ fn print_summary(config: &Config) { } ); + // Hardware + println!( + " {} Hardware: {}", + style("🔌").cyan(), + if config.hardware.enabled { + let mode = config.hardware.transport_mode(); + match mode { + hardware::HardwareTransport::Native => style("Native GPIO (direct)").green().to_string(), + hardware::HardwareTransport::Serial => format!( + "{}", + style(format!( + "Serial → {} @ {} baud", + config.hardware.serial_port.as_deref().unwrap_or("?"), + config.hardware.baud_rate + )).green() + ), + hardware::HardwareTransport::Probe => format!( + "{}", + style(format!( + "Probe → {}", + config.hardware.probe_target.as_deref().unwrap_or("?") + )).green() + ), + hardware::HardwareTransport::None => "disabled (software only)".to_string(), + } + } else { + "disabled (software only)".to_string() + } + ); + println!(); println!(" {}", style("Next steps:").white().bold()); println!(); diff --git a/src/security/audit.rs b/src/security/audit.rs new file mode 100644 index 0000000..971134e --- /dev/null +++ b/src/security/audit.rs @@ -0,0 +1,279 @@ +//! Audit logging for security events + +use crate::config::AuditConfig; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Mutex; +use uuid::Uuid; + +/// Audit event types +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuditEventType { + CommandExecution, + FileAccess, + ConfigChange, + AuthSuccess, + AuthFailure, + PolicyViolation, + SecurityEvent, +} + +/// Actor information (who performed the action) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Actor { + pub channel: String, + pub user_id: Option, + pub username: Option, +} + +/// Action information (what was done) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Action { + pub command: Option, + pub risk_level: Option, + pub approved: bool, + pub allowed: bool, +} + +/// Execution result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecutionResult { + pub success: bool, + pub exit_code: Option, + pub duration_ms: Option, + pub error: Option, +} + +/// Security context +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecurityContext { + pub policy_violation: bool, + pub rate_limit_remaining: Option, + pub sandbox_backend: Option, +} + +/// Complete audit event +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditEvent { + pub timestamp: DateTime, + pub event_id: String, + pub event_type: AuditEventType, + pub actor: Option, + pub action: Option, + pub result: Option, + pub security: SecurityContext, +} + +impl AuditEvent { + /// Create a new audit event + pub fn new(event_type: AuditEventType) -> Self { + Self { + timestamp: Utc::now(), + event_id: Uuid::new_v4().to_string(), + event_type, + actor: None, + action: None, + result: None, + security: SecurityContext { + policy_violation: false, + rate_limit_remaining: None, + sandbox_backend: None, + }, + } + } + + /// Set the actor + pub fn with_actor(mut self, channel: String, user_id: Option, username: Option) -> Self { + self.actor = Some(Actor { + channel, + user_id, + username, + }); + self + } + + /// Set the action + pub fn with_action(mut self, command: String, risk_level: String, approved: bool, allowed: bool) -> Self { + self.action = Some(Action { + command: Some(command), + risk_level: Some(risk_level), + approved, + allowed, + }); + self + } + + /// Set the result + pub fn with_result(mut self, success: bool, exit_code: Option, duration_ms: u64, error: Option) -> Self { + self.result = Some(ExecutionResult { + success, + exit_code, + duration_ms: Some(duration_ms), + error, + }); + self + } + + /// Set security context + pub fn with_security(mut self, sandbox_backend: Option) -> Self { + self.security.sandbox_backend = sandbox_backend; + self + } +} + +/// Audit logger +pub struct AuditLogger { + log_path: PathBuf, + config: AuditConfig, + buffer: Mutex>, +} + +impl AuditLogger { + /// Create a new audit logger + pub fn new(config: AuditConfig, zeroclaw_dir: PathBuf) -> Result { + let log_path = zeroclaw_dir.join(&config.log_path); + Ok(Self { + log_path, + config, + buffer: Mutex::new(Vec::new()), + }) + } + + /// Log an event + pub fn log(&self, event: &AuditEvent) -> Result<()> { + if !self.config.enabled { + return Ok(()); + } + + // Check log size and rotate if needed + self.rotate_if_needed()?; + + // Serialize and write + let line = serde_json::to_string(event)?; + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.log_path)?; + + writeln!(file, "{}", line)?; + file.sync_all()?; + + Ok(()) + } + + /// Log a command execution event + pub fn log_command( + &self, + channel: &str, + command: &str, + risk_level: &str, + approved: bool, + allowed: bool, + success: bool, + duration_ms: u64, + ) -> Result<()> { + let event = AuditEvent::new(AuditEventType::CommandExecution) + .with_actor(channel.to_string(), None, None) + .with_action(command.to_string(), risk_level.to_string(), approved, allowed) + .with_result(success, None, duration_ms, None); + + self.log(&event) + } + + /// Rotate log if it exceeds max size + fn rotate_if_needed(&self) -> Result<()> { + if let Ok(metadata) = std::fs::metadata(&self.log_path) { + let current_size_mb = metadata.len() / (1024 * 1024); + if current_size_mb >= self.config.max_size_mb as u64 { + self.rotate()?; + } + } + Ok(()) + } + + /// Rotate the log file + fn rotate(&self) -> Result<()> { + for i in (1..10).rev() { + let old_name = format!("{}.{}.log", self.log_path.display(), i); + let new_name = format!("{}.{}.log", self.log_path.display(), i + 1); + let _ = std::fs::rename(&old_name, &new_name); + } + + let rotated = format!("{}.1.log", self.log_path.display()); + std::fs::rename(&self.log_path, &rotated)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn audit_event_new_creates_unique_id() { + let event1 = AuditEvent::new(AuditEventType::CommandExecution); + let event2 = AuditEvent::new(AuditEventType::CommandExecution); + assert_ne!(event1.event_id, event2.event_id); + } + + #[test] + fn audit_event_with_actor() { + let event = AuditEvent::new(AuditEventType::CommandExecution) + .with_actor("telegram".to_string(), Some("123".to_string()), Some("@alice".to_string())); + + assert!(event.actor.is_some()); + let actor = event.actor.as_ref().unwrap(); + assert_eq!(actor.channel, "telegram"); + assert_eq!(actor.user_id, Some("123".to_string())); + assert_eq!(actor.username, Some("@alice".to_string())); + } + + #[test] + fn audit_event_with_action() { + let event = AuditEvent::new(AuditEventType::CommandExecution) + .with_action("ls -la".to_string(), "low".to_string(), false, true); + + assert!(event.action.is_some()); + let action = event.action.as_ref().unwrap(); + assert_eq!(action.command, Some("ls -la".to_string())); + assert_eq!(action.risk_level, Some("low".to_string())); + } + + #[test] + fn audit_event_serializes_to_json() { + let event = AuditEvent::new(AuditEventType::CommandExecution) + .with_actor("telegram".to_string(), None, None) + .with_action("ls".to_string(), "low".to_string(), false, true) + .with_result(true, Some(0), 15, None); + + let json = serde_json::to_string(&event); + assert!(json.is_ok()); + let parsed: AuditEvent = serde_json::from_str(&json.unwrap().as_str()).expect("parse"); + assert!(parsed.actor.is_some()); + assert!(parsed.action.is_some()); + assert!(parsed.result.is_some()); + } + + #[test] + fn audit_logger_disabled_does_not_create_file() -> Result<()> { + let tmp = TempDir::new()?; + let config = AuditConfig { + enabled: false, + ..Default::default() + }; + let logger = AuditLogger::new(config, tmp.path().to_path_buf())?; + let event = AuditEvent::new(AuditEventType::CommandExecution); + + logger.log(&event)?; + + // File should not exist since logging is disabled + assert!(!tmp.path().join("audit.log").exists()); + Ok(()) + } +} diff --git a/src/security/bubblewrap.rs b/src/security/bubblewrap.rs new file mode 100644 index 0000000..1c83c8f --- /dev/null +++ b/src/security/bubblewrap.rs @@ -0,0 +1,85 @@ +//! Bubblewrap sandbox (user namespaces for Linux/macOS) + +use crate::security::traits::Sandbox; +use std::process::Command; + +/// Bubblewrap sandbox backend +#[derive(Debug, Clone, Default)] +pub struct BubblewrapSandbox; + +impl BubblewrapSandbox { + pub fn new() -> std::io::Result { + if Self::is_installed() { + Ok(Self) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Bubblewrap not found", + )) + } + } + + pub fn probe() -> std::io::Result { + Self::new() + } + + fn is_installed() -> bool { + Command::new("bwrap") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } +} + +impl Sandbox for BubblewrapSandbox { + fn wrap_command(&self, cmd: &mut Command) -> std::io::Result<()> { + let program = cmd.get_program().to_string_lossy().to_string(); + let args: Vec = cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect(); + + let mut bwrap_cmd = Command::new("bwrap"); + bwrap_cmd.args([ + "--ro-bind", "/usr", "/usr", + "--dev", "/dev", + "--proc", "/proc", + "--bind", "/tmp", "/tmp", + "--unshare-all", + "--die-with-parent", + ]); + bwrap_cmd.arg(&program); + bwrap_cmd.args(&args); + + *cmd = bwrap_cmd; + Ok(()) + } + + fn is_available(&self) -> bool { + Self::is_installed() + } + + fn name(&self) -> &str { + "bubblewrap" + } + + fn description(&self) -> &str { + "User namespace sandbox (requires bwrap)" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bubblewrap_sandbox_name() { + assert_eq!(BubblewrapSandbox.name(), "bubblewrap"); + } + + #[test] + fn bubblewrap_is_available_only_if_installed() { + // Result depends on whether bwrap is installed + let available = BubblewrapSandbox::is_available(); + // Either way, the name should still work + assert_eq!(BubblewrapSandbox.name(), "bubblewrap"); + } +} diff --git a/src/security/detect.rs b/src/security/detect.rs new file mode 100644 index 0000000..11c7ea0 --- /dev/null +++ b/src/security/detect.rs @@ -0,0 +1,151 @@ +//! Auto-detection of available security features + +use crate::config::{SandboxBackend, SecurityConfig}; +use crate::security::traits::Sandbox; +use std::sync::Arc; + +/// Create a sandbox based on auto-detection or explicit config +pub fn create_sandbox(config: &SecurityConfig) -> Arc { + let backend = &config.sandbox.backend; + + // If explicitly disabled, return noop + if matches!(backend, SandboxBackend::None) || config.sandbox.enabled == Some(false) { + return Arc::new(super::traits::NoopSandbox); + } + + // If specific backend requested, try that + match backend { + SandboxBackend::Landlock => { + #[cfg(feature = "sandbox-landlock")] + { + #[cfg(target_os = "linux")] + { + if let Ok(sandbox) = super::landlock::LandlockSandbox::new() { + return Arc::new(sandbox); + } + } + } + tracing::warn!("Landlock requested but not available, falling back to application-layer"); + Arc::new(super::traits::NoopSandbox) + } + SandboxBackend::Firejail => { + #[cfg(target_os = "linux")] + { + if let Ok(sandbox) = super::firejail::FirejailSandbox::new() { + return Arc::new(sandbox); + } + } + tracing::warn!("Firejail requested but not available, falling back to application-layer"); + Arc::new(super::traits::NoopSandbox) + } + SandboxBackend::Bubblewrap => { + #[cfg(feature = "sandbox-bubblewrap")] + { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + if let Ok(sandbox) = super::bubblewrap::BubblewrapSandbox::new() { + return Arc::new(sandbox); + } + } + } + tracing::warn!("Bubblewrap requested but not available, falling back to application-layer"); + Arc::new(super::traits::NoopSandbox) + } + SandboxBackend::Docker => { + if let Ok(sandbox) = super::docker::DockerSandbox::new() { + return Arc::new(sandbox); + } + tracing::warn!("Docker requested but not available, falling back to application-layer"); + Arc::new(super::traits::NoopSandbox) + } + SandboxBackend::Auto | SandboxBackend::None => { + // Auto-detect best available + detect_best_sandbox() + } + } +} + +/// Auto-detect the best available sandbox +fn detect_best_sandbox() -> Arc { + #[cfg(target_os = "linux")] + { + // Try Landlock first (native, no dependencies) + #[cfg(feature = "sandbox-landlock")] + { + if let Ok(sandbox) = super::landlock::LandlockSandbox::probe() { + tracing::info!("Landlock sandbox enabled (Linux kernel 5.13+)"); + return Arc::new(sandbox); + } + } + + // Try Firejail second (user-space tool) + if let Ok(sandbox) = super::firejail::FirejailSandbox::probe() { + tracing::info!("Firejail sandbox enabled"); + return Arc::new(sandbox); + } + } + + #[cfg(target_os = "macos")] + { + // Try Bubblewrap on macOS + #[cfg(feature = "sandbox-bubblewrap")] + { + if let Ok(sandbox) = super::bubblewrap::BubblewrapSandbox::probe() { + tracing::info!("Bubblewrap sandbox enabled"); + return Arc::new(sandbox); + } + } + } + + // Docker is heavy but works everywhere if docker is installed + if let Ok(sandbox) = super::docker::DockerSandbox::probe() { + tracing::info!("Docker sandbox enabled"); + return Arc::new(sandbox); + } + + // Fallback: application-layer security only + tracing::info!("No sandbox backend available, using application-layer security"); + Arc::new(super::traits::NoopSandbox) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{SandboxConfig, SecurityConfig}; + + #[test] + fn detect_best_sandbox_returns_something() { + let sandbox = detect_best_sandbox(); + // Should always return at least NoopSandbox + assert!(sandbox.is_available()); + } + + #[test] + fn explicit_none_returns_noop() { + let config = SecurityConfig { + sandbox: SandboxConfig { + enabled: Some(false), + backend: SandboxBackend::None, + firejail_args: Vec::new(), + }, + ..Default::default() + }; + let sandbox = create_sandbox(&config); + assert_eq!(sandbox.name(), "none"); + } + + #[test] + fn auto_mode_detects_something() { + let config = SecurityConfig { + sandbox: SandboxConfig { + enabled: None, // Auto-detect + backend: SandboxBackend::Auto, + firejail_args: Vec::new(), + }, + ..Default::default() + }; + let sandbox = create_sandbox(&config); + // Should return some sandbox (at least NoopSandbox) + assert!(sandbox.is_available()); + } +} diff --git a/src/security/docker.rs b/src/security/docker.rs new file mode 100644 index 0000000..84aac10 --- /dev/null +++ b/src/security/docker.rs @@ -0,0 +1,113 @@ +//! Docker sandbox (container isolation) + +use crate::security::traits::Sandbox; +use std::process::Command; + +/// Docker sandbox backend +#[derive(Debug, Clone)] +pub struct DockerSandbox { + image: String, +} + +impl Default for DockerSandbox { + fn default() -> Self { + Self { + image: "alpine:latest".to_string(), + } + } +} + +impl DockerSandbox { + pub fn new() -> std::io::Result { + if Self::is_installed() { + Ok(Self::default()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Docker not found", + )) + } + } + + pub fn with_image(image: String) -> std::io::Result { + if Self::is_installed() { + Ok(Self { image }) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Docker not found", + )) + } + } + + pub fn probe() -> std::io::Result { + Self::new() + } + + fn is_installed() -> bool { + Command::new("docker") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } +} + +impl Sandbox for DockerSandbox { + fn wrap_command(&self, cmd: &mut Command) -> std::io::Result<()> { + let program = cmd.get_program().to_string_lossy().to_string(); + let args: Vec = cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect(); + + let mut docker_cmd = Command::new("docker"); + docker_cmd.args([ + "run", "--rm", + "--memory", "512m", + "--cpus", "1.0", + "--network", "none", + ]); + docker_cmd.arg(&self.image); + docker_cmd.arg(&program); + docker_cmd.args(&args); + + *cmd = docker_cmd; + Ok(()) + } + + fn is_available(&self) -> bool { + Self::is_installed() + } + + fn name(&self) -> &str { + "docker" + } + + fn description(&self) -> &str { + "Docker container isolation (requires docker)" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn docker_sandbox_name() { + let sandbox = DockerSandbox::default(); + assert_eq!(sandbox.name(), "docker"); + } + + #[test] + fn docker_sandbox_default_image() { + let sandbox = DockerSandbox::default(); + assert_eq!(sandbox.image, "alpine:latest"); + } + + #[test] + fn docker_with_custom_image() { + let result = DockerSandbox::with_image("ubuntu:latest".to_string()); + match result { + Ok(sandbox) => assert_eq!(sandbox.image, "ubuntu:latest"), + Err(_) => assert!(!DockerSandbox::is_installed()), + } + } +} diff --git a/src/security/firejail.rs b/src/security/firejail.rs new file mode 100644 index 0000000..08bbf3c --- /dev/null +++ b/src/security/firejail.rs @@ -0,0 +1,122 @@ +//! Firejail sandbox (Linux user-space sandboxing) +//! +//! Firejail is a SUID sandbox program that Linux applications use to sandbox themselves. + +use crate::security::traits::Sandbox; +use std::process::Command; + +/// Firejail sandbox backend for Linux +#[derive(Debug, Clone, Default)] +pub struct FirejailSandbox; + +impl FirejailSandbox { + /// Create a new Firejail sandbox + pub fn new() -> std::io::Result { + if Self::is_installed() { + Ok(Self) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Firejail not found. Install with: sudo apt install firejail", + )) + } + } + + /// Probe if Firejail is available (for auto-detection) + pub fn probe() -> std::io::Result { + Self::new() + } + + /// Check if firejail is installed + fn is_installed() -> bool { + Command::new("firejail") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } +} + +impl Sandbox for FirejailSandbox { + fn wrap_command(&self, cmd: &mut Command) -> std::io::Result<()> { + // Prepend firejail to the command + let program = cmd.get_program().to_string_lossy().to_string(); + let args: Vec = cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect(); + + // Build firejail wrapper with security flags + let mut firejail_cmd = Command::new("firejail"); + firejail_cmd.args([ + "--private=home", // New home directory + "--private-dev", // Minimal /dev + "--nosound", // No audio + "--no3d", // No 3D acceleration + "--novideo", // No video devices + "--nowheel", // No input devices + "--notv", // No TV devices + "--noprofile", // Skip profile loading + "--quiet", // Suppress warnings + ]); + + // Add the original command + firejail_cmd.arg(&program); + firejail_cmd.args(&args); + + // Replace the command + *cmd = firejail_cmd; + Ok(()) + } + + fn is_available(&self) -> bool { + Self::is_installed() + } + + fn name(&self) -> &str { + "firejail" + } + + fn description(&self) -> &str { + "Linux user-space sandbox (requires firejail to be installed)" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn firejail_sandbox_name() { + assert_eq!(FirejailSandbox.name(), "firejail"); + } + + #[test] + fn firejail_description_mentions_dependency() { + let desc = FirejailSandbox.description(); + assert!(desc.contains("firejail")); + } + + #[test] + fn firejail_new_fails_if_not_installed() { + // This will fail unless firejail is actually installed + let result = FirejailSandbox::new(); + match result { + Ok(_) => println!("Firejail is installed"), + Err(e) => assert!(e.kind() == std::io::ErrorKind::NotFound || e.kind() == std::io::ErrorKind::Unsupported), + } + } + + #[test] + fn firejail_wrap_command_prepends_firejail() { + let sandbox = FirejailSandbox; + let mut cmd = Command::new("echo"); + cmd.arg("test"); + + // Note: wrap_command will fail if firejail isn't installed, + // but we can still test the logic structure + let _ = sandbox.wrap_command(&mut cmd); + + // After wrapping, the program should be firejail + if sandbox.is_available() { + assert_eq!(cmd.get_program().to_string_lossy(), "firejail"); + } + } +} diff --git a/src/security/landlock.rs b/src/security/landlock.rs new file mode 100644 index 0000000..90942e2 --- /dev/null +++ b/src/security/landlock.rs @@ -0,0 +1,199 @@ +//! Landlock sandbox (Linux kernel 5.13+ LSM) +//! +//! Landlock provides unprivileged sandboxing through the Linux kernel. +//! This module uses the pure-Rust `landlock` crate for filesystem access control. + +#[cfg(all(feature = "sandbox-landlock", target_os = "linux"))] +use landlock::{AccessFS, Ruleset, RulesetCreated}; + +use crate::security::traits::Sandbox; +use std::path::Path; + +/// Landlock sandbox backend for Linux +#[cfg(all(feature = "sandbox-landlock", target_os = "linux"))] +#[derive(Debug)] +pub struct LandlockSandbox { + workspace_dir: Option, +} + +#[cfg(all(feature = "sandbox-landlock", target_os = "linux"))] +impl LandlockSandbox { + /// Create a new Landlock sandbox with the given workspace directory + pub fn new() -> std::io::Result { + Self::with_workspace(None) + } + + /// Create a Landlock sandbox with a specific workspace directory + pub fn with_workspace(workspace_dir: Option) -> std::io::Result { + // Test if Landlock is available by trying to create a minimal ruleset + let test_ruleset = Ruleset::new() + .set_access_fs(AccessFS::read_file | AccessFS::write_file); + + match test_ruleset.create() { + Ok(_) => Ok(Self { workspace_dir }), + Err(e) => { + tracing::debug!("Landlock not available: {}", e); + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Landlock not available", + )) + } + } + } + + /// Probe if Landlock is available (for auto-detection) + pub fn probe() -> std::io::Result { + Self::new() + } + + /// Apply Landlock restrictions to the current process + fn apply_restrictions(&self) -> std::io::Result<()> { + let mut ruleset = Ruleset::new() + .set_access_fs( + AccessFS::read_file + | AccessFS::write_file + | AccessFS::read_dir + | AccessFS::remove_dir + | AccessFS::remove_file + | AccessFS::make_char + | AccessFS::make_sock + | AccessFS::make_fifo + | AccessFS::make_block + | AccessFS::make_reg + | AccessFS::make_sym + ); + + // Allow workspace directory (read/write) + if let Some(ref workspace) = self.workspace_dir { + if workspace.exists() { + ruleset = ruleset.add_path(workspace, AccessFS::read_file | AccessFS::write_file | AccessFS::read_dir)?; + } + } + + // Allow /tmp for general operations + ruleset = ruleset.add_path(Path::new("/tmp"), AccessFS::read_file | AccessFS::write_file)?; + + // Allow /usr and /bin for executing commands + ruleset = ruleset.add_path(Path::new("/usr"), AccessFS::read_file | AccessFS::read_dir)?; + ruleset = ruleset.add_path(Path::new("/bin"), AccessFS::read_file | AccessFS::read_dir)?; + + // Apply the ruleset + match ruleset.create() { + Ok(_) => { + tracing::debug!("Landlock restrictions applied successfully"); + Ok(()) + } + Err(e) => { + tracing::warn!("Failed to apply Landlock restrictions: {}", e); + Err(std::io::Error::new(std::io::ErrorKind::Other, e)) + } + } + } +} + +#[cfg(all(feature = "sandbox-landlock", target_os = "linux"))] +impl Sandbox for LandlockSandbox { + fn wrap_command(&self, cmd: &mut std::process::Command) -> std::io::Result<()> { + // Apply Landlock restrictions before executing the command + // Note: This affects the current process, not the child process + // Child processes inherit the Landlock restrictions + self.apply_restrictions() + } + + fn is_available(&self) -> bool { + // Try to create a minimal ruleset to verify availability + Ruleset::new() + .set_access_fs(AccessFS::read_file) + .create() + .is_ok() + } + + fn name(&self) -> &str { + "landlock" + } + + fn description(&self) -> &str { + "Linux kernel LSM sandboxing (filesystem access control)" + } +} + +// Stub implementations for non-Linux or when feature is disabled +#[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))] +pub struct LandlockSandbox; + +#[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))] +impl LandlockSandbox { + pub fn new() -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Landlock is only supported on Linux with the sandbox-landlock feature", + )) + } + + pub fn with_workspace(_workspace_dir: Option) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Landlock is only supported on Linux", + )) + } + + pub fn probe() -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Landlock is only supported on Linux", + )) + } +} + +#[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))] +impl Sandbox for LandlockSandbox { + fn wrap_command(&self, _cmd: &mut std::process::Command) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Landlock is only supported on Linux", + )) + } + + fn is_available(&self) -> bool { + false + } + + fn name(&self) -> &str { + "landlock" + } + + fn description(&self) -> &str { + "Linux kernel LSM sandboxing (not available on this platform)" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(all(feature = "sandbox-landlock", target_os = "linux"))] + #[test] + fn landlock_sandbox_name() { + if let Ok(sandbox) = LandlockSandbox::new() { + assert_eq!(sandbox.name(), "landlock"); + } + } + + #[cfg(not(all(feature = "sandbox-landlock", target_os = "linux")))] + #[test] + fn landlock_not_available_on_non_linux() { + assert!(!LandlockSandbox.is_available()); + assert_eq!(LandlockSandbox.name(), "landlock"); + } + + #[test] + fn landlock_with_none_workspace() { + // Should work even without a workspace directory + let result = LandlockSandbox::with_workspace(None); + // Result depends on platform and feature flag + match result { + Ok(sandbox) => assert!(sandbox.is_available()), + Err(_) => assert!(!cfg!(all(feature = "sandbox-landlock", target_os = "linux"))), + } + } +} diff --git a/src/security/mod.rs b/src/security/mod.rs index 5a85deb..60885bd 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -1,9 +1,25 @@ +pub mod audit; +pub mod detect; +#[cfg(feature = "sandbox-bubblewrap")] +pub mod bubblewrap; +pub mod docker; +#[cfg(target_os = "linux")] +pub mod firejail; +#[cfg(feature = "sandbox-landlock")] +pub mod landlock; pub mod pairing; pub mod policy; pub mod secrets; +pub mod traits; +#[allow(unused_imports)] +pub use audit::{AuditEvent, AuditEventType, AuditLogger}; +#[allow(unused_imports)] +pub use detect::create_sandbox; #[allow(unused_imports)] pub use pairing::PairingGuard; pub use policy::{AutonomyLevel, SecurityPolicy}; #[allow(unused_imports)] pub use secrets::SecretStore; +#[allow(unused_imports)] +pub use traits::{NoopSandbox, Sandbox}; diff --git a/src/security/traits.rs b/src/security/traits.rs new file mode 100644 index 0000000..452480d --- /dev/null +++ b/src/security/traits.rs @@ -0,0 +1,76 @@ +//! Sandbox trait for pluggable OS-level isolation + +use async_trait::async_trait; +use std::process::Command; + +/// Sandbox backend for OS-level isolation +#[async_trait] +pub trait Sandbox: Send + Sync { + /// Wrap a command with sandbox protection + fn wrap_command(&self, cmd: &mut Command) -> std::io::Result<()>; + + /// Check if this sandbox backend is available on the current platform + fn is_available(&self) -> bool; + + /// Human-readable name of this sandbox backend + fn name(&self) -> &str; + + /// Description of what this sandbox provides + fn description(&self) -> &str; +} + +/// No-op sandbox (always available, provides no additional isolation) +#[derive(Debug, Clone, Default)] +pub struct NoopSandbox; + +impl Sandbox for NoopSandbox { + fn wrap_command(&self, _cmd: &mut Command) -> std::io::Result<()> { + // Pass through unchanged + Ok(()) + } + + fn is_available(&self) -> bool { + true + } + + fn name(&self) -> &str { + "none" + } + + fn description(&self) -> &str { + "No sandboxing (application-layer security only)" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noop_sandbox_name() { + assert_eq!(NoopSandbox.name(), "none"); + } + + #[test] + fn noop_sandbox_is_always_available() { + assert!(NoopSandbox.is_available()); + } + + #[test] + fn noop_sandbox_wrap_command_is_noop() { + let mut cmd = Command::new("echo"); + cmd.arg("test"); + let original_program = cmd.get_program().to_string_lossy().to_string(); + let original_args: Vec = cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect(); + + let sandbox = NoopSandbox; + assert!(sandbox.wrap_command(&mut cmd).is_ok()); + + // Command should be unchanged + assert_eq!(cmd.get_program().to_string_lossy(), original_program); + assert_eq!( + cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect::>(), + original_args + ); + } +}