oxicode_sdk/observability/
audit.rs1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::UNIX_EPOCH;
7use tokio::sync::broadcast;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum AuditEntry {
13 SecurityDecision {
15 subject: String,
17 capability: String,
19 granted: bool,
21 timestamp_ms: u64,
23 },
24 ToolExecution {
26 agent_id: String,
28 tool_name: String,
30 params_summary: String,
32 success: bool,
34 duration_ms: u64,
36 timestamp_ms: u64,
38 },
39 Lifecycle {
41 agent_id: String,
43 event: String,
45 timestamp_ms: u64,
47 },
48 Custom {
50 category: String,
52 message: String,
54 #[serde(default)]
56 metadata: HashMap<String, serde_json::Value>,
57 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 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 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 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 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#[derive(Debug, Clone, Default)]
122pub struct AuditFilter {
123 pub agent_id: Option<String>,
125 pub entry_type: Option<String>,
127 pub after_ms: Option<u64>,
129}
130
131pub 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 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 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 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 pub fn entries(&self) -> Vec<AuditEntry> {
215 self.entries.read().clone()
216 }
217
218 pub fn subscribe(&self) -> broadcast::Receiver<AuditEntry> {
220 self.tx.subscribe()
221 }
222
223 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 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}