agentsec-core 0.1.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! BlackList check: classify installed MCP server names against a
//! [`crate::registry::Registry`] of known-good entries.
//!
//! This is a **demo-level** signature check: it reads MCP server names
//! from `.mcp.json` (cwd) and `.claude.json` (under [`crate::Paths::user_home`])
//! at call time, then assigns each name one of three verdicts:
//!
//! - [`UnknownVerdictKind::KnownGood`] — exact name match in the registry.
//! - [`UnknownVerdictKind::Typosquat`] — Levenshtein distance ≤ 2 to a
//!   registry entry (and not identical).
//! - [`UnknownVerdictKind::Unknown`] — no match; the user has installed
//!   an MCP server AgentSec doesn't recognise. This is **neutral**, not a
//!   block — it just surfaces "we've never seen this".
//!
//! The classify step does not consume the inventory snapshot — it
//! re-reads the source JSON because the snapshot only stores hashes, not
//! the full structure.

use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::Paths;
use crate::registry::Registry;

const TYPOSQUAT_DISTANCE_MAX: usize = 2;

/// One row of [`classify`]'s output.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UnknownVerdict {
    /// Source file the server name was read from
    /// (e.g. `~/.claude.json` or `<cwd>/.mcp.json`).
    pub path: String,
    /// MCP server name (the key under `mcpServers.<name>`).
    pub name: String,
    /// Classification of `name` against the registry.
    pub verdict: UnknownVerdictKind,
    /// Free-text rationale, suitable for one-line surface in the Markdown
    /// report. Echoes the registry [`crate::registry::RegistryEntry::source`]
    /// when relevant.
    pub reason: String,
}

/// Three-level classification.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum UnknownVerdictKind {
    /// Exact match in the registry.
    KnownGood,
    /// Levenshtein distance ≤ 2 to a registry entry. Carries the closest
    /// match and the distance.
    Typosquat { closest: String, distance: usize },
    /// No match at any distance ≤ 2.
    Unknown,
}

/// Read MCP server names from `.mcp.json` and `.claude.json` and classify
/// each one against `registry`.
///
/// File-read failures are silently skipped — a missing `.mcp.json` is the
/// common case, and a malformed `.claude.json` shouldn't fail the whole
/// classification.
///
/// Output is sorted by `(path, name)` for stable reporting.
pub fn classify(paths: &Paths, registry: &Registry) -> Vec<UnknownVerdict> {
    let mut named: BTreeSet<(String, String)> = BTreeSet::new();

    // ── project-rooted `.mcp.json` (cwd-relative) ─────────────────────
    let project_mcp = PathBuf::from(".mcp.json");
    extract_names(&project_mcp, &mut named, /*per_project=*/ false);

    // ── home-rooted `.claude.json` (top-level + per-project blocks) ───
    let home_config = paths.user_home.join(".claude.json");
    extract_names(&home_config, &mut named, /*per_project=*/ true);

    named
        .into_iter()
        .map(|(path, name)| classify_one(&path, &name, registry))
        .collect()
}

fn classify_one(path: &str, name: &str, registry: &Registry) -> UnknownVerdict {
    if let Some(entry) = registry.get(name) {
        return UnknownVerdict {
            path: path.to_string(),
            name: name.to_string(),
            verdict: UnknownVerdictKind::KnownGood,
            reason: format!("registered (source: {})", entry.source),
        };
    }
    if let Some((entry, distance)) = registry.closest(name, TYPOSQUAT_DISTANCE_MAX) {
        return UnknownVerdict {
            path: path.to_string(),
            name: name.to_string(),
            verdict: UnknownVerdictKind::Typosquat {
                closest: entry.name.clone(),
                distance,
            },
            reason: format!("close to `{}` (distance {})", entry.name, distance),
        };
    }
    UnknownVerdict {
        path: path.to_string(),
        name: name.to_string(),
        verdict: UnknownVerdictKind::Unknown,
        reason: "not in registry".to_string(),
    }
}

