1use crate::gate::{ToolCall, Verdict, VerdictKind};
7use libro::{AuditChain, EventSeverity};
8use serde::{Deserialize, Serialize};
9use std::collections::VecDeque;
10use std::sync::Mutex;
11use tokio::sync::RwLock;
12
13const MAX_EVENTS: usize = 10_000;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SecurityEvent {
19 pub id: uuid::Uuid,
20 pub timestamp: chrono::DateTime<chrono::Utc>,
21 pub agent_id: String,
22 pub tool_name: String,
23 pub verdict: VerdictKind,
24 pub reason: Option<String>,
25}
26
27pub struct AuditLogger {
28 events: RwLock<VecDeque<SecurityEvent>>,
30 chain: Mutex<AuditChain>,
32}
33
34impl Default for AuditLogger {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40impl AuditLogger {
41 pub fn new() -> Self {
42 Self {
43 events: RwLock::new(VecDeque::new()),
44 chain: Mutex::new(AuditChain::new()),
45 }
46 }
47
48 pub async fn log(&self, call: &ToolCall, verdict: &Verdict) {
50 let reason = match verdict {
51 Verdict::Allow => None,
52 Verdict::Deny { reason, .. } => Some(reason.clone()),
53 Verdict::Flag { reason } => Some(reason.clone()),
54 };
55
56 let event = SecurityEvent {
57 id: uuid::Uuid::new_v4(),
58 timestamp: chrono::Utc::now(),
59 agent_id: call.agent_id.clone(),
60 tool_name: call.tool_name.clone(),
61 verdict: verdict.kind(),
62 reason: reason.clone(),
63 };
64
65 {
67 let severity = match verdict.kind() {
68 VerdictKind::Allow => EventSeverity::Info,
69 VerdictKind::Flag => EventSeverity::Warning,
70 VerdictKind::Deny => EventSeverity::Security,
71 };
72 let action = match verdict.kind() {
73 VerdictKind::Allow => "tool_call.allow",
74 VerdictKind::Flag => "tool_call.flag",
75 VerdictKind::Deny => "tool_call.deny",
76 };
77 let mut details = serde_json::json!({
78 "tool_name": call.tool_name,
79 });
80 if let Some(ref r) = reason {
81 details["reason"] = serde_json::Value::String(r.clone());
82 }
83 if let Verdict::Deny { code, .. } = verdict {
84 details["deny_code"] = serde_json::Value::String(code.as_str().to_owned());
85 }
86 let mut chain = self.chain.lock().expect("chain lock poisoned");
87 chain.append_with_agent(severity, "t-ron", action, details, &call.agent_id);
88 }
89
90 let mut events = self.events.write().await;
92 events.push_back(event);
93 if events.len() > MAX_EVENTS {
94 events.pop_front();
95 }
96 }
97
98 pub async fn recent(&self, limit: usize) -> Vec<SecurityEvent> {
100 let events = self.events.read().await;
101 events.iter().rev().take(limit).cloned().collect()
102 }
103
104 pub async fn agent_events(&self, agent_id: &str, limit: usize) -> Vec<SecurityEvent> {
106 let events = self.events.read().await;
107 events
108 .iter()
109 .rev()
110 .filter(|e| e.agent_id == agent_id)
111 .take(limit)
112 .cloned()
113 .collect()
114 }
115
116 pub async fn deny_count(&self) -> usize {
118 self.events
119 .read()
120 .await
121 .iter()
122 .filter(|e| e.verdict == VerdictKind::Deny)
123 .count()
124 }
125
126 pub async fn total_count(&self) -> usize {
128 self.events.read().await.len()
129 }
130
131 pub fn verify_chain(&self) -> libro::Result<()> {
133 let chain = self.chain.lock().expect("chain lock poisoned");
134 chain.verify()
135 }
136
137 pub fn chain_review(&self) -> libro::ChainReview {
139 let chain = self.chain.lock().expect("chain lock poisoned");
140 chain.review()
141 }
142
143 pub fn chain_len(&self) -> usize {
146 self.chain.lock().expect("chain lock poisoned").len()
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::gate::DenyCode;
154
155 #[tokio::test]
156 async fn log_and_retrieve() {
157 let logger = AuditLogger::new();
158 let call = ToolCall {
159 agent_id: "agent-1".to_string(),
160 tool_name: "test_tool".to_string(),
161 params: serde_json::json!({}),
162 timestamp: chrono::Utc::now(),
163 };
164
165 logger.log(&call, &Verdict::Allow).await;
166 logger
167 .log(
168 &call,
169 &Verdict::Deny {
170 reason: "nope".into(),
171 code: crate::gate::DenyCode::Unauthorized,
172 },
173 )
174 .await;
175
176 assert_eq!(logger.total_count().await, 2);
177 assert_eq!(logger.deny_count().await, 1);
178
179 let recent = logger.recent(10).await;
180 assert_eq!(recent.len(), 2);
181 assert_eq!(recent[0].verdict, VerdictKind::Deny); }
183
184 #[tokio::test]
185 async fn log_flag_verdict() {
186 let logger = AuditLogger::new();
187 let call = ToolCall {
188 agent_id: "agent-1".to_string(),
189 tool_name: "test_tool".to_string(),
190 params: serde_json::json!({}),
191 timestamp: chrono::Utc::now(),
192 };
193 logger
194 .log(
195 &call,
196 &Verdict::Flag {
197 reason: "suspicious".into(),
198 },
199 )
200 .await;
201
202 let events = logger.recent(1).await;
203 assert_eq!(events[0].verdict, VerdictKind::Flag);
204 assert_eq!(events[0].reason.as_deref(), Some("suspicious"));
205 assert_eq!(logger.deny_count().await, 0);
207 }
208
209 #[tokio::test]
210 async fn agent_events_filtering() {
211 let logger = AuditLogger::new();
212 let call_a = ToolCall {
213 agent_id: "agent-a".to_string(),
214 tool_name: "tool".to_string(),
215 params: serde_json::json!({}),
216 timestamp: chrono::Utc::now(),
217 };
218 let call_b = ToolCall {
219 agent_id: "agent-b".to_string(),
220 tool_name: "tool".to_string(),
221 params: serde_json::json!({}),
222 timestamp: chrono::Utc::now(),
223 };
224
225 for _ in 0..5 {
226 logger.log(&call_a, &Verdict::Allow).await;
227 }
228 for _ in 0..3 {
229 logger.log(&call_b, &Verdict::Allow).await;
230 }
231
232 assert_eq!(logger.agent_events("agent-a", 100).await.len(), 5);
233 assert_eq!(logger.agent_events("agent-b", 100).await.len(), 3);
234 assert_eq!(logger.agent_events("nobody", 100).await.len(), 0);
235 }
236
237 #[tokio::test]
238 async fn agent_events_respects_limit() {
239 let logger = AuditLogger::new();
240 let call = ToolCall {
241 agent_id: "agent-1".to_string(),
242 tool_name: "tool".to_string(),
243 params: serde_json::json!({}),
244 timestamp: chrono::Utc::now(),
245 };
246 for _ in 0..10 {
247 logger.log(&call, &Verdict::Allow).await;
248 }
249 assert_eq!(logger.agent_events("agent-1", 3).await.len(), 3);
250 }
251
252 #[tokio::test]
253 async fn recent_limit_larger_than_count() {
254 let logger = AuditLogger::new();
255 let call = ToolCall {
256 agent_id: "agent-1".to_string(),
257 tool_name: "tool".to_string(),
258 params: serde_json::json!({}),
259 timestamp: chrono::Utc::now(),
260 };
261 logger.log(&call, &Verdict::Allow).await;
262 assert_eq!(logger.recent(100).await.len(), 1);
264 }
265
266 #[tokio::test]
267 async fn empty_log_queries() {
268 let logger = AuditLogger::new();
269 assert_eq!(logger.total_count().await, 0);
270 assert_eq!(logger.deny_count().await, 0);
271 assert!(logger.recent(10).await.is_empty());
272 assert!(logger.agent_events("nobody", 10).await.is_empty());
273 }
274
275 #[tokio::test]
276 async fn max_events_eviction() {
277 let logger = AuditLogger::new();
278 let call = ToolCall {
279 agent_id: "agent-1".to_string(),
280 tool_name: "tool".to_string(),
281 params: serde_json::json!({}),
282 timestamp: chrono::Utc::now(),
283 };
284
285 for _ in 0..(MAX_EVENTS + 100) {
287 logger.log(&call, &Verdict::Allow).await;
288 }
289 assert_eq!(logger.total_count().await, MAX_EVENTS);
290 }
291
292 #[tokio::test]
293 async fn event_has_unique_id() {
294 let logger = AuditLogger::new();
295 let call = ToolCall {
296 agent_id: "agent-1".to_string(),
297 tool_name: "tool".to_string(),
298 params: serde_json::json!({}),
299 timestamp: chrono::Utc::now(),
300 };
301 logger.log(&call, &Verdict::Allow).await;
302 logger.log(&call, &Verdict::Allow).await;
303
304 let events = logger.recent(2).await;
305 assert_ne!(events[0].id, events[1].id);
306 }
307
308 #[tokio::test]
309 async fn chain_written_on_log() {
310 let logger = AuditLogger::new();
311 let call = ToolCall {
312 agent_id: "agent-1".to_string(),
313 tool_name: "tarang_probe".to_string(),
314 params: serde_json::json!({}),
315 timestamp: chrono::Utc::now(),
316 };
317 logger.log(&call, &Verdict::Allow).await;
318 logger
319 .log(
320 &call,
321 &Verdict::Deny {
322 reason: "blocked".into(),
323 code: DenyCode::Unauthorized,
324 },
325 )
326 .await;
327
328 assert_eq!(logger.chain_len(), 2);
329 assert!(logger.verify_chain().is_ok());
330 }
331
332 #[tokio::test]
333 async fn chain_integrity_after_many_writes() {
334 let logger = AuditLogger::new();
335 let call = ToolCall {
336 agent_id: "agent-1".to_string(),
337 tool_name: "tool".to_string(),
338 params: serde_json::json!({}),
339 timestamp: chrono::Utc::now(),
340 };
341 for _ in 0..100 {
342 logger.log(&call, &Verdict::Allow).await;
343 }
344 assert_eq!(logger.chain_len(), 100);
345 assert!(logger.verify_chain().is_ok());
346 }
347
348 #[tokio::test]
349 async fn chain_has_agent_id() {
350 let logger = AuditLogger::new();
351 let call = ToolCall {
352 agent_id: "web-agent".to_string(),
353 tool_name: "tarang_probe".to_string(),
354 params: serde_json::json!({}),
355 timestamp: chrono::Utc::now(),
356 };
357 logger.log(&call, &Verdict::Allow).await;
358
359 let chain = logger.chain.lock().unwrap();
360 let entry = &chain.entries()[0];
361 assert_eq!(entry.agent_id(), Some("web-agent"));
362 assert_eq!(entry.source(), "t-ron");
363 assert_eq!(entry.action(), "tool_call.allow");
364 }
365
366 #[tokio::test]
367 async fn chain_deny_has_details() {
368 let logger = AuditLogger::new();
369 let call = ToolCall {
370 agent_id: "bad-agent".to_string(),
371 tool_name: "aegis_scan".to_string(),
372 params: serde_json::json!({}),
373 timestamp: chrono::Utc::now(),
374 };
375 logger
376 .log(
377 &call,
378 &Verdict::Deny {
379 reason: "rate limit exceeded".into(),
380 code: DenyCode::RateLimited,
381 },
382 )
383 .await;
384
385 let chain = logger.chain.lock().unwrap();
386 let entry = &chain.entries()[0];
387 assert_eq!(entry.action(), "tool_call.deny");
388 assert_eq!(entry.severity(), EventSeverity::Security);
389 let details = entry.details();
390 assert_eq!(details["tool_name"], "aegis_scan");
391 assert_eq!(details["reason"], "rate limit exceeded");
392 assert_eq!(details["deny_code"], "rate_limited");
393 }
394
395 #[tokio::test]
396 async fn chain_review_works() {
397 let logger = AuditLogger::new();
398 let call = ToolCall {
399 agent_id: "agent-1".to_string(),
400 tool_name: "tool".to_string(),
401 params: serde_json::json!({}),
402 timestamp: chrono::Utc::now(),
403 };
404 logger.log(&call, &Verdict::Allow).await;
405
406 let review = logger.chain_review();
407 assert_eq!(review.entry_count, 1);
408 }
409}