sparrow-cli 0.5.1

A local-first Rust agent cockpit — route, run, replay, rewind
Documentation
use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::config::Config;
use crate::hooks::Hook;
use crate::permissions::PermissionMode;
use crate::redaction::RedactionFilter;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Severity {
    Info,
    Warning,
    Critical,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityFinding {
    pub severity: Severity,
    pub category: String,
    pub message: String,
    pub detail: String,
    pub recommendation: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityAudit {
    pub score: u32,
    pub findings: Vec<SecurityFinding>,
    pub checked_at: String,
}

impl SecurityAudit {
    pub fn run(config: &Config, hooks: &[Hook]) -> Self {
        let mut findings = Vec::new();

        Self::check_permissions(config, &mut findings);
        Self::check_gateway_senders(config, &mut findings);
        Self::check_dangerous_tools(config, &mut findings);
        Self::check_plugins(config, &mut findings);
        Self::check_hooks(config, hooks, &mut findings);
        Self::check_secrets_in_repo(&mut findings);
        Self::check_sandbox_exec(config, &mut findings);

        let score = Self::compute_score(&findings);

        SecurityAudit {
            score,
            findings,
            checked_at: chrono::Utc::now().to_rfc3339(),
        }
    }

    fn check_permissions(config: &Config, findings: &mut Vec<SecurityFinding>) {
        let perms = &config.permissions;

        if perms.paths.deny.is_empty() {
            findings.push(SecurityFinding {
                severity: Severity::Warning,
                category: "permissions".into(),
                message: "No denied paths configured".into(),
                detail: "The permissions config has no denied paths, meaning sensitive files like .git, .env, .ssh are not explicitly blocked.".into(),
                recommendation: "Add default denied paths: .git, .env, .ssh, id_rsa, id_ed25519".into(),
            });
        }

        if matches!(perms.mode, PermissionMode::Autonomous) {
            findings.push(SecurityFinding {
                severity: Severity::Critical,
                category: "permissions".into(),
                message: "Autonomous mode without tool restrictions".into(),
                detail: "Permission mode is 'autonomous' but no tools are explicitly denied. Dangerous tools like exec could run unrestricted.".into(),
                recommendation: "Add dangerous tools to deny list or switch to a more restrictive permission mode".into(),
            });
        }
    }

    fn check_gateway_senders(config: &Config, findings: &mut Vec<SecurityFinding>) {
        let surfaces = &config.surfaces;

        for (name, surface) in [
            ("telegram", surfaces.telegram.as_ref()),
            ("discord", surfaces.discord.as_ref()),
            ("slack", surfaces.slack.as_ref()),
        ] {
            if let Some(s) = surface {
                if s.enabled && s.allow_users.is_empty() {
                    findings.push(SecurityFinding {
                        severity: Severity::Critical,
                        category: "gateway".into(),
                        message: format!("Gateway {} accepts all users", name),
                        detail: format!(
                            "The {} gateway surface is enabled but has no allow_users list, meaning any user can send messages.",
                            name
                        ),
                        recommendation: format!(
                            "Add allow_users list to {} surface config to restrict access",
                            name
                        ),
                    });
                }
            }
        }

        if let Some(e) = surfaces.email.as_ref() {
            if e.enabled && e.allowed_to.is_empty() {
                findings.push(SecurityFinding {
                    severity: Severity::Critical,
                    category: "gateway".into(),
                    message: "Gateway email accepts all recipients".into(),
                    detail: "The email surface is enabled but has no allowed_to list, meaning replies can be sent to any address.".into(),
                    recommendation: "Add allowed_to to surfaces.email config to restrict recipients".into(),
                });
            }
        }
    }

    fn check_dangerous_tools(config: &Config, findings: &mut Vec<SecurityFinding>) {
        let tools = &config.permissions.tools;

        let dangerous = ["exec", "terminal", "destructive"];
        for tool_name in &dangerous {
            if tools.deny.iter().any(|t| t == tool_name) {
                continue;
            }
            if !tools.allow.iter().any(|t| t == tool_name) {
                findings.push(SecurityFinding {
                    severity: Severity::Warning,
                    category: "tools".into(),
                    message: format!("Dangerous tool '{}' not explicitly denied", tool_name),
                    detail: format!(
                        "Tool '{}' is classified as dangerous but is not in the deny list.",
                        tool_name
                    ),
                    recommendation: format!(
                        "Add '{}' to tools.deny or use permission rules to restrict it",
                        tool_name
                    ),
                });
            }
        }
    }

    fn check_plugins(config: &Config, findings: &mut Vec<SecurityFinding>) {
        let plugins_dir = config.config_dir.join("plugins");

        if !plugins_dir.exists() {
            return;
        }

        if let Ok(entries) = std::fs::read_dir(&plugins_dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_dir() {
                    let manifest_path = path.join("plugin.toml");
                    if manifest_path.exists() {
                        if let Ok(content) = std::fs::read_to_string(&manifest_path) {
                            if !content.contains("allowlist") {
                                findings.push(SecurityFinding {
                                    severity: Severity::Warning,
                                    category: "plugins".into(),
                                    message: format!(
                                        "Plugin '{}' has no allowlist",
                                        path.file_name().unwrap_or_default().to_string_lossy()
                                    ),
                                    detail: "Plugin manifest does not define an allowlist.".into(),
                                    recommendation:
                                        "Add an allowlist section to the plugin manifest".into(),
                                });
                            }
                        }
                    }
                }
            }
        }
    }

    fn check_hooks(_config: &Config, hooks: &[Hook], findings: &mut Vec<SecurityFinding>) {
        let redaction = RedactionFilter::new();

        for hook in hooks {
            if !hook.enabled {
                continue;
            }

            let cmd = &hook.command;
            let lower = cmd.to_lowercase();
            let suspicious = [
                "rm -rf",
                "curl |",
                "wget |",
                "eval ",
                "exec(",
                "powershell -enc",
                "base64 -d",
            ];

            for pattern in &suspicious {
                if lower.contains(pattern) {
                    findings.push(SecurityFinding {
                        severity: Severity::Critical,
                        category: "hooks".into(),
                        message: format!("Suspicious hook command: {}", cmd),
                        detail: format!(
                            "Hook '{}' contains suspicious pattern '{}'.",
                            hook.id, pattern
                        ),
                        recommendation: "Review and sanitize hook commands".into(),
                    });
                }
            }

            if redaction.contains_secret(cmd) {
                findings.push(SecurityFinding {
                    severity: Severity::Critical,
                    category: "hooks".into(),
                    message: format!("Secret found in hook: {}", hook.id),
                    detail: "Hook command contains what appears to be a secret or API key.".into(),
                    recommendation:
                        "Remove secrets from hook commands and use environment variables".into(),
                });
            }
        }
    }

    fn check_secrets_in_repo(findings: &mut Vec<SecurityFinding>) {
        let repo_root = Path::new(".");

        let secret_patterns = [
            "sk-ant-", "ghp_", "gho_", "ghu_", "ghs_", "ghr_", "xai-", "nvapi-", "hf_", "gsk_",
        ];

        Self::scan_directory_for_secrets(repo_root, &secret_patterns, findings);
    }

    fn scan_directory_for_secrets(
        dir: &Path,
        patterns: &[&str],
        findings: &mut Vec<SecurityFinding>,
    ) {
        if let Ok(entries) = std::fs::read_dir(dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_file() {
                    if let Ok(content) = std::fs::read_to_string(&path) {
                        for pattern in patterns {
                            if content.contains(pattern) {
                                findings.push(SecurityFinding {
                                    severity: Severity::Critical,
                                    category: "secrets".into(),
                                    message: format!(
                                        "Potential secret in {}",
                                        path.display()
                                    ),
                                    detail: format!(
                                        "File contains pattern '{}': {}",
                                        pattern,
                                        content.lines().find(|l| l.contains(pattern)).unwrap_or("")
                                    ),
                                    recommendation: "Remove secrets from source files and use environment variables".into(),
                                });
                            }
                        }
                    }
                }
            }
        }
    }

    fn check_sandbox_exec(config: &Config, findings: &mut Vec<SecurityFinding>) {
        let sandbox = &config.defaults.sandbox;
        let permissions = &config.permissions;

        if sandbox == "local" && permissions.mode == PermissionMode::Autonomous {
            let tools_deny = &permissions.tools.deny;
            if !tools_deny.iter().any(|t| t == "exec" || t == "terminal") {
                findings.push(SecurityFinding {
                    severity: Severity::Critical,
                    category: "sandbox".into(),
                    message: "Exec tool exposed without sandbox".into(),
                    detail: "The exec tool is not denied and sandbox mode is 'local', allowing unrestricted command execution.".into(),
                    recommendation: "Add 'exec' and 'terminal' to permissions.tools.deny or enable sandbox mode".into(),
                });
            }
        }
    }

    fn compute_score(findings: &[SecurityFinding]) -> u32 {
        if findings.is_empty() {
            return 100;
        }

        let mut score: i32 = 100;
        for finding in findings {
            match finding.severity {
                Severity::Info => {}
                Severity::Warning => score -= 5,
                Severity::Critical => score -= 15,
            }
        }

        score.max(0) as u32
    }

    pub fn to_json(&self) -> String {
        serde_json::to_string_pretty(self).unwrap_or_default()
    }

    pub fn summary(&self) -> String {
        let critical = self
            .findings
            .iter()
            .filter(|f| matches!(f.severity, Severity::Critical))
            .count();
        let warnings = self
            .findings
            .iter()
            .filter(|f| matches!(f.severity, Severity::Warning))
            .count();
        let info = self
            .findings
            .iter()
            .filter(|f| matches!(f.severity, Severity::Info))
            .count();

        format!(
            "Security Audit: score {}/100 | {} critical, {} warnings, {} info",
            self.score, critical, warnings, info
        )
    }
}