Skip to main content

oxicode_sdk/observability/
audit.rs

1//! Security audit trail.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::UNIX_EPOCH;
7use tokio::sync::broadcast;
8
9/// An audit trail entry.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum AuditEntry {
13    /// A security capability check.
14    SecurityDecision {
15        /// Principal requesting the capability (e.g. agent or user id).
16        subject: String,
17        /// Capability that was checked.
18        capability: String,
19        /// Whether the capability was granted.
20        granted: bool,
21        /// Unix-millisecond timestamp of the decision.
22        timestamp_ms: u64,
23    },
24    /// A tool execution event.
25    ToolExecution {
26        /// ID of the agent that invoked the tool.
27        agent_id: String,
28        /// Name of the tool that was executed.
29        tool_name: String,
30        /// Short summary of the tool call parameters.
31        params_summary: String,
32        /// Whether the tool execution succeeded.
33        success: bool,
34        /// Execution duration in milliseconds.
35        duration_ms: u64,
36        /// Unix-millisecond timestamp of the execution.
37        timestamp_ms: u64,
38    },
39    /// An agent lifecycle event.
40    Lifecycle {
41        /// ID of the agent whose lifecycle changed.
42        agent_id: String,
43        /// Description of the lifecycle event.
44        event: String,
45        /// Unix-millisecond timestamp of the event.
46        timestamp_ms: u64,
47    },
48    /// A custom entry with arbitrary metadata.
49    Custom {
50        /// Free-form category label for grouping custom entries.
51        category: String,
52        /// Human-readable message describing the entry.
53        message: String,
54        /// Arbitrary structured metadata.
55        #[serde(default)]
56        metadata: HashMap<String, serde_json::Value>,
57        /// Unix-millisecond timestamp of the entry.
58        timestamp_ms: u64,
59    },
60}
61
62impl AuditEntry {
63    fn now_ms() -> u64 {
64        std::time::SystemTime::now()
65            .duration_since(UNIX_EPOCH)
66            .map(|d| d.as_millis() as u64)
67            .unwrap_or(0)
68    }
69
70    /// Log a security decision.
71    pub fn security_decision(subject: String, cap: String, granted: bool) -> Self {
72        Self::SecurityDecision {
73            subject,
74            capability: cap,
75            granted,
76            timestamp_ms: Self::now_ms(),
77        }
78    }
79
80    /// Log a tool execution.
81    pub fn tool_execution(
82        agent_id: String,
83        tool_name: String,
84        params_summary: String,
85        success: bool,
86        duration_ms: u64,
87    ) -> Self {
88        Self::ToolExecution {
89            agent_id,
90            tool_name,
91            params_summary,
92            success,
93            duration_ms,
94            timestamp_ms: Self::now_ms(),
95        }
96    }
97
98    /// Log a lifecycle event.
99    pub fn lifecycle(agent_id: String, event: String) -> Self {
100        Self::Lifecycle {
101            agent_id,
102            event,
103            timestamp_ms: Self::now_ms(),
104        }
105    }
106
107    /// Log a custom entry.
108    pub fn custom(category: String, message: String) -> Self {
109        Self::Custom {
110            category,
111            message,
112            metadata: HashMap::new(),
113            timestamp_ms: Self::now_ms(),
114        }
115    }
116}
117
118// ── AuditFilter ─────────────────────────────────────────────────────
119
120/// Filter criteria for querying the audit log.
121#[derive(Debug, Clone, Default)]
122pub struct AuditFilter {
123    /// Filter by agent ID.
124    pub agent_id: Option<String>,
125    /// Filter by entry type.
126    pub entry_type: Option<String>,
127    /// Filter by minimum timestamp.
128    pub after_ms: Option<u64>,
129}
130
131/// Audit log — append-only event recorder with query and subscription.
132pub struct AuditLog {
133    entries: parking_lot::RwLock<Vec<AuditEntry>>,
134    max_entries: usize,
135    total_appended: AtomicU64,
136    tx: broadcast::Sender<AuditEntry>,
137}
138
139impl AuditLog {
140    /// Create a new audit log.
141    ///
142    /// When `channel_capacity` > 0, subscribers receive events via a
143    /// broadcast channel.
144    pub fn new(channel_capacity: usize) -> Self {
145        let (tx, _) = if channel_capacity > 0 {
146            broadcast::channel(channel_capacity)
147        } else {
148            broadcast::channel(1)
149        };
150        Self {
151            entries: parking_lot::RwLock::new(Vec::new()),
152            max_entries: 10_000,
153            total_appended: AtomicU64::new(0),
154            tx,
155        }
156    }
157
158    /// Append a new entry. Trims oldest entries when `max_entries` is exceeded.
159    pub fn log(&self, entry: AuditEntry) {
160        let mut entries = self.entries.write();
161        entries.push(entry.clone());
162        let len = entries.len();
163        let keep = self.max_entries;
164        if len > keep {
165            entries.drain(0..len - keep);
166        }
167        drop(entries);
168        self.total_appended.fetch_add(1, Ordering::Relaxed);
169        let _ = self.tx.send(entry);
170    }
171
172    /// Query entries matching the filter.
173    pub fn query(&self, filter: AuditFilter) -> Vec<AuditEntry> {
174        self.entries
175            .read()
176            .iter()
177            .filter(|e| {
178                if let Some(agent_id) = &filter.agent_id {
179                    match e {
180                        AuditEntry::ToolExecution { agent_id: a, .. } => a == agent_id,
181                        AuditEntry::Lifecycle { agent_id: a, .. } => a == agent_id,
182                        _ => false,
183                    }
184                } else {
185                    true
186                }
187            })
188            .filter(|e| {
189                if let Some(t) = &filter.entry_type {
190                    serde_json::to_string(e)
191                        .map(|s| s.contains(t))
192                        .unwrap_or(false)
193                } else {
194                    true
195                }
196            })
197            .filter(|e| {
198                if let Some(after) = filter.after_ms {
199                    match e {
200                        AuditEntry::SecurityDecision { timestamp_ms, .. } => *timestamp_ms >= after,
201                        AuditEntry::ToolExecution { timestamp_ms, .. } => *timestamp_ms >= after,
202                        AuditEntry::Lifecycle { timestamp_ms, .. } => *timestamp_ms >= after,
203                        AuditEntry::Custom { timestamp_ms, .. } => *timestamp_ms >= after,
204                    }
205                } else {
206                    true
207                }
208            })
209            .cloned()
210            .collect()
211    }
212
213    /// Return all entries in chronological order.
214    pub fn entries(&self) -> Vec<AuditEntry> {
215        self.entries.read().clone()
216    }
217
218    /// Subscribe to new audit entries.
219    pub fn subscribe(&self) -> broadcast::Receiver<AuditEntry> {
220        self.tx.subscribe()
221    }
222
223    /// Return the total number of entries ever appended.
224    pub fn total_appended(&self) -> u64 {
225        self.total_appended.load(Ordering::Relaxed)
226    }
227}
228
229impl std::fmt::Debug for AuditLog {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        f.debug_struct("AuditLog")
232            .field("entry_count", &self.entries.read().len())
233            .field(
234                "total_appended",
235                &self.total_appended.load(Ordering::Relaxed),
236            )
237            .finish()
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn audit_log_append() {
247        let log = AuditLog::new(64);
248        log.log(AuditEntry::security_decision(
249            "agent-1".into(),
250            "file:read".into(),
251            true,
252        ));
253
254        let entries = log.entries();
255        assert_eq!(entries.len(), 1);
256    }
257
258    #[test]
259    fn audit_log_query_by_agent() {
260        let log = AuditLog::new(64);
261        log.log(AuditEntry::tool_execution(
262            "a1".into(),
263            "read".into(),
264            "{path:...}".into(),
265            true,
266            50,
267        ));
268        log.log(AuditEntry::tool_execution(
269            "a2".into(),
270            "bash".into(),
271            "{}".into(),
272            true,
273            100,
274        ));
275
276        let filter = AuditFilter {
277            agent_id: Some("a1".into()),
278            ..Default::default()
279        };
280        let results = log.query(filter);
281        assert_eq!(results.len(), 1);
282    }
283
284    #[test]
285    fn audit_log_trim_on_max_entries() {
286        let log = AuditLog::new(64);
287        // Manually set a low max for testing
288        // We can't override max_entries, so this test just validates append+read
289        log.log(AuditEntry::custom("debug".into(), "hello".into()));
290        assert_eq!(log.entries().len(), 1);
291    }
292
293    #[test]
294    fn audit_entry_helpers() {
295        let se = AuditEntry::security_decision("s".into(), "c".into(), true);
296        assert!(matches!(se, AuditEntry::SecurityDecision { .. }));
297
298        let te = AuditEntry::tool_execution("aid".into(), "read".into(), "{}".into(), true, 10);
299        assert!(matches!(te, AuditEntry::ToolExecution { .. }));
300
301        let le = AuditEntry::lifecycle("a".into(), "run_start".into());
302        assert!(matches!(le, AuditEntry::Lifecycle { .. }));
303    }
304
305    #[tokio::test]
306    async fn audit_log_subscribe() {
307        let log = AuditLog::new(64);
308        let mut rx = log.subscribe();
309        log.log(AuditEntry::lifecycle("test".into(), "msg".into()));
310        let event = rx.recv().await.unwrap();
311        assert!(matches!(event, AuditEntry::Lifecycle { .. }));
312    }
313}