1use aspect_core::{Aspect, AspectError, JoinPoint, ProceedingJoinPoint};
10use parking_lot::{Mutex, RwLock};
11use std::any::Any;
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16pub type SessionId = String;
18
19#[derive(Clone, Debug)]
21pub struct AuditContext {
22 pub session: SessionId,
24 pub tool: String,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct AuditRecord {
31 pub timestamp_ms: u128,
33 pub session: SessionId,
35 pub tool: String,
37 pub function: String,
39 pub ok: bool,
41 pub error: Option<String>,
43}
44
45#[derive(Clone, Default)]
50pub struct ActionAuditTrailAspect {
51 inner: Arc<Inner>,
52}
53
54#[derive(Default)]
55struct Inner {
56 trail: Mutex<Vec<AuditRecord>>,
57 pending: RwLock<HashMap<u64, AuditContext>>,
58 next_key: RwLock<u64>,
59}
60
61impl ActionAuditTrailAspect {
62 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn bind(&self, ctx: AuditContext) -> u64 {
69 let mut next = self.inner.next_key.write();
70 *next += 1;
71 let key = *next;
72 self.inner.pending.write().insert(key, ctx);
73 key
74 }
75
76 pub fn release(&self, key: u64) {
78 self.inner.pending.write().remove(&key);
79 }
80
81 pub fn snapshot(&self) -> Vec<AuditRecord> {
83 self.inner.trail.lock().clone()
84 }
85
86 pub fn len(&self) -> usize {
88 self.inner.trail.lock().len()
89 }
90
91 pub fn is_empty(&self) -> bool {
93 self.inner.trail.lock().is_empty()
94 }
95
96 fn most_recent_context(&self) -> Option<AuditContext> {
97 self.inner
98 .pending
99 .read()
100 .iter()
101 .max_by_key(|(k, _)| *k)
102 .map(|(_, v)| v.clone())
103 }
104
105 fn now_ms() -> u128 {
106 SystemTime::now()
107 .duration_since(UNIX_EPOCH)
108 .map(|d| d.as_millis())
109 .unwrap_or(0)
110 }
111
112 fn append(&self, record: AuditRecord) {
113 self.inner.trail.lock().push(record);
114 }
115}
116
117impl Aspect for ActionAuditTrailAspect {
118 fn around(&self, pjp: ProceedingJoinPoint) -> Result<Box<dyn Any>, AspectError> {
119 let ctx = self.most_recent_context();
120 let function = pjp.context().function_name.to_string();
121 let ts = Self::now_ms();
122 let result = pjp.proceed();
123 let (ok, err) = match &result {
124 Ok(_) => (true, None),
125 Err(e) => (false, Some(format!("{:?}", e))),
126 };
127 let (session, tool) = ctx
128 .map(|c| (c.session, c.tool))
129 .unwrap_or_else(|| ("unknown".into(), "unknown".into()));
130 self.append(AuditRecord {
131 timestamp_ms: ts,
132 session,
133 tool,
134 function,
135 ok,
136 error: err,
137 });
138 result
139 }
140
141 fn after(&self, _ctx: &JoinPoint, _result: &dyn Any) {
142 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use aspect_core::Location;
152
153 fn jp() -> JoinPoint {
154 JoinPoint {
155 function_name: "execute_tool_call",
156 module_path: "agent::dispatch",
157 location: Location {
158 file: "agent.rs",
159 line: 1,
160 },
161 }
162 }
163
164 #[test]
165 fn records_a_successful_call() {
166 let audit = ActionAuditTrailAspect::new();
167 let key = audit.bind(AuditContext {
168 session: "s1".into(),
169 tool: "read_file".into(),
170 });
171 let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(7_i32) as Box<dyn Any>), jp());
172 let _ = audit.around(pjp);
173 audit.release(key);
174
175 let trail = audit.snapshot();
176 assert_eq!(trail.len(), 1);
177 assert_eq!(trail[0].session, "s1");
178 assert_eq!(trail[0].tool, "read_file");
179 assert!(trail[0].ok);
180 assert_eq!(trail[0].error, None);
181 }
182
183 #[test]
184 fn records_an_error_call() {
185 let audit = ActionAuditTrailAspect::new();
186 let key = audit.bind(AuditContext {
187 session: "s1".into(),
188 tool: "delete_file".into(),
189 });
190 let pjp = ProceedingJoinPoint::new(
191 || Err::<Box<dyn Any>, AspectError>(AspectError::execution("denied")),
192 jp(),
193 );
194 let _ = audit.around(pjp);
195 audit.release(key);
196
197 let trail = audit.snapshot();
198 assert_eq!(trail.len(), 1);
199 assert!(!trail[0].ok);
200 assert!(trail[0].error.as_deref().unwrap().contains("denied"));
201 }
202
203 #[test]
204 fn trail_is_append_only_across_calls() {
205 let audit = ActionAuditTrailAspect::new();
206 for i in 0..3 {
207 let key = audit.bind(AuditContext {
208 session: format!("s{}", i),
209 tool: "t".into(),
210 });
211 let pjp = ProceedingJoinPoint::new(|| Ok(Box::new(0_i32) as Box<dyn Any>), jp());
212 let _ = audit.around(pjp);
213 audit.release(key);
214 }
215 let trail = audit.snapshot();
216 assert_eq!(trail.len(), 3);
217 assert_eq!(trail[0].session, "s0");
218 assert_eq!(trail[2].session, "s2");
219 }
220}