Skip to main content

moloch_core/agent/
audit_bridge.rs

1//! Bridge between agent accountability types and the core audit event system.
2//!
3//! This module provides the missing integration layer (Section 11) that
4//! connects agent accountability types to the audit chain. Agent actions
5//! are expressed as `AuditEvent` instances with structured metadata
6//! embedding the full causal context, capability references, and
7//! attestation hashes.
8
9use serde::{Deserialize, Serialize};
10
11use crate::crypto::{Hash, PublicKey, SecretKey};
12use crate::error::Result;
13use crate::event::{ActorId, ActorKind, AuditEvent, EventType, Outcome, ResourceId};
14
15use super::capability::CapabilityId;
16use super::causality::CausalContext;
17use super::session::SessionId;
18
19/// Structured metadata embedded in agent audit events.
20///
21/// This is serialized to JSON and stored in the `AuditEvent.metadata`
22/// field, providing the full accountability context for any agent action.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct AgentEventMetadata {
25    /// Session in which the action occurred.
26    pub session_id: SessionId,
27
28    /// Causal context linking this event to its origin.
29    pub causal_context_hash: Hash,
30
31    /// Capability that authorized this action.
32    pub capability_id: Option<CapabilityId>,
33
34    /// Hash of the agent's attestation for cross-reference.
35    pub attestation_hash: Option<Hash>,
36
37    /// Reasoning summary for the action.
38    pub reasoning: Option<String>,
39
40    /// Whether human approval was obtained.
41    pub human_approved: bool,
42}
43
44impl AgentEventMetadata {
45    /// Create metadata for an agent action.
46    pub fn new(session_id: SessionId, causal_context: &CausalContext) -> Self {
47        let causal_bytes = serde_json::to_vec(causal_context).unwrap_or_default();
48        Self {
49            session_id,
50            causal_context_hash: crate::crypto::hash(&causal_bytes),
51            capability_id: None,
52            attestation_hash: None,
53            reasoning: None,
54            human_approved: false,
55        }
56    }
57
58    /// Set the capability that authorized this action.
59    pub fn with_capability(mut self, id: CapabilityId) -> Self {
60        self.capability_id = Some(id);
61        self
62    }
63
64    /// Set the attestation hash for cross-reference.
65    pub fn with_attestation_hash(mut self, hash: Hash) -> Self {
66        self.attestation_hash = Some(hash);
67        self
68    }
69
70    /// Set the reasoning summary.
71    pub fn with_reasoning(mut self, reasoning: impl Into<String>) -> Self {
72        self.reasoning = Some(reasoning.into());
73        self
74    }
75
76    /// Mark that human approval was obtained.
77    pub fn with_human_approval(mut self) -> Self {
78        self.human_approved = true;
79        self
80    }
81
82    /// Serialize to bytes for embedding in AuditEvent metadata.
83    pub fn to_bytes(&self) -> Vec<u8> {
84        serde_json::to_vec(self).unwrap_or_default()
85    }
86
87    /// Deserialize from AuditEvent metadata bytes.
88    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
89        serde_json::from_slice(bytes)
90            .map_err(|e| crate::error::Error::invalid_input(format!("invalid metadata: {}", e)))
91    }
92}
93
94/// Builder for creating agent-contextualized audit events.
95///
96/// Wraps the standard `AuditEventBuilder` with agent-specific defaults
97/// and structured metadata.
98pub struct AgentAuditEventBuilder {
99    agent_key: PublicKey,
100    agent_name: Option<String>,
101    action: Option<String>,
102    resource: Option<ResourceId>,
103    outcome: Option<Outcome>,
104    metadata: AgentEventMetadata,
105}
106
107impl AgentAuditEventBuilder {
108    /// Create a new builder for an agent action event.
109    pub fn new(
110        agent_key: PublicKey,
111        session_id: SessionId,
112        causal_context: &CausalContext,
113    ) -> Self {
114        Self {
115            agent_key,
116            agent_name: None,
117            action: None,
118            resource: None,
119            outcome: None,
120            metadata: AgentEventMetadata::new(session_id, causal_context),
121        }
122    }
123
124    /// Set the agent's display name.
125    pub fn agent_name(mut self, name: impl Into<String>) -> Self {
126        self.agent_name = Some(name.into());
127        self
128    }
129
130    /// Set the action description.
131    pub fn action(mut self, action: impl Into<String>) -> Self {
132        self.action = Some(action.into());
133        self
134    }
135
136    /// Set the resource affected.
137    pub fn resource(mut self, resource: ResourceId) -> Self {
138        self.resource = Some(resource);
139        self
140    }
141
142    /// Set the outcome.
143    pub fn outcome(mut self, outcome: Outcome) -> Self {
144        self.outcome = Some(outcome);
145        self
146    }
147
148    /// Set the capability that authorized this action.
149    pub fn capability(mut self, id: CapabilityId) -> Self {
150        self.metadata = self.metadata.with_capability(id);
151        self
152    }
153
154    /// Set the attestation hash for cross-reference.
155    pub fn attestation_hash(mut self, hash: Hash) -> Self {
156        self.metadata = self.metadata.with_attestation_hash(hash);
157        self
158    }
159
160    /// Set the reasoning summary.
161    pub fn reasoning(mut self, reasoning: impl Into<String>) -> Self {
162        let r: String = reasoning.into();
163        self.metadata = self.metadata.with_reasoning(r.clone());
164        self.action = self.action.or(Some(r));
165        self
166    }
167
168    /// Mark that human approval was obtained.
169    pub fn human_approved(mut self) -> Self {
170        self.metadata = self.metadata.with_human_approval();
171        self
172    }
173
174    /// Build and sign the audit event.
175    pub fn sign(self, agent_key: &SecretKey) -> Result<AuditEvent> {
176        let action = self.action.unwrap_or_else(|| "agent_action".to_string());
177        let resource = self.resource.ok_or_else(|| {
178            crate::error::Error::invalid_input("resource is required for agent audit event")
179        })?;
180
181        let mut actor = ActorId::new(self.agent_key, ActorKind::Agent);
182        if let Some(name) = self.agent_name {
183            actor = actor.with_name(name);
184        }
185
186        AuditEvent::builder()
187            .now()
188            .event_type(EventType::AgentAction {
189                action,
190                reasoning: self.metadata.reasoning.clone(),
191            })
192            .actor(actor)
193            .resource(resource)
194            .outcome(self.outcome.unwrap_or(Outcome::Success))
195            .metadata_bytes(self.metadata.to_bytes())
196            .sign(agent_key)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::agent::principal::PrincipalId;
204    use crate::crypto::{hash, SecretKey};
205    use crate::event::{EventId, ResourceKind};
206
207    fn test_key() -> SecretKey {
208        SecretKey::generate()
209    }
210
211    fn test_session_id() -> SessionId {
212        SessionId::random()
213    }
214
215    fn test_event_id() -> EventId {
216        EventId(hash(b"test-event"))
217    }
218
219    fn test_principal() -> PrincipalId {
220        PrincipalId::user("test-agent@system").unwrap()
221    }
222
223    fn test_causal_context() -> CausalContext {
224        CausalContext::root(test_event_id(), test_session_id(), test_principal())
225    }
226
227    // === AgentEventMetadata Tests ===
228
229    #[test]
230    fn metadata_new_sets_session_and_causal_hash() {
231        let session = test_session_id();
232        let causal = test_causal_context();
233        let meta = AgentEventMetadata::new(session, &causal);
234
235        assert_eq!(meta.session_id, session);
236        assert!(meta.capability_id.is_none());
237        assert!(meta.attestation_hash.is_none());
238        assert!(!meta.human_approved);
239    }
240
241    #[test]
242    fn metadata_with_capability() {
243        let cap_id = CapabilityId::generate();
244        let meta = AgentEventMetadata::new(test_session_id(), &test_causal_context())
245            .with_capability(cap_id);
246
247        assert_eq!(meta.capability_id, Some(cap_id));
248    }
249
250    #[test]
251    fn metadata_with_attestation_hash() {
252        let h = hash(b"attestation");
253        let meta = AgentEventMetadata::new(test_session_id(), &test_causal_context())
254            .with_attestation_hash(h);
255
256        assert_eq!(meta.attestation_hash, Some(h));
257    }
258
259    #[test]
260    fn metadata_serialization_roundtrip() {
261        let cap_id = CapabilityId::generate();
262        let att_hash = hash(b"attestation");
263
264        let meta = AgentEventMetadata::new(test_session_id(), &test_causal_context())
265            .with_capability(cap_id)
266            .with_attestation_hash(att_hash)
267            .with_reasoning("testing serialization")
268            .with_human_approval();
269
270        let bytes = meta.to_bytes();
271        let restored = AgentEventMetadata::from_bytes(&bytes).unwrap();
272
273        assert_eq!(restored.capability_id, Some(cap_id));
274        assert_eq!(restored.attestation_hash, Some(att_hash));
275        assert_eq!(restored.reasoning.as_deref(), Some("testing serialization"));
276        assert!(restored.human_approved);
277    }
278
279    // === AgentAuditEventBuilder Tests ===
280
281    #[test]
282    fn agent_action_creates_audit_event() {
283        let key = test_key();
284        let session = test_session_id();
285        let causal = test_causal_context();
286
287        let event = AgentAuditEventBuilder::new(key.public_key(), session, &causal)
288            .action("code_review")
289            .resource(ResourceId::new(ResourceKind::PullRequest, "pr-42"))
290            .outcome(Outcome::Success)
291            .sign(&key)
292            .unwrap();
293
294        assert_eq!(event.actor.kind, ActorKind::Agent);
295        assert_eq!(event.actor.key, key.public_key());
296        assert!(event.validate().is_ok());
297    }
298
299    #[test]
300    fn audit_event_preserves_causal_context() {
301        let key = test_key();
302        let session = test_session_id();
303        let causal = test_causal_context();
304
305        let event = AgentAuditEventBuilder::new(key.public_key(), session, &causal)
306            .action("deploy")
307            .resource(ResourceId::new(ResourceKind::Repository, "org/project"))
308            .sign(&key)
309            .unwrap();
310
311        // Metadata should deserialize back to AgentEventMetadata
312        let meta = AgentEventMetadata::from_bytes(&event.metadata).unwrap();
313        assert_eq!(meta.session_id, session);
314        // The causal hash should be deterministic for the same context
315        let expected_hash = {
316            let bytes = serde_json::to_vec(&causal).unwrap_or_default();
317            hash(&bytes)
318        };
319        assert_eq!(meta.causal_context_hash, expected_hash);
320    }
321
322    #[test]
323    fn audit_event_references_capability() {
324        let key = test_key();
325        let cap_id = CapabilityId::generate();
326
327        let event = AgentAuditEventBuilder::new(
328            key.public_key(),
329            test_session_id(),
330            &test_causal_context(),
331        )
332        .action("write_file")
333        .resource(ResourceId::new(ResourceKind::File, "src/main.rs"))
334        .capability(cap_id)
335        .sign(&key)
336        .unwrap();
337
338        let meta = AgentEventMetadata::from_bytes(&event.metadata).unwrap();
339        assert_eq!(meta.capability_id, Some(cap_id));
340    }
341
342    #[test]
343    fn audit_event_includes_attestation_hash() {
344        let key = test_key();
345        let att_hash = hash(b"agent-attestation-data");
346
347        let event = AgentAuditEventBuilder::new(
348            key.public_key(),
349            test_session_id(),
350            &test_causal_context(),
351        )
352        .action("execute_command")
353        .resource(ResourceId::new(ResourceKind::Repository, "org/repo"))
354        .attestation_hash(att_hash)
355        .sign(&key)
356        .unwrap();
357
358        let meta = AgentEventMetadata::from_bytes(&event.metadata).unwrap();
359        assert_eq!(meta.attestation_hash, Some(att_hash));
360    }
361
362    #[test]
363    fn audit_event_chain_commitment() {
364        let key = test_key();
365
366        let event = AgentAuditEventBuilder::new(
367            key.public_key(),
368            test_session_id(),
369            &test_causal_context(),
370        )
371        .action("merge_pr")
372        .resource(ResourceId::new(ResourceKind::PullRequest, "pr-99"))
373        .sign(&key)
374        .unwrap();
375
376        // Event participates in the hash chain
377        let id1 = event.id();
378        let id2 = event.id();
379        // Deterministic content-addressed ID
380        assert_eq!(id1, id2);
381        // Signature validates
382        assert!(event.validate().is_ok());
383    }
384
385    #[test]
386    fn agent_event_with_reasoning_and_human_approval() {
387        let key = test_key();
388
389        let event = AgentAuditEventBuilder::new(
390            key.public_key(),
391            test_session_id(),
392            &test_causal_context(),
393        )
394        .action("delete_repository")
395        .resource(ResourceId::new(ResourceKind::Repository, "org/obsolete"))
396        .reasoning("Repository has been archived and data migrated")
397        .human_approved()
398        .outcome(Outcome::Success)
399        .sign(&key)
400        .unwrap();
401
402        let meta = AgentEventMetadata::from_bytes(&event.metadata).unwrap();
403        assert!(meta.human_approved);
404        assert_eq!(
405            meta.reasoning.as_deref(),
406            Some("Repository has been archived and data migrated")
407        );
408    }
409
410    #[test]
411    fn agent_event_with_agent_name() {
412        let key = test_key();
413
414        let event = AgentAuditEventBuilder::new(
415            key.public_key(),
416            test_session_id(),
417            &test_causal_context(),
418        )
419        .agent_name("ReviewBot")
420        .action("review")
421        .resource(ResourceId::new(ResourceKind::PullRequest, "pr-1"))
422        .sign(&key)
423        .unwrap();
424
425        assert_eq!(event.actor.name.as_deref(), Some("ReviewBot"));
426        assert_eq!(event.actor.kind, ActorKind::Agent);
427    }
428
429    #[test]
430    fn agent_event_denied_outcome() {
431        let key = test_key();
432
433        let event = AgentAuditEventBuilder::new(
434            key.public_key(),
435            test_session_id(),
436            &test_causal_context(),
437        )
438        .action("deploy_production")
439        .resource(ResourceId::new(ResourceKind::Repository, "org/app"))
440        .outcome(Outcome::Denied {
441            reason: "insufficient capability".to_string(),
442        })
443        .sign(&key)
444        .unwrap();
445
446        assert!(matches!(event.outcome, Outcome::Denied { .. }));
447    }
448
449    #[test]
450    fn agent_event_requires_resource() {
451        let key = test_key();
452
453        let result = AgentAuditEventBuilder::new(
454            key.public_key(),
455            test_session_id(),
456            &test_causal_context(),
457        )
458        .action("something")
459        .sign(&key);
460
461        assert!(result.is_err());
462    }
463
464    #[test]
465    fn metadata_invalid_bytes_returns_error() {
466        let result = AgentEventMetadata::from_bytes(b"not valid json");
467        assert!(result.is_err());
468    }
469}