Skip to main content

t_ron/
audit.rs

1//! Audit logger — logs every tool call verdict.
2//!
3//! Dual-writes to an in-memory ring buffer (fast operational queries) and a
4//! libro audit chain (tamper-proof cryptographic hash chain).
5
6use 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
13/// Maximum audit events kept in the operational ring buffer.
14const MAX_EVENTS: usize = 10_000;
15
16/// A logged security event (operational view).
17#[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    /// Fast ring buffer for operational queries (risk scoring, recent events).
29    events: RwLock<VecDeque<SecurityEvent>>,
30    /// Cryptographic hash chain for tamper-proof audit trail.
31    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    /// Log a tool call verdict to both the ring buffer and the libro chain.
50    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        // Write to libro chain (sync lock, fast — no await points)
67        {
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        // Write to operational ring buffer
95        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    /// Get recent events.
103    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    /// Get events for a specific agent.
109    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    /// Count denied calls.
121    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    /// Total event count.
131    pub async fn total_count(&self) -> usize {
132        self.events.read().await.len()
133    }
134
135    /// Verify the libro audit chain integrity.
136    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    /// Get a structured review/summary of the audit chain.
145    #[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    /// Number of entries in the libro chain (may differ from ring buffer
155    /// if ring buffer has evicted old entries).
156    #[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); // Most recent first
197    }
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        // Flags are not denials
221        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        // Ask for 100 but only 1 exists
278        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        // Log MAX_EVENTS + 100 events
301        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}