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    pub fn new() -> Self {
42        Self {
43            events: RwLock::new(VecDeque::new()),
44            chain: Mutex::new(AuditChain::new()),
45        }
46    }
47
48    /// Log a tool call verdict to both the ring buffer and the libro chain.
49    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        // Write to libro chain (sync lock, fast — no await points)
66        {
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        // Write to operational ring buffer
91        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    /// Get recent events.
99    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    /// Get events for a specific agent.
105    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    /// Count denied calls.
117    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    /// Total event count.
127    pub async fn total_count(&self) -> usize {
128        self.events.read().await.len()
129    }
130
131    /// Verify the libro audit chain integrity.
132    pub fn verify_chain(&self) -> libro::Result<()> {
133        let chain = self.chain.lock().expect("chain lock poisoned");
134        chain.verify()
135    }
136
137    /// Get a structured review/summary of the audit chain.
138    pub fn chain_review(&self) -> libro::ChainReview {
139        let chain = self.chain.lock().expect("chain lock poisoned");
140        chain.review()
141    }
142
143    /// Number of entries in the libro chain (may differ from ring buffer
144    /// if ring buffer has evicted old entries).
145    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); // Most recent first
182    }
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        // Flags are not denials
206        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        // Ask for 100 but only 1 exists
263        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        // Log MAX_EVENTS + 100 events
286        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}