aiward 0.5.27

Local-first AI secret firewall for development environments.
Documentation
use std::collections::BTreeSet;

use chrono::{DateTime, Timelike, Utc};
use serde::Serialize;
use serde_json::Value;

use crate::config::AnomalyDetectionConfig;

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AnomalyAlert {
    #[serde(rename = "type")]
    pub event_type: &'static str,
    pub code: &'static str,
    pub message: String,
    pub grant_id: uuid::Uuid,
    pub count: usize,
}

pub fn detect_grant_anomalies(
    config: &AnomalyDetectionConfig,
    events: &[Value],
    grant_id: uuid::Uuid,
    now: DateTime<Utc>,
) -> Vec<AnomalyAlert> {
    if !config.enabled {
        return Vec::new();
    }

    let samples = execution_samples(events, grant_id);
    let mut alerts = Vec::new();
    let recent_count = samples
        .iter()
        .filter(|sample| now.signed_duration_since(sample.timestamp).num_minutes() <= 60)
        .count();
    if recent_count > config.max_runs_per_hour_per_grant {
        alerts.push(AnomalyAlert {
            event_type: "anomaly.grant_frequency",
            code: "anomaly.grant_frequency",
            message: format!("grant used {recent_count} times in the last hour"),
            grant_id,
            count: recent_count,
        });
    }

    let hour = now.hour() as u8;
    if outside_working_hours(hour, config.working_hours_start, config.working_hours_end) {
        alerts.push(AnomalyAlert {
            event_type: "anomaly.outside_hours",
            code: "anomaly.outside_hours",
            message: format!("grant used outside configured working hours at {hour:02}:00"),
            grant_id,
            count: 1,
        });
    }

    let branches = samples
        .iter()
        .filter_map(|sample| sample.branch.as_deref())
        .collect::<BTreeSet<_>>();
    if branches.len() > config.max_branches_per_grant {
        alerts.push(AnomalyAlert {
            event_type: "anomaly.grant_branch_spread",
            code: "anomaly.grant_branch_spread",
            message: format!("grant used across {} branches", branches.len()),
            grant_id,
            count: branches.len(),
        });
    }

    alerts
}

fn execution_samples(events: &[Value], grant_id: uuid::Uuid) -> Vec<ExecutionSample> {
    events
        .iter()
        .filter_map(|event| {
            let timestamp = event
                .get("timestamp")
                .and_then(Value::as_str)
                .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
                .map(|value| value.with_timezone(&Utc))?;
            let payload = event.get("payload")?;
            if payload.get("type").and_then(Value::as_str) != Some("execution.finished") {
                return None;
            }
            let event_grant_id = payload
                .get("grantId")
                .and_then(Value::as_str)
                .and_then(|value| uuid::Uuid::parse_str(value).ok())?;
            if event_grant_id != grant_id {
                return None;
            }
            Some(ExecutionSample {
                timestamp,
                branch: payload
                    .get("branch")
                    .and_then(Value::as_str)
                    .map(str::to_string),
            })
        })
        .collect()
}

fn outside_working_hours(hour: u8, start: u8, end: u8) -> bool {
    if start == end {
        return false;
    }
    if start < end {
        hour < start || hour >= end
    } else {
        hour < start && hour >= end
    }
}

#[derive(Debug)]
struct ExecutionSample {
    timestamp: DateTime<Utc>,
    branch: Option<String>,
}

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

    fn config() -> AnomalyDetectionConfig {
        AnomalyDetectionConfig {
            enabled: true,
            working_hours_start: 8,
            working_hours_end: 20,
            max_runs_per_hour_per_grant: 2,
            max_branches_per_grant: 2,
        }
    }

    fn event(grant_id: uuid::Uuid, branch: &str, timestamp: DateTime<Utc>) -> Value {
        json!({
            "timestamp": timestamp.to_rfc3339(),
            "payload": {
                "type": "execution.finished",
                "grantId": grant_id,
                "branch": branch
            }
        })
    }

    #[test]
    fn detects_frequency_hours_and_branch_spread() {
        let grant_id = uuid::Uuid::new_v4();
        let now = Utc.with_ymd_and_hms(2026, 5, 26, 21, 0, 0).unwrap();
        let events = vec![
            event(grant_id, "a", now),
            event(grant_id, "b", now),
            event(grant_id, "c", now),
        ];

        let alerts = detect_grant_anomalies(&config(), &events, grant_id, now);

        assert!(alerts
            .iter()
            .any(|alert| alert.code == "anomaly.grant_frequency"));
        assert!(alerts
            .iter()
            .any(|alert| alert.code == "anomaly.outside_hours"));
        assert!(alerts
            .iter()
            .any(|alert| alert.code == "anomaly.grant_branch_spread"));
    }

    #[test]
    fn ignores_disabled_clean_other_and_malformed_events() {
        let grant_id = uuid::Uuid::new_v4();
        let other_id = uuid::Uuid::new_v4();
        let now = Utc.with_ymd_and_hms(2026, 5, 26, 12, 0, 0).unwrap();
        let events = vec![
            event(other_id, "a", now),
            json!({ "timestamp": now.to_rfc3339(), "payload": { "type": "execution.started" }}),
            json!({ "timestamp": "bad", "payload": {} }),
        ];

        assert!(detect_grant_anomalies(&config(), &events, grant_id, now).is_empty());
        let disabled = AnomalyDetectionConfig {
            enabled: false,
            ..config()
        };
        assert!(detect_grant_anomalies(&disabled, &events, grant_id, now).is_empty());
    }

    #[test]
    fn handles_overnight_working_hours() {
        assert!(!outside_working_hours(23, 20, 8));
        assert!(!outside_working_hours(7, 20, 8));
        assert!(outside_working_hours(12, 20, 8));
        assert!(!outside_working_hours(12, 12, 12));
    }
}