sandspy 0.1.1

Real-time security monitor for AI coding agents
Documentation
// sandspy::report — Report generation

use crate::events::{Event, RiskLevel};
use crate::ui::summary::{self, extract_findings, SessionData};
use anyhow::{Context, Result};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::PathBuf;

pub mod html;
pub mod json;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetadata {
    pub agent_name: String,
    pub pid: Option<u32>,
    pub duration: u64,
    pub risk_score: u32,
    pub event_count: usize,
    pub timestamp: DateTime<Utc>,
}

#[derive(Debug, Serialize)]
pub struct JsonReport {
    pub metadata: SessionMetadata,
    pub events: Vec<Event>,
    pub findings: Vec<JsonFinding>,
}

#[derive(Debug, Serialize)]
pub struct JsonFinding {
    pub severity: RiskLevel,
    pub message: String,
}

pub fn load_session(session_id: &str) -> Result<(SessionMetadata, Vec<Event>)> {
    let session_dir = resolve_session_dir(session_id)?;
    let metadata_path = session_dir.join("metadata.json");
    let events_path = session_dir.join("events.jsonl");

    let metadata_content = fs::read_to_string(&metadata_path)
        .with_context(|| format!("failed to read metadata file: {}", metadata_path.display()))?;
    let metadata = serde_json::from_str::<SessionMetadata>(&metadata_content)
        .with_context(|| format!("failed to parse metadata file: {}", metadata_path.display()))?;

    let file = File::open(&events_path)
        .with_context(|| format!("failed to open events file: {}", events_path.display()))?;
    let reader = BufReader::new(file);
    let mut events = Vec::new();
    for line in reader.lines() {
        let line =
            line.with_context(|| format!("failed reading line from {}", events_path.display()))?;
        if line.trim().is_empty() {
            continue;
        }
        let event = serde_json::from_str::<Event>(&line)
            .with_context(|| format!("invalid JSON event in {}", events_path.display()))?;
        events.push(event);
    }

    Ok((metadata, events))
}

pub fn print_markdown_summary(metadata: SessionMetadata, events: Vec<Event>) {
    let start = metadata.timestamp;
    let end = start + ChronoDuration::seconds(metadata.duration as i64);
    let data = SessionData {
        agent_name: metadata.agent_name,
        agent_pid: metadata.pid,
        start,
        end,
        events,
        risk_score: metadata.risk_score,
    };
    summary::print_summary(&data);
}

pub fn build_json_report(metadata: SessionMetadata, events: Vec<Event>) -> JsonReport {
    let findings = extract_findings(&events)
        .into_iter()
        .map(|finding| JsonFinding {
            severity: finding.severity,
            message: finding.message,
        })
        .collect();

    JsonReport {
        metadata,
        events,
        findings,
    }
}

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

fn resolve_session_dir(session_id: &str) -> Result<PathBuf> {
    let root = sessions_root();
    let exact = root.join(session_id);
    if exact.exists() {
        return Ok(exact);
    }

    let mut matches = Vec::new();
    for entry in fs::read_dir(&root)
        .with_context(|| format!("failed to read sessions root: {}", root.display()))?
    {
        let entry = entry.with_context(|| format!("failed to read entry in {}", root.display()))?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let id = entry.file_name().to_string_lossy().to_string();
        if id.starts_with(session_id) {
            matches.push(path);
        }
    }

    matches.sort();
    matches
        .into_iter()
        .next()
        .with_context(|| format!("session not found: {session_id}"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::events::{EventKind, RiskLevel};
    use std::path::PathBuf;

    #[test]
    fn test_extract_findings() {
        let events = vec![
            Event {
                timestamp: chrono::Utc::now(),
                risk_score: 10,
                kind: EventKind::SecretAccess {
                    name: "AWSAccessKey".to_string(),
                    source: crate::events::SecretSource::File,
                },
            },
            Event {
                timestamp: chrono::Utc::now(),
                risk_score: 0,
                kind: EventKind::FileRead {
                    path: PathBuf::from("random.txt"),
                    sensitive: false,
                    category: crate::events::FileCategory::Data,
                },
            },
            Event {
                timestamp: chrono::Utc::now(),
                risk_score: 100,
                kind: EventKind::NetworkConnection {
                    remote_addr: "100.10.10.1".to_string(),
                    remote_port: 80,
                    domain: None,
                    category: crate::events::NetCategory::Unknown,
                    bytes_sent: 0,
                    bytes_recv: 0,
                },
            },
        ];

        let findings = extract_findings(&events);
        assert_eq!(findings.len(), 2);

        // Ensure sorted by severity high -> low
        assert_eq!(findings[0].severity, RiskLevel::Critical);
        assert!(findings[0].message.contains("AWSAccessKey"));

        assert_eq!(findings[1].severity, RiskLevel::High);
        assert!(findings[1].message.contains("unknown network"));
    }
}