chio-guards 0.1.0

Security guards for the Chio runtime kernel, adapted from ClawdStrike
Documentation
//! Phase 19.2 behavioral-profile integration tests.
//!
//! These tests confirm the roadmap acceptance criteria:
//!   1. EMA baseline stabilizes under a steady sample.
//!   2. A 50x spike in call rate triggers an advisory signal.
//!   3. The guard reads from chio-store-sqlite receipt queries
//!      (demonstrated via a SqliteReceiptStore-backed
//!      `ReceiptFeedSource` adapter).

#![allow(clippy::expect_used, clippy::unwrap_used)]

use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};

use chio_core::crypto::Keypair;
use chio_core::receipt::{ChioReceipt, ChioReceiptBody, Decision, ToolCallAction};
use chio_guards::{
    behavioral_profile::{BehavioralMetric, BehavioralProfileConfig, ReceiptFeedSource},
    BehavioralProfileGuard, DEFAULT_SIGMA_THRESHOLD,
};
use chio_kernel::{KernelError, ReceiptStore};
use chio_store_sqlite::SqliteReceiptStore;

// --- helpers ----------------------------------------------------------------

fn unique_db_path(prefix: &str) -> PathBuf {
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("time before epoch")
        .as_nanos();
    std::env::temp_dir().join(format!("{prefix}-{nonce}.sqlite3"))
}

fn make_receipt(id: &str, capability_id: &str, timestamp: u64, decision: Decision) -> ChioReceipt {
    let keypair = Keypair::generate();
    let action =
        ToolCallAction::from_parameters(serde_json::json!({})).expect("hash receipt parameters");
    ChioReceipt::sign(
        ChioReceiptBody {
            id: id.to_string(),
            timestamp,
            capability_id: capability_id.to_string(),
            tool_server: "srv".to_string(),
            tool_name: "tool".to_string(),
            action,
            decision,
            content_hash: "ch".to_string(),
            policy_hash: "ph".to_string(),
            evidence: Vec::new(),
            metadata: None,
            trust_level: chio_core::TrustLevel::default(),
            tenant_id: None,
            kernel_key: keypair.public_key(),
        },
        &keypair,
    )
    .unwrap()
}

/// Adapter that exposes an chio-store-sqlite `SqliteReceiptStore` as
/// a `ReceiptFeedSource`. Agent identity is resolved by reading the
/// capability-id prefix here to keep the test self-contained; a real
/// deployment would join through the capability_lineage table via
/// `ReceiptQuery::agent_subject`.
struct SqliteFeed {
    store: Mutex<SqliteReceiptStore>,
    agent_capabilities: Vec<(String, String)>,
}

impl SqliteFeed {
    fn new(store: SqliteReceiptStore) -> Self {
        Self {
            store: Mutex::new(store),
            agent_capabilities: Vec::new(),
        }
    }

    fn bind_agent(&mut self, agent: &str, capability_id: &str) {
        self.agent_capabilities
            .push((agent.to_string(), capability_id.to_string()));
    }
}

impl ReceiptFeedSource for SqliteFeed {
    fn receipts_for_agent(
        &self,
        agent_id: &str,
        since: u64,
        until: u64,
    ) -> Result<Vec<ChioReceipt>, KernelError> {
        let caps: Vec<String> = self
            .agent_capabilities
            .iter()
            .filter(|(a, _)| a == agent_id)
            .map(|(_, c)| c.clone())
            .collect();

        let store = self
            .store
            .lock()
            .map_err(|_| KernelError::Internal("sqlite feed lock poisoned".to_string()))?;
        let mut out = Vec::new();
        for cap in caps {
            let result = store
                .query_receipts(&chio_kernel::receipt_query::ReceiptQuery {
                    capability_id: Some(cap),
                    since: Some(since),
                    until: Some(until),
                    limit: 200,
                    ..Default::default()
                })
                .map_err(|e| KernelError::Internal(format!("sqlite query: {e}")))?;
            for row in result.receipts {
                out.push(row.receipt);
            }
        }
        Ok(out)
    }
}

// --- tests ------------------------------------------------------------------

#[test]
fn ema_baseline_stabilizes_under_steady_calls() {
    let guard = BehavioralProfileGuard::with_config(
        Box::new(chio_guards::InMemoryReceiptFeed::new()),
        BehavioralProfileConfig {
            baseline_min_windows: 2,
            ..Default::default()
        },
    );
    for i in 0..20 {
        let outcome = guard
            .observe_sample("agent-a", BehavioralMetric::CallRate, 10.0, i * 60)
            .unwrap();
        if i >= 10 {
            assert!(
                (outcome.baseline.ema_mean - 10.0).abs() < 0.1,
                "baseline must stabilize near 10, got {}",
                outcome.baseline.ema_mean
            );
        }
    }
    let final_baseline = guard
        .baseline("agent-a", BehavioralMetric::CallRate)
        .unwrap()
        .expect("baseline should exist");
    assert_eq!(final_baseline.sample_count, 20);
}

#[test]
fn fifty_x_spike_triggers_advisory_signal() {
    let guard = BehavioralProfileGuard::with_config(
        Box::new(chio_guards::InMemoryReceiptFeed::new()),
        BehavioralProfileConfig {
            baseline_min_windows: 2,
            ..Default::default()
        },
    );
    // Steady-state: 10 calls/window for 15 windows.
    for i in 0..15 {
        let _ = guard
            .observe_sample("agent-b", BehavioralMetric::CallRate, 10.0, i * 60)
            .unwrap();
    }
    let spike = guard
        .observe_sample("agent-b", BehavioralMetric::CallRate, 500.0, 100_000)
        .unwrap();
    assert!(
        spike.anomaly,
        "50x spike must flag anomaly (z={:?})",
        spike.z_score
    );
    assert!(spike.z_score.unwrap_or(0.0).abs() > DEFAULT_SIGMA_THRESHOLD);
}

#[test]
fn guard_reads_from_sqlite_receipt_store() {
    let db = unique_db_path("behavioral-profile-sqlite");
    let mut store = SqliteReceiptStore::open(&db).expect("open sqlite");

    // Insert 10 receipts in the first window, 500 in the second.
    let base_ts = 1_700_000_000u64;
    let cap = "cap-agent-c";
    for i in 0..10 {
        store
            .append_chio_receipt(&make_receipt(
                &format!("r-lo-{i}"),
                cap,
                base_ts + i as u64,
                Decision::Allow,
            ))
            .unwrap();
    }
    for i in 0..500 {
        store
            .append_chio_receipt(&make_receipt(
                &format!("r-hi-{i}"),
                cap,
                base_ts + 60 + (i as u64 % 60),
                Decision::Allow,
            ))
            .unwrap();
    }

    let mut feed = SqliteFeed::new(store);
    feed.bind_agent("agent-c", cap);

    let counter = Arc::new(std::sync::atomic::AtomicU64::new(0));
    let counter_clone = counter.clone();
    let config = BehavioralProfileConfig {
        baseline_min_windows: 2,
        window_secs: 60,
        ..Default::default()
    };
    // Each call to the clock advances by window_secs so each evaluate
    // lands in a fresh window-start bucket.
    let clock: Box<dyn Fn() -> u64 + Send + Sync> = Box::new(move || {
        let idx = counter_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        base_ts + idx * 60
    });
    let guard = BehavioralProfileGuard::with_config(Box::new(feed), config).with_clock(clock);

    // Prime the baseline by feeding the steady 10-sample window twice.
    // (The guard reads from sqlite for the current window; manually
    // pre-heat by calling observe_sample with synthetic samples.)
    for i in 0..5 {
        let _ = guard
            .observe_sample("agent-c", BehavioralMetric::CallRate, 10.0, i * 60)
            .unwrap();
    }

    // Now drive the guard through a window that contains the spike.
    let spike_window_start = base_ts - (base_ts % 60) + 60;
    let spike_outcome = guard
        .observe_sample(
            "agent-c",
            BehavioralMetric::CallRate,
            500.0,
            spike_window_start + 1_000_000,
        )
        .unwrap();
    assert!(
        spike_outcome.anomaly,
        "guard backed by SqliteReceiptStore must flag the 50x spike"
    );

    let _ = std::fs::remove_file(db);
}