agentsec-core 0.3.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! MCP entry manipulation for `~/.claude.json`.
//!
//! Pure JSON-value operations: add or remove the `agentsec` entry under
//! `mcpServers` (user scope) or `projects.<cwd>.mcpServers` (project scope).
//! Callers are responsible for reading the file, calling these helpers, then
//! writing the result back.

use std::path::Path;

use serde_json::{Value, json};

use super::{ChangeKind, InstallScope};

/// Add `mcpServers.agentsec` (user) or `projects.<cwd>.mcpServers.agentsec`
/// (project) to `root`.
///
/// Returns [`ChangeKind::NoOp`] if the entry is already present and
/// `force` is `false`. Returns [`ChangeKind::Updated`] if the entry existed
/// and `force` is `true`. Returns [`ChangeKind::Added`] if the entry was
/// absent.
pub fn add_mcp_entry(
    root: &mut Value,
    scope: InstallScope,
    dotenv: Option<&Path>,
    force: bool,
) -> ChangeKind {
    let entry = build_entry(dotenv);

    match scope {
        InstallScope::User => {
            ensure_object(root);
            ensure_object_key(root, "mcpServers");
            upsert_agentsec(root["mcpServers"].as_object_mut().unwrap(), entry, force)
        }
        InstallScope::Project => {
            let cwd = std::env::current_dir()
                .unwrap_or_else(|_| std::path::PathBuf::from("."))
                .display()
                .to_string();

            ensure_object(root);
            ensure_object_key(root, "projects");
            root["projects"]
                .as_object_mut()
                .unwrap()
                .entry(cwd.clone())
                .or_insert_with(|| json!({}));
            ensure_object_key(&mut root["projects"][&cwd], "mcpServers");
            upsert_agentsec(
                root["projects"][&cwd]["mcpServers"]
                    .as_object_mut()
                    .unwrap(),
                entry,
                force,
            )
        }
    }
}

/// Remove `mcpServers.agentsec` (user) or `projects.<cwd>.mcpServers.agentsec`
/// (project) from `root`.
///
/// Returns [`ChangeKind::Removed`] if an entry was removed, or
/// [`ChangeKind::NoOp`] if no entry existed.
pub fn remove_mcp_entry(root: &mut Value, scope: InstallScope) -> ChangeKind {
    match scope {
        InstallScope::User => {
            let removed = root
                .get_mut("mcpServers")
                .and_then(|v| v.as_object_mut())
                .is_some_and(|m| m.remove("agentsec").is_some());
            if removed {
                ChangeKind::Removed
            } else {
                ChangeKind::NoOp
            }
        }
        InstallScope::Project => {
            let cwd = std::env::current_dir()
                .unwrap_or_else(|_| std::path::PathBuf::from("."))
                .display()
                .to_string();
            let removed = root
                .get_mut("projects")
                .and_then(|v| v.as_object_mut())
                .and_then(|m| m.get_mut(&cwd))
                .and_then(|p| p.get_mut("mcpServers"))
                .and_then(|v| v.as_object_mut())
                .is_some_and(|m| m.remove("agentsec").is_some());
            if removed {
                ChangeKind::Removed
            } else {
                ChangeKind::NoOp
            }
        }
    }
}

// ── private helpers ───────────────────────────────────────────────────────────

/// Ensure `v` is a JSON object; replace with `{}` if not.
fn ensure_object(v: &mut Value) {
    if v.as_object().is_none() {
        *v = json!({});
    }
}

/// Ensure `root[key]` exists and is a JSON object; insert `{}` if not.
fn ensure_object_key(root: &mut Value, key: &str) {
    let obj = root.as_object_mut().unwrap();
    match obj.get(key) {
        None => {
            obj.insert(key.to_string(), json!({}));
        }
        Some(v) if !v.is_object() => {
            obj.insert(key.to_string(), json!({}));
        }
        _ => {}
    }
}

/// Insert or replace the `agentsec` key in `servers_obj`.
fn upsert_agentsec(
    servers_obj: &mut serde_json::Map<String, Value>,
    entry: Value,
    force: bool,
) -> ChangeKind {
    if servers_obj.contains_key("agentsec") {
        if force {
            servers_obj.insert("agentsec".to_string(), entry);
            ChangeKind::Updated
        } else {
            ChangeKind::NoOp
        }
    } else {
        servers_obj.insert("agentsec".to_string(), entry);
        ChangeKind::Added
    }
}

fn build_entry(dotenv: Option<&Path>) -> Value {
    if let Some(d) = dotenv {
        json!({
            "command": "agentsec",
            "args": ["mcp"],
            "env": {
                "AGENTSEC_DOTENV": d.display().to_string()
            }
        })
    } else {
        json!({
            "command": "agentsec",
            "args": ["mcp"]
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn add_mcp_entry_user_scope_adds_entry() {
        let mut root = json!({});
        let kind = add_mcp_entry(&mut root, InstallScope::User, None, false);
        assert_eq!(kind, ChangeKind::Added);
        assert!(root["mcpServers"]["agentsec"].is_object());
        assert_eq!(root["mcpServers"]["agentsec"]["command"], "agentsec");
    }

    #[test]
    fn add_mcp_entry_idempotent_without_force() {
        let mut root = json!({});
        add_mcp_entry(&mut root, InstallScope::User, None, false);
        let kind = add_mcp_entry(&mut root, InstallScope::User, None, false);
        assert_eq!(kind, ChangeKind::NoOp);
        // Still exactly one entry
        let count = root["mcpServers"].as_object().unwrap().len();
        assert_eq!(count, 1);
    }

    #[test]
    fn add_mcp_entry_force_overwrites() {
        let mut root = json!({
            "mcpServers": {
                "agentsec": {"command": "old"}
            }
        });
        let kind = add_mcp_entry(&mut root, InstallScope::User, None, true);
        assert_eq!(kind, ChangeKind::Updated);
        assert_eq!(root["mcpServers"]["agentsec"]["command"], "agentsec");
    }

    #[test]
    fn add_mcp_entry_dotenv_adds_env_field() {
        let mut root = json!({});
        let dotenv = std::path::Path::new("/tmp/.env");
        add_mcp_entry(&mut root, InstallScope::User, Some(dotenv), false);
        assert_eq!(
            root["mcpServers"]["agentsec"]["env"]["AGENTSEC_DOTENV"],
            "/tmp/.env"
        );
    }

    #[test]
    fn remove_mcp_entry_removes_agentsec_only() {
        let mut root = json!({
            "mcpServers": {
                "agentsec": {"command": "agentsec"},
                "other": {"command": "other"}
            }
        });
        let kind = remove_mcp_entry(&mut root, InstallScope::User);
        assert_eq!(kind, ChangeKind::Removed);
        assert!(root["mcpServers"]["agentsec"].is_null());
        // other is preserved
        assert_eq!(root["mcpServers"]["other"]["command"], "other");
    }

    #[test]
    fn remove_mcp_entry_noop_when_absent() {
        let mut root = json!({"mcpServers": {}});
        let kind = remove_mcp_entry(&mut root, InstallScope::User);
        assert_eq!(kind, ChangeKind::NoOp);
    }
}