clawgarden-cli 0.7.3

ClawGarden CLI - Multi-bot/multi-agent Garden management tool
//! Security checks for ClawGarden CLI
//!
//! Validates workspace mount paths and .env file permissions.
//! Used by `garden doctor` for security preflight checks.

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

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

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

    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)
#[cfg(test)]
fn is_blocked_path(path: &std::path::Path) -> bool {
    let path_str = path.to_string_lossy();
    for blocked in BLOCKED_PATHS {
        if path_str.contains(blocked) {
            return true;
        }
    }
    path_str.contains("~")
}

/// Scan the workspace agents directory for any blocked paths
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);
    }

    let entries = fs::read_dir(&agents_dir).context("Failed to read agents directory")?;
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_file() {
            continue;
        }
        if let Some(name) = path.file_name() {
            let name_str = name.to_string_lossy();
            for blocked in BLOCKED_PATHS {
                if name_str.contains(blocked) || name_str.starts_with('.') {
                    violations.push(path.clone());
                    break;
                }
            }
        }
    }

    Ok(violations)
}

/// Check .env for potential security issues
pub fn check_env_security(env_path: &PathBuf) -> Vec<String> {
    let mut warnings = Vec::new();

    if !env_path.exists() {
        return warnings;
    }

    // Check file permissions on unix
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(metadata) = fs::metadata(env_path) {
            let mode = metadata.permissions().mode();
            if mode & 0o077 != 0 {
                warnings.push(".env file is readable by group/others (should be 0600)".to_string());
            }
        }
    }

    // Check for suspicious values
    if let Ok(content) = fs::read_to_string(env_path) {
        for line in content.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            if let Some((key, value)) = line.split_once('=') {
                let key = key.trim();
                let value = value.trim();
                if !value.is_empty()
                    && (value.starts_with("sk-") || value.starts_with("key-") || value.len() < 8)
                {
                    warnings.push(format!("{} may have a weak or suspicious value", key));
                }
            }
        }
    }

    warnings
}

#[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")));
        assert!(!is_blocked_path(std::path::Path::new(
            "/home/user/mygarden"
        )));
    }

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

        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);

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