/// Read `path` as JSON and extract MCP server names from top-level
/// `mcpServers` (and, if `per_project`, from each
/// `projects.<p>.mcpServers` block too). Adds `(display-path, name)`
/// tuples to `out`. Silently no-ops on missing / unparseable files.
fn extract_names(path: &PathBuf, out: &mut BTreeSet<(String, String)>, per_project: bool) {
    let Ok(body) = fs::read_to_string(path) else {
        return;
    };
    let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) else {
        return;
    };
    let display = path.display().to_string();
    if let Some(top) = json
        .get("mcpServers")
        .and_then(serde_json::Value::as_object)
    {
        for name in top.keys() {
            out.insert((display.clone(), name.clone()));
        }
    }
    if per_project {
        if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
            for (proj_name, proj_val) in projects {
                if let Some(servers) = proj_val
                    .get("mcpServers")
                    .and_then(serde_json::Value::as_object)
                {
                    for name in servers.keys() {
                        let scoped = format!("{display}#projects.{proj_name}.mcpServers");
                        out.insert((scoped, name.clone()));
                    }
                }
            }
        }
    }
}

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

    fn write_claude_json(tmp: &tempfile::TempDir, body: &str) {
        std::fs::write(tmp.path().join(".claude.json"), body).unwrap();
    }

    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
        Paths {
            home: tmp.path().to_path_buf(),
            user_home: tmp.path().to_path_buf(),
        }
    }

    fn small_registry() -> Registry {
        Registry::from_entries(vec![
            RegistryEntry {
                name: "filesystem".into(),
                source: "test-source".into(),
            },
            RegistryEntry {
                name: "github".into(),
                source: "test-source".into(),
            },
        ])
    }

    #[test]
    fn known_good_is_recognised() {
        let tmp = tempfile::tempdir().unwrap();
        write_claude_json(&tmp, r#"{"mcpServers":{"filesystem":{"command":"x"}}}"#);
        let verdicts = classify(&paths_for(&tmp), &small_registry());
        assert_eq!(verdicts.len(), 1);
        assert_eq!(verdicts[0].name, "filesystem");
        assert_eq!(verdicts[0].verdict, UnknownVerdictKind::KnownGood);
    }

    #[test]
    fn typosquat_is_flagged() {
        let tmp = tempfile::tempdir().unwrap();
        // "filesystme" — single transposition typo from "filesystem".
        write_claude_json(&tmp, r#"{"mcpServers":{"filesystme":{"command":"x"}}}"#);
        let verdicts = classify(&paths_for(&tmp), &small_registry());
        assert_eq!(verdicts.len(), 1);
        match &verdicts[0].verdict {
            UnknownVerdictKind::Typosquat { closest, distance } => {
                assert_eq!(closest, "filesystem");
                assert!(*distance > 0 && *distance <= 2);
            }
            other => panic!("expected Typosquat, got {other:?}"),
        }
    }

    #[test]
    fn unknown_name_is_unknown() {
        let tmp = tempfile::tempdir().unwrap();
        write_claude_json(
            &tmp,
            r#"{"mcpServers":{"my-private-tool":{"command":"x"}}}"#,
        );
        let verdicts = classify(&paths_for(&tmp), &small_registry());
        assert_eq!(verdicts.len(), 1);
        assert_eq!(verdicts[0].verdict, UnknownVerdictKind::Unknown);
    }

    #[test]
    fn per_project_servers_are_classified() {
        let tmp = tempfile::tempdir().unwrap();
        write_claude_json(
            &tmp,
            r#"{
                "mcpServers": {"github": {"command":"x"}},
                "projects": {
                    "/some/proj": {
                        "mcpServers": {"my-tool": {"command":"y"}}
                    }
                }
            }"#,
        );
        let verdicts = classify(&paths_for(&tmp), &small_registry());
        assert_eq!(verdicts.len(), 2);
        let names: Vec<&str> = verdicts.iter().map(|v| v.name.as_str()).collect();
        assert!(names.contains(&"github"));
        assert!(names.contains(&"my-tool"));
    }

    #[test]
    fn missing_claude_json_yields_empty_result() {
        let tmp = tempfile::tempdir().unwrap();
        let verdicts = classify(&paths_for(&tmp), &small_registry());
        assert!(verdicts.is_empty());
    }

    #[test]
    fn malformed_json_silently_skipped() {
        let tmp = tempfile::tempdir().unwrap();
        write_claude_json(&tmp, "not even close to JSON");
        let verdicts = classify(&paths_for(&tmp), &small_registry());
        assert!(verdicts.is_empty());
    }
}