sandspy 0.1.1

Real-time security monitor for AI coding agents
Documentation
// sandspy::analysis::profiler — Agent profile loading and matching
#![allow(dead_code)]

use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Deserialize)]
pub struct AgentProfile {
    #[serde(skip)]
    pub id: String,
    pub agent: AgentSection,
    pub expected: ExpectedSection,
    pub alerts: AlertsSection,
    pub risk_weights: RiskWeights,
}

#[derive(Debug, Clone, Deserialize)]
pub struct AgentSection {
    pub name: String,
    pub process_names: Vec<String>,
    pub description: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ExpectedSection {
    pub network: ExpectedNetwork,
    pub filesystem: ExpectedFilesystem,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ExpectedNetwork {
    pub allowed_domains: Vec<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ExpectedFilesystem {
    pub normal_patterns: Vec<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct AlertsSection {
    pub sensitive_file_access: bool,
    pub unknown_network: bool,
    pub shell_dangerous_commands: bool,
    pub clipboard_read: bool,
    pub env_secret_access: bool,
    pub excessive_file_reads: u32,
    pub excessive_data_out: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct RiskWeights {
    pub sensitive_file_read: u32,
    pub unknown_network_connection: u32,
    pub secret_env_access: u32,
    pub dangerous_command: u32,
    pub clipboard_read: u32,
    pub excessive_scope: u32,
}

pub fn load_profiles() -> Result<Vec<AgentProfile>> {
    let mut profiles = load_builtin_profiles()?;
    load_user_overrides(&mut profiles)?;

    let mut values = profiles.into_values().collect::<Vec<_>>();
    values.sort_by(|left, right| left.id.cmp(&right.id));
    Ok(values)
}

pub fn match_profile<'a>(
    profiles: &'a [AgentProfile],
    forced_profile: Option<&str>,
    process_or_command: Option<&str>,
) -> Option<&'a AgentProfile> {
    if let Some(forced) = forced_profile {
        return find_by_id_or_name(profiles, forced);
    }

    if let Some(process) = process_or_command {
        let normalized = normalize_name(process);
        if let Some(profile) = profiles.iter().find(|profile| {
            profile
                .agent
                .process_names
                .iter()
                .any(|name| normalize_name(name) == normalized)
        }) {
            return Some(profile);
        }
    }

    find_by_id_or_name(profiles, "generic")
}

fn find_by_id_or_name<'a>(profiles: &'a [AgentProfile], name: &str) -> Option<&'a AgentProfile> {
    let normalized = normalize_name(name);
    profiles.iter().find(|profile| {
        normalize_name(&profile.id) == normalized
            || normalize_name(&profile.agent.name) == normalized
    })
}

fn load_builtin_profiles() -> Result<HashMap<String, AgentProfile>> {
    let mut profiles = HashMap::new();

    for (id, content) in builtin_profile_sources() {
        let mut profile = parse_profile(id, content)
            .with_context(|| format!("failed to parse built-in profile: {id}"))?;
        profile.id = id.to_string();
        profiles.insert(id.to_string(), profile);
    }

    Ok(profiles)
}

fn load_user_overrides(profiles: &mut HashMap<String, AgentProfile>) -> Result<()> {
    let user_profiles_dir = sandspy_profiles_dir();
    if !user_profiles_dir.exists() {
        return Ok(());
    }

    for entry in fs::read_dir(&user_profiles_dir).with_context(|| {
        format!(
            "failed to read user profiles dir: {}",
            user_profiles_dir.display()
        )
    })? {
        let entry = match entry {
            Ok(value) => value,
            Err(error) => {
                tracing::warn!(%error, "skipping unreadable user profile entry");
                continue;
            }
        };

        let path = entry.path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
            continue;
        }

        let profile_id = profile_id_from_path(&path).unwrap_or_else(|| "generic".to_string());
        let content = match fs::read_to_string(&path) {
            Ok(value) => value,
            Err(error) => {
                tracing::warn!(%error, path = %path.display(), "failed to read user profile");
                continue;
            }
        };

        match parse_profile(&profile_id, &content) {
            Ok(mut profile) => {
                profile.id = profile_id.clone();
                profiles.insert(profile_id, profile);
            }
            Err(error) => {
                tracing::warn!(%error, path = %path.display(), "failed to parse user profile");
            }
        }
    }

    Ok(())
}

fn parse_profile(id: &str, content: &str) -> Result<AgentProfile> {
    let mut profile = toml::from_str::<AgentProfile>(content)
        .with_context(|| format!("invalid profile TOML: {id}"))?;
    profile.id = id.to_string();
    Ok(profile)
}

fn profile_id_from_path(path: &Path) -> Option<String> {
    path.file_stem()
        .and_then(|value| value.to_str())
        .map(ToString::to_string)
}

fn normalize_name(value: &str) -> String {
    let lower = value.to_ascii_lowercase();
    let token = lower.split_whitespace().next().unwrap_or_default();
    if let Some(stripped) = token.strip_suffix(".exe") {
        stripped.to_string()
    } else {
        token.to_string()
    }
}

fn builtin_profile_sources() -> Vec<(&'static str, &'static str)> {
    vec![
        ("aider", include_str!("../../profiles/aider.toml")),
        (
            "antigravity",
            include_str!("../../profiles/antigravity.toml"),
        ),
        (
            "claude-code",
            include_str!("../../profiles/claude-code.toml"),
        ),
        ("cline", include_str!("../../profiles/cline.toml")),
        ("codex-cli", include_str!("../../profiles/codex-cli.toml")),
        ("continue", include_str!("../../profiles/continue.toml")),
        ("cursor", include_str!("../../profiles/cursor.toml")),
        ("gemini-cli", include_str!("../../profiles/gemini-cli.toml")),
        ("generic", include_str!("../../profiles/generic.toml")),
        ("openclaw", include_str!("../../profiles/openclaw.toml")),
        ("windsurf", include_str!("../../profiles/windsurf.toml")),
    ]
}

fn sandspy_profiles_dir() -> PathBuf {
    let home = env::var("HOME")
        .ok()
        .or_else(|| env::var("USERPROFILE").ok())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."));
    home.join(".sandspy").join("profiles")
}