openclaw-scan 0.1.1

Security scanner for agentic AI framework installations (OpenClaw, Claude Code, and compatible)
Documentation
//! Command-line argument definitions (clap derive API).

use std::path::PathBuf;

use clap::{Parser, ValueEnum};

// ── Severity filter ───────────────────────────────────────────────────────────

/// Minimum severity level for displayed findings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub enum SeverityFilter {
    Critical,
    High,
    Medium,
    #[default]
    Low,
    Info,
}

impl SeverityFilter {
    /// Convert to the corresponding [`crate::finding::Severity`] threshold.
    pub fn to_severity(self) -> crate::finding::Severity {
        use crate::finding::Severity;
        match self {
            SeverityFilter::Critical => Severity::Critical,
            SeverityFilter::High => Severity::High,
            SeverityFilter::Medium => Severity::Medium,
            SeverityFilter::Low => Severity::Low,
            SeverityFilter::Info => Severity::Info,
        }
    }
}

// ── Category filter ───────────────────────────────────────────────────────────

/// Which category to scan (default: all).
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum CategoryFilter {
    Config,
    Secrets,
    Permissions,
    Network,
    Deps,
    Hooks,
    History,
}

impl CategoryFilter {
    /// Convert to the corresponding [`crate::finding::Category`].
    pub fn to_category(self) -> crate::finding::Category {
        use crate::finding::Category;
        match self {
            CategoryFilter::Config => Category::ConfigSecurity,
            CategoryFilter::Secrets => Category::SecretDetection,
            CategoryFilter::Permissions => Category::FilePermissions,
            CategoryFilter::Network => Category::NetworkSecurity,
            CategoryFilter::Deps => Category::DependencySecurity,
            CategoryFilter::Hooks => Category::HookSecurity,
            CategoryFilter::History => Category::DataExposure,
        }
    }
}

// ── Main CLI struct ───────────────────────────────────────────────────────────

/// Security scanner for agentic AI framework installations.
///
/// Scans OpenClaw, Claude Code, and any compatible agentic framework directory
/// for security misconfigurations, exposed credentials, permission issues, and
/// supply-chain risks. Works with all model backends (Claude, OpenAI, Mistral,
/// xAI, OpenRouter, and more).
///
/// Auto-detects ~/.claude, ~/.openclaw, ~/.config/openclaw, or $OPENCLAW_HOME
/// if no path is given.
#[derive(Parser, Debug)]
#[command(
    name = "ocls",
    version,
    author,
    about = "Security scanner for agentic AI framework installations",
    long_about = None,
)]
pub struct Cli {
    /// Path(s) to scan. Repeatable.
    ///
    /// If omitted, auto-detects from $OPENCLAW_HOME, ~/.openclaw,
    /// ~/.claude, or ~/.config/openclaw (in that priority order).
    #[arg(value_name = "PATH")]
    pub paths: Vec<PathBuf>,

    /// Output machine-readable JSON instead of the rich terminal view.
    #[arg(short = 'j', long)]
    pub json: bool,

    /// Suppress the banner; emit findings only.
    #[arg(short = 'q', long)]
    pub quiet: bool,

    /// Show remediation steps and evidence for every finding.
    #[arg(short = 'v', long)]
    pub verbose: bool,

    /// Disable ANSI colour codes.
    ///
    /// Colours are also automatically disabled when stdout is not a tty.
    #[arg(long)]
    pub no_color: bool,

    /// Scan only the specified category.
    #[arg(long, value_name = "CATEGORY")]
    pub category: Option<CategoryFilter>,

    /// Minimum severity level to include in the report.
    #[arg(long, value_name = "SEVERITY", default_value = "low")]
    pub min_severity: SeverityFilter,

    /// Exclude paths matching this glob from scanning. Repeatable.
    #[arg(long, value_name = "GLOB")]
    pub ignore_path: Vec<String>,
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    #[test]
    fn cli_debug_assert() {
        // Verifies there are no conflicting arg names, missing value parsers, etc.
        Cli::command().debug_assert();
    }

    #[test]
    fn severity_filter_default_is_low() {
        let cli = Cli::parse_from(["ocls"]);
        assert_eq!(cli.min_severity, SeverityFilter::Low);
    }

    #[test]
    fn severity_filter_to_severity_roundtrip() {
        use crate::finding::Severity;
        assert_eq!(SeverityFilter::Critical.to_severity(), Severity::Critical);
        assert_eq!(SeverityFilter::High.to_severity(), Severity::High);
        assert_eq!(SeverityFilter::Medium.to_severity(), Severity::Medium);
        assert_eq!(SeverityFilter::Low.to_severity(), Severity::Low);
        assert_eq!(SeverityFilter::Info.to_severity(), Severity::Info);
    }

    #[test]
    fn json_flag_parsed() {
        let cli = Cli::parse_from(["ocls", "--json"]);
        assert!(cli.json);
    }

    #[test]
    fn verbose_flag_parsed() {
        let cli = Cli::parse_from(["ocls", "-v"]);
        assert!(cli.verbose);
    }

    #[test]
    fn no_color_flag_parsed() {
        let cli = Cli::parse_from(["ocls", "--no-color"]);
        assert!(cli.no_color);
    }

    #[test]
    fn multiple_paths_parsed() {
        let cli = Cli::parse_from(["ocls", "/tmp/a", "/tmp/b"]);
        assert_eq!(cli.paths.len(), 2);
    }

    #[test]
    fn category_filter_to_category() {
        use crate::finding::Category;
        assert_eq!(
            CategoryFilter::Secrets.to_category(),
            Category::SecretDetection
        );
        assert_eq!(
            CategoryFilter::Config.to_category(),
            Category::ConfigSecurity
        );
        assert_eq!(
            CategoryFilter::Permissions.to_category(),
            Category::FilePermissions
        );
        assert_eq!(
            CategoryFilter::Network.to_category(),
            Category::NetworkSecurity
        );
        assert_eq!(
            CategoryFilter::Deps.to_category(),
            Category::DependencySecurity
        );
        assert_eq!(CategoryFilter::Hooks.to_category(), Category::HookSecurity);
        assert_eq!(
            CategoryFilter::History.to_category(),
            Category::DataExposure
        );
    }

    #[test]
    fn min_severity_medium_parsed() {
        let cli = Cli::parse_from(["ocls", "--min-severity", "medium"]);
        assert_eq!(cli.min_severity, SeverityFilter::Medium);
    }

    #[test]
    fn ignore_path_repeatable() {
        let cli = Cli::parse_from(["ocls", "--ignore-path", "*.log", "--ignore-path", "tmp/"]);
        assert_eq!(cli.ignore_path.len(), 2);
    }
}