Skip to main content

aspect_std/
audit.rs

1//! Action-audit-trail aspect for agentic systems.
2//!
3//! Records an append-only trail of tool invocations: timestamp, session,
4//! tool name, outcome (Ok/Err). The trail is the artifact regulatory
5//! frameworks (EU AI Act, OWASP LLM-Top-10) call for; modularizing it
6//! as an aspect rather than inlining it at every call site is the
7//! contribution this work measures.
8
9use 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
16/// Identifier for a session whose actions are being audited.
17pub type SessionId = String;
18
19/// Per-call context the host populates before the audited join point.
20#[derive(Clone, Debug)]
21pub struct AuditContext {
22    /// Session whose action is being recorded.
23    pub session: SessionId,
24    /// Name of the tool being invoked.
25    pub tool: String,
26}
27
28/// One audit-trail entry.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct AuditRecord {
31    /// Unix-millisecond timestamp of the call.
32    pub timestamp_ms: u128,
33    /// Session id.
34    pub session: SessionId,
35    /// Tool name.
36    pub tool: String,
37    /// Function being guarded.
38    pub function: String,
39    /// Whether the call returned `Ok`.
40    pub ok: bool,
41    /// Error message, if the call failed.
42    pub error: Option<String>,
43}
44
45/// Audit-trail aspect.
46///
47/// The host calls `bind(ctx)` before each guarded invocation. The
48/// aspect appends one record per invocation, in arrival order.
49#[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    /// Create an empty audit-trail aspect.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// Bind an audit context to the next guarded invocation.
68    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    /// Clear a previously bound context. Idempotent.
77    pub fn release(&self, key: u64) {
78        self.inner.pending.write().remove(&key);
79    }
80
81    /// Snapshot the full audit trail.
82    pub fn snapshot(&self) -> Vec<AuditRecord> {
83        self.inner.trail.lock().clone()
84    }
85
86    /// Number of recorded entries.
87    pub fn len(&self) -> usize {
88        self.inner.trail.lock().len()
89    }
90
91    /// Whether the trail is empty.
92    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        // around-based path is authoritative; this hook is a no-op so that
143        // composing with aspects that drive `before`/`after` does not
144        // produce duplicate audit records.
145    }
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}