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