clawgarden-cli 0.1.4

ClawGarden CLI - Multi-bot/multi-agent Garden management tool
//! Security preflight checks for ClawGarden CLI
//!
//! Validates that the workspace mount paths are safe and don't expose
//! sensitive directories like SSH keys, GPG keys, or AWS credentials.

use anyhow::{Context, Result};
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;

/// Exit code for security path violations
#[allow(dead_code)]
pub const SECURITY_PATH_BLOCKED: i32 = 77;

/// Default blocked paths that should never be mounted into the Garden container
#[allow(dead_code)]
const DEFAULT_BLOCKED_PATHS: &[&str] = &[
    ".ssh", ".gnupg", ".aws", ".npm", ".cargo", ".rustup", ".config",
];

/// Load allowed agent types from allowlist file
#[allow(dead_code)]
pub fn load_allowlist(path: &PathBuf) -> Result<HashSet<String>> {
    let mut allowed = HashSet::new();
    allowed.insert("pi-coding-agent".to_string()); // Always allowed

    if path.exists() {
        let content = fs::read_to_string(path).context("Failed to read allowlist")?;
        for line in content.lines() {
            let trimmed = line.trim();
            if !trimmed.is_empty() && !trimmed.starts_with('#') {
                allowed.insert(trimmed.to_string());
            }
        }
    }

    Ok(allowed)
}

/// Check if a path is blocked (sensitive)
#[allow(dead_code)]
fn is_blocked_path(path: &std::path::Path) -> bool {
    let path_str = path.to_string_lossy();
    for blocked in DEFAULT_BLOCKED_PATHS {
        if path_str.contains(blocked) {
            return true;
        }
    }

    // Check for home directory expansion issues
    if path_str.contains("~") {
        return true;
    }

    false
}

/// Scan the workspace agents directory for any blocked paths
#[allow(dead_code)]
pub fn scan_workspace_for_blocked_paths(workspace: &PathBuf) -> Result<Vec<PathBuf>> {
    let agents_dir = workspace.join("agents");
    let mut violations = Vec::new();

    if !agents_dir.exists() {
        return Ok(violations);
    }

    // Check entries in agents directory
    let entries = fs::read_dir(&agents_dir).context("Failed to read agents directory")?;
    for entry in entries.flatten() {
        let path = entry.path();

        // Skip files (like registry.json, .allowlist)
        if path.is_file() {
            continue;
        }

        // Check if directory name contains blocked patterns
        if let Some(name) = path.file_name() {
            let name_str = name.to_string_lossy();
            for blocked in DEFAULT_BLOCKED_PATHS {
                if name_str.contains(blocked) || name_str.starts_with('.') {
                    violations.push(path.clone());
                    break;
                }
            }
        }
    }

    Ok(violations)
}

/// Run security preflight checks
///
/// Returns Ok(()) if all checks pass, Err with message if blocked paths found.
#[allow(dead_code)]
pub fn run_security_preflight() -> Result<()> {
    let workspace = std::env::var("GARDEN_WORKSPACE")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("."));

    println!("Scanning workspace at {:?}", workspace);

    // Check for blocked paths in agents directory
    let violations = scan_workspace_for_blocked_paths(&workspace)?;
    if !violations.is_empty() {
        eprintln!("SECURITY: Found blocked path violations:");
        for v in &violations {
            eprintln!("  - {:?}", v);
        }
        anyhow::bail!("Blocked paths detected in workspace. Cannot proceed for security reasons.");
    }

    // Check that workspace directory itself doesn't contain sensitive files
    if let Ok(entries) = fs::read_dir(&workspace) {
        for entry in entries.flatten() {
            if let Some(name) = entry.file_name().to_str() {
                // Check for sensitive file patterns
                if name.contains(".env")
                    || name.contains("credentials")
                    || name.contains("secret")
                    || name.contains("key")
                {
                    eprintln!(
                        "SECURITY WARNING: Potentially sensitive file found: {}",
                        name
                    );
                }
            }
        }
    }

    // Load and display allowlist status
    let allowlist_path = workspace.join("agents/.allowlist");
    if allowlist_path.exists() {
        match load_allowlist(&allowlist_path) {
            Ok(allowed) => {
                println!("Allowlist loaded: {} entries", allowed.len());
            }
            Err(e) => {
                eprintln!("Warning: Could not load allowlist: {}", e);
            }
        }
    } else {
        println!("No allowlist file found, using defaults.");
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_blocked_path() {
        assert!(is_blocked_path(std::path::Path::new("/home/user/.ssh")));
        assert!(is_blocked_path(std::path::Path::new("/home/user/.gnupg")));
        assert!(is_blocked_path(std::path::Path::new("/home/user/.aws")));
        assert!(is_blocked_path(std::path::Path::new(
            "/some/path/.ssh/config"
        )));
        assert!(is_blocked_path(std::path::Path::new("~/.ssh/id_rsa")));
    }

    #[test]
    fn test_load_allowlist() {
        let temp_dir = std::env::temp_dir();
        let allowlist_path = temp_dir.join("test_allowlist");

        // Create test allowlist
        fs::write(
            &allowlist_path,
            "pi-coding-agent\n# comment\ncustom-agent\n",
        )
        .unwrap();

        let allowed = load_allowlist(&allowlist_path).unwrap();
        assert!(allowed.contains("pi-coding-agent"));
        assert!(allowed.contains("custom-agent"));
        assert_eq!(allowed.len(), 2);

        // Cleanup
        fs::remove_file(&allowlist_path).ok();
    }
}