sandspy 0.1.1

Real-time security monitor for AI coding agents
Documentation
// sandspy::daemon — Background daemon mode

use crate::alerts;
use anyhow::{Context, Result};
use chrono::Utc;
use std::collections::{BTreeMap, HashSet};
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::Duration;
use sysinfo::{Pid, ProcessesToUpdate, System};
use tokio::time;

pub async fn start() -> Result<()> {
    let pid_path = daemon_pid_path();
    let log_path = daemon_log_path();
    ensure_sandspy_dir()?;

    if let Some(existing_pid) = read_pid_file(&pid_path)? {
        if is_pid_alive(existing_pid) {
            println!("daemon already running (pid {})", existing_pid);
            return Ok(());
        }
    }

    fs::write(&pid_path, std::process::id().to_string())
        .with_context(|| format!("failed to write pid file: {}", pid_path.display()))?;
    append_log(&log_path, "daemon started")?;

    let mut system = System::new_all();
    let mut monitored = BTreeMap::<u32, String>::new();

    loop {
        system.refresh_processes(ProcessesToUpdate::All, true);
        let known = known_agent_names();

        for process in system.processes().values() {
            let process_name = process.name().to_string_lossy().to_string();
            let normalized = normalize_name(&process_name);

            if !known.contains(normalized.as_str()) {
                continue;
            }

            let process_pid = process.pid().as_u32();
            if monitored.contains_key(&process_pid) {
                continue;
            }

            let watch_pid = spawn_watch_child(&process_name)?;
            monitored.insert(process_pid, process_name.clone());
            append_log(
                &log_path,
                &format!(
                    "agent_detected name={} pid={} watch_pid={}",
                    process_name, process_pid, watch_pid
                ),
            )?;
            let _ = alerts::notify("sandspy", &format!("agent detected: {}", process_name));
        }

        let alive_pids = system
            .processes()
            .values()
            .map(|p| p.pid().as_u32())
            .collect::<HashSet<_>>();
        monitored.retain(|pid, _| alive_pids.contains(pid));

        time::sleep(Duration::from_secs(5)).await;
    }
}

pub async fn stop() -> Result<()> {
    let pid_path = daemon_pid_path();
    let log_path = daemon_log_path();
    let pid = read_pid_file(&pid_path)?.context("daemon pid file not found or empty")?;

    terminate_pid(pid)?;

    if pid_path.exists() {
        fs::remove_file(&pid_path)
            .with_context(|| format!("failed to remove pid file: {}", pid_path.display()))?;
    }

    let _ = append_log(&log_path, &format!("daemon stopped pid={pid}"));
    println!("daemon stopped (pid {})", pid);
    Ok(())
}

pub async fn status() -> Result<()> {
    let pid_path = daemon_pid_path();
    let pid = read_pid_file(&pid_path)?;

    match pid {
        Some(value) if is_pid_alive(value) => {
            println!("running (pid {})", value);
        }
        _ => {
            println!("not running");
        }
    }

    Ok(())
}

pub async fn watch() -> Result<()> {
    let log_path = daemon_log_path();
    if !log_path.exists() {
        println!("no daemon log found");
        return Ok(());
    }

    let file = OpenOptions::new()
        .read(true)
        .open(&log_path)
        .with_context(|| format!("failed to open log: {}", log_path.display()))?;
    let reader = BufReader::new(file);
    let mut names = Vec::<String>::new();
    let lines: Vec<String> = reader.lines().map_while(|l| l.ok()).collect();

    for line in lines.iter().rev().take(500) {
        if let Some(name) = extract_agent_name(line) {
            if !names.iter().any(|existing| existing == &name) {
                names.push(name);
            }
        }
    }

    if names.is_empty() {
        println!("no monitored agents found in daemon log");
        return Ok(());
    }

    println!("currently monitored agents:");
    for name in names.into_iter().rev() {
        println!("- {}", name);
    }

    Ok(())
}

fn sandspy_dir() -> PathBuf {
    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    home.join(".sandspy")
}

fn daemon_pid_path() -> PathBuf {
    sandspy_dir().join("daemon.pid")
}

fn daemon_log_path() -> PathBuf {
    sandspy_dir().join("daemon.log")
}

fn ensure_sandspy_dir() -> Result<()> {
    let dir = sandspy_dir();
    fs::create_dir_all(&dir)
        .with_context(|| format!("failed to create sandspy dir: {}", dir.display()))
}

fn append_log(path: &PathBuf, message: &str) -> Result<()> {
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .with_context(|| format!("failed to open daemon log: {}", path.display()))?;
    writeln!(file, "{} {}", Utc::now().to_rfc3339(), message)
        .with_context(|| format!("failed to append daemon log: {}", path.display()))
}

fn read_pid_file(path: &PathBuf) -> Result<Option<u32>> {
    if !path.exists() {
        return Ok(None);
    }

    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read pid file: {}", path.display()))?;
    let trimmed = content.trim();
    if trimmed.is_empty() {
        return Ok(None);
    }

    let pid = trimmed
        .parse::<u32>()
        .with_context(|| format!("invalid pid value in {}", path.display()))?;
    Ok(Some(pid))
}

fn is_pid_alive(pid: u32) -> bool {
    let mut system = System::new_all();
    system.refresh_processes(ProcessesToUpdate::All, true);
    system.process(Pid::from_u32(pid)).is_some()
}

#[cfg(unix)]
fn terminate_pid(pid: u32) -> Result<()> {
    let result = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
    if result != 0 {
        anyhow::bail!("failed to send SIGTERM to pid {}", pid);
    }
    Ok(())
}

#[cfg(target_os = "windows")]
fn terminate_pid(pid: u32) -> Result<()> {
    use windows::Win32::Foundation::CloseHandle;
    use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};

    unsafe {
        let handle = OpenProcess(PROCESS_TERMINATE, false, pid)?;
        TerminateProcess(handle, 1)?;
        let _ = CloseHandle(handle);
    }

    Ok(())
}

#[cfg(not(any(unix, target_os = "windows")))]
fn terminate_pid(_pid: u32) -> Result<()> {
    Ok(())
}

fn spawn_watch_child(process_name: &str) -> Result<u32> {
    let exe = std::env::current_exe().context("failed to resolve current executable")?;
    let mut command = Command::new(exe);
    command
        .arg("watch")
        .arg(process_name)
        .arg("--json")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .stdin(Stdio::null());

    #[cfg(target_os = "windows")]
    {
        use std::os::windows::process::CommandExt;
        const DETACHED_PROCESS: u32 = 0x00000008;
        const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
        command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
    }

    let child = command
        .spawn()
        .with_context(|| format!("failed to spawn watch child for agent {process_name}"))?;
    Ok(child.id())
}

fn known_agent_names() -> HashSet<&'static str> {
    [
        "claude",
        "claude-code",
        "cursor",
        "copilot",
        "gemini",
        "gemini-cli",
        "codex",
        "windsurf",
        "cline",
        "aider",
        "continue",
        "antigravity",
        "openclaw",
    ]
    .into_iter()
    .collect()
}

fn normalize_name(value: &str) -> String {
    value
        .to_ascii_lowercase()
        .trim_end_matches(".exe")
        .to_string()
}

fn extract_agent_name(line: &str) -> Option<String> {
    let token = "agent_detected name=";
    let start = line.find(token)? + token.len();
    let rest = &line[start..];
    let end = rest.find(" pid=").unwrap_or(rest.len());
    let value = rest[..end].trim();
    if value.is_empty() {
        None
    } else {
        Some(value.to_string())
    }
}