openclaw-scan 0.1.1

Security scanner for agentic AI framework installations (OpenClaw, Claude Code, and compatible)
Documentation
//! Framework-agnostic installation path discovery.
//!
//! Resolution priority:
//! 1. Explicit `--path <dir>` argument
//! 2. `OPENCLAW_HOME` environment variable
//! 3. Auto-detect common locations (`~/.claude/`, `~/.openclaw/`, …)
//! 4. Error with a clear message

use std::path::{Path, PathBuf};

use anyhow::{bail, Context};

// ── FrameworkHint ─────────────────────────────────────────────────────────────

/// A hint about which agentic framework was detected.
/// This is informational only — scanners operate identically regardless.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameworkHint {
    /// Claude Code installation at `~/.claude/`.
    ClaudeCode,
    /// OpenClaw installation at `~/.openclaw/` or similar.
    Openclaw,
    /// Explicitly supplied path whose framework couldn't be identified.
    Unknown,
}

impl std::fmt::Display for FrameworkHint {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            FrameworkHint::ClaudeCode => write!(f, "Claude Code"),
            FrameworkHint::Openclaw => write!(f, "OpenClaw"),
            FrameworkHint::Unknown => write!(f, "agentic framework"),
        }
    }
}

// ── InstallRoot ───────────────────────────────────────────────────────────────

/// A resolved installation root — the directory the scanners will walk.
#[derive(Debug, Clone)]
pub struct InstallRoot {
    /// The canonical, absolute path to the installation directory.
    pub path: PathBuf,
    /// Which framework (best-guess) this root belongs to.
    pub framework: FrameworkHint,
}

impl InstallRoot {
    /// Build from an explicit path, detecting the framework from the path shape.
    pub fn from_explicit(path: PathBuf) -> anyhow::Result<Self> {
        let canonical = path
            .canonicalize()
            .with_context(|| format!("cannot access path: {}", path.display()))?;

        if !canonical.is_dir() {
            bail!("path is not a directory: {}", canonical.display());
        }

        let framework = detect_framework(&canonical);
        Ok(InstallRoot {
            path: canonical,
            framework,
        })
    }
}

// ── resolve ───────────────────────────────────────────────────────────────────

/// Resolve the installation root(s) to scan.
///
/// Returns one `InstallRoot` per unique directory.
/// If `explicit` is non-empty every entry is used as-is and auto-detection
/// is skipped.  Otherwise auto-detection runs.
pub fn resolve(explicit: Vec<PathBuf>) -> anyhow::Result<Vec<InstallRoot>> {
    if !explicit.is_empty() {
        return explicit
            .into_iter()
            .map(InstallRoot::from_explicit)
            .collect();
    }

    // Try $OPENCLAW_HOME first.
    if let Ok(val) = std::env::var("OPENCLAW_HOME") {
        let p = PathBuf::from(&val);
        if p.is_dir() {
            // Canonicalize to resolve symlinks and relative segments (H-4).
            let canonical = p.canonicalize().unwrap_or(p);
            let framework = detect_framework(&canonical);
            return Ok(vec![InstallRoot {
                path: canonical,
                framework,
            }]);
        }
    }

    // Auto-detect from well-known locations.
    let candidates = candidate_paths();
    for (path, framework) in candidates {
        if path.is_dir() && looks_like_install(&path) {
            return Ok(vec![InstallRoot { path, framework }]);
        }
    }

    bail!(
        "No agentic framework installation found.\n\
         Tried: ~/.claude, ~/.openclaw, ~/.config/openclaw, $OPENCLAW_HOME\n\
         Use `ocls --path <dir>` to specify the installation directory."
    )
}

// ── Internal helpers ──────────────────────────────────────────────────────────

/// Ordered list of candidate directories and their expected framework.
fn candidate_paths() -> Vec<(PathBuf, FrameworkHint)> {
    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    vec![
        (home.join(".claude"), FrameworkHint::ClaudeCode),
        (home.join(".openclaw"), FrameworkHint::Openclaw),
        (
            home.join(".config").join("openclaw"),
            FrameworkHint::Openclaw,
        ),
        // Also check the current directory for project-local .claude folders
        (PathBuf::from(".claude"), FrameworkHint::ClaudeCode),
        (PathBuf::from(".openclaw"), FrameworkHint::Openclaw),
    ]
}

/// Heuristically determine which framework lives at `path`.
fn detect_framework(path: &Path) -> FrameworkHint {
    // L-2: collapsed dead if/else — both branches previously returned Unknown.
    match path.file_name().and_then(|n| n.to_str()).unwrap_or("") {
        ".claude" => FrameworkHint::ClaudeCode,
        ".openclaw" => FrameworkHint::Openclaw,
        _ => FrameworkHint::Unknown,
    }
}

/// A directory is a plausible install root if it contains at least one of the
/// well-known marker files that agentic frameworks store.
fn looks_like_install(path: &Path) -> bool {
    let markers = [
        "settings.json",
        "history.jsonl",
        ".credentials.json",
        "credentials.json",
        "installed_plugins.json",
        "mcp-needs-auth-cache.json",
    ];
    markers.iter().any(|m| path.join(m).exists())
}

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

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

    fn make_install_dir() -> TempDir {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("settings.json"), b"{}").unwrap();
        dir
    }

    #[test]
    fn explicit_path_must_be_directory() {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        let result = InstallRoot::from_explicit(tmp.path().to_path_buf());
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not a directory"));
    }

    #[test]
    fn explicit_path_nonexistent() {
        let result = InstallRoot::from_explicit(PathBuf::from("/nonexistent/path/xyz"));
        assert!(result.is_err());
    }

    #[test]
    fn explicit_path_valid_dir() {
        let dir = make_install_dir();
        let result = InstallRoot::from_explicit(dir.path().to_path_buf());
        assert!(result.is_ok());
    }

    #[test]
    fn looks_like_install_with_marker() {
        let dir = make_install_dir();
        assert!(looks_like_install(dir.path()));
    }

    #[test]
    fn looks_like_install_empty_dir() {
        let dir = tempfile::tempdir().unwrap();
        assert!(!looks_like_install(dir.path()));
    }

    #[test]
    fn detect_framework_claude() {
        let path = PathBuf::from("/home/user/.claude");
        assert_eq!(detect_framework(&path), FrameworkHint::ClaudeCode);
    }

    #[test]
    fn detect_framework_openclaw() {
        let path = PathBuf::from("/home/user/.openclaw");
        assert_eq!(detect_framework(&path), FrameworkHint::Openclaw);
    }

    #[test]
    fn resolve_returns_error_when_nothing_found() {
        // Override home so no real ~/.claude exists in CI
        // We test by passing no explicit paths and checking the error message.
        // (We can't easily mock dirs::home_dir so we just verify the error type.)
        // This test is intentionally light — the heavy path logic is tested above.
        let result = resolve(vec![PathBuf::from("/tmp/__nonexistent_ocls_test__")]);
        assert!(result.is_err());
    }

    #[test]
    fn framework_hint_display() {
        assert_eq!(FrameworkHint::ClaudeCode.to_string(), "Claude Code");
        assert_eq!(FrameworkHint::Openclaw.to_string(), "OpenClaw");
        assert_eq!(FrameworkHint::Unknown.to_string(), "agentic framework");
    }
}