#![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;
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()
}
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)
}
}
#[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()
},
);
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");
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()
};
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);
for i in 0..5 {
let _ = guard
.observe_sample("agent-c", BehavioralMetric::CallRate, 10.0, i * 60)
.unwrap();
}
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);
}