sandspy 0.1.1

Real-time security monitor for AI coding agents
Documentation
// sandspy::events — Core event types and event bus
//
// Every monitor module produces events, every output mode consumes them.
// All communication flows through a single tokio mpsc channel.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum EventKind {
    // Filesystem
    FileRead {
        path: PathBuf,
        sensitive: bool,
        category: FileCategory,
    },
    FileWrite {
        path: PathBuf,
        diff_summary: Option<String>,
    },
    FileDelete {
        path: PathBuf,
    },
    // Network
    NetworkConnection {
        remote_addr: String,
        remote_port: u16,
        domain: Option<String>,
        category: NetCategory,
        bytes_sent: u64,
        bytes_recv: u64,
    },
    // Process
    ProcessSpawn {
        pid: u32,
        name: String,
        cmdline: String,
        parent_pid: u32,
    },
    ProcessExit {
        pid: u32,
        exit_code: Option<i32>,
    },
    // Commands
    ShellCommand {
        command: String,
        working_dir: PathBuf,
        risk: RiskLevel,
    },
    CommandComplete {
        command: String,
        exit_code: i32,
        duration: Duration,
    },
    // Secrets
    SecretAccess {
        name: String,
        source: SecretSource,
    },
    EnvVarRead {
        name: String,
        sensitive: bool,
    },
    // Clipboard
    ClipboardRead {
        content_type: String,
        contains_secret: bool,
    },
    ClipboardWrite {
        content_type: String,
    },
    // Alerts (generated by analysis engine)
    Alert {
        message: String,
        severity: RiskLevel,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum RiskLevel {
    Low,
    Medium,
    High,
    Critical,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileCategory {
    Source,
    Config,
    Secret,
    Binary,
    Data,
    Documentation,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NetCategory {
    ExpectedApi,
    Telemetry,
    Tracking,
    Unknown,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SecretSource {
    File,
    EnvVar,
    Clipboard,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
    pub timestamp: DateTime<Utc>,
    pub kind: EventKind,
    pub risk_score: u32,
}

impl Event {
    pub fn new(kind: EventKind) -> Self {
        Self {
            timestamp: Utc::now(),
            kind,
            risk_score: 0,
        }
    }

    #[allow(dead_code)]
    pub fn with_risk(kind: EventKind, risk_score: u32) -> Self {
        Self {
            timestamp: Utc::now(),
            kind,
            risk_score,
        }
    }
}

/// Create the central event bus.
/// Returns (sender, receiver). Clone the sender for each monitor module.
pub fn create_event_bus() -> (mpsc::Sender<Event>, mpsc::Receiver<Event>) {
    mpsc::channel(10_000)
}

/// Join a command line from OsString parts. Used across monitor modules.
pub fn join_cmdline(cmdline: &[std::ffi::OsString]) -> String {
    cmdline
        .iter()
        .map(|arg| arg.to_string_lossy().to_string())
        .collect::<Vec<_>>()
        .join(" ")
}

/// Info about a detected AI agent process.
#[derive(Debug, Clone)]
pub struct AgentInfo {
    pub pid: u32,
    pub name: String,
    pub uptime_secs: u64,
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn test_event_creation_basics() {
        let e = Event::new(EventKind::FileRead {
            path: PathBuf::from("/etc/passwd"),
            sensitive: true,
            category: crate::events::FileCategory::Config,
        });
        assert_eq!(e.risk_score, 0); // Defaults to 0
        match e.kind {
            EventKind::FileRead { path, .. } => {
                assert_eq!(path.to_str().unwrap(), "/etc/passwd");
            }
            _ => panic!("Wrong variant!"),
        }
    }

    #[test]
    fn test_event_with_risk() {
        let e = Event::with_risk(
            EventKind::ClipboardRead {
                content_type: "text".to_string(),
                contains_secret: true,
            },
            55,
        );
        assert_eq!(e.risk_score, 55);
    }
}