1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct AgentEventMetadata {
25 pub session_id: SessionId,
27
28 pub causal_context_hash: Hash,
30
31 pub capability_id: Option<CapabilityId>,
33
34 pub attestation_hash: Option<Hash>,
36
37 pub reasoning: Option<String>,
39
40 pub human_approved: bool,
42}
43
44impl AgentEventMetadata {
45 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 pub fn with_capability(mut self, id: CapabilityId) -> Self {
60 self.capability_id = Some(id);
61 self
62 }
63
64 pub fn with_attestation_hash(mut self, hash: Hash) -> Self {
66 self.attestation_hash = Some(hash);
67 self
68 }
69
70 pub fn with_reasoning(mut self, reasoning: impl Into<String>) -> Self {
72 self.reasoning = Some(reasoning.into());
73 self
74 }
75
76 pub fn with_human_approval(mut self) -> Self {
78 self.human_approved = true;
79 self
80 }
81
82 pub fn to_bytes(&self) -> Vec<u8> {
84 serde_json::to_vec(self).unwrap_or_default()
85 }
86
87 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
94pub 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 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 pub fn agent_name(mut self, name: impl Into<String>) -> Self {
126 self.agent_name = Some(name.into());
127 self
128 }
129
130 pub fn action(mut self, action: impl Into<String>) -> Self {
132 self.action = Some(action.into());
133 self
134 }
135
136 pub fn resource(mut self, resource: ResourceId) -> Self {
138 self.resource = Some(resource);
139 self
140 }
141
142 pub fn outcome(mut self, outcome: Outcome) -> Self {
144 self.outcome = Some(outcome);
145 self
146 }
147
148 pub fn capability(mut self, id: CapabilityId) -> Self {
150 self.metadata = self.metadata.with_capability(id);
151 self
152 }
153
154 pub fn attestation_hash(mut self, hash: Hash) -> Self {
156 self.metadata = self.metadata.with_attestation_hash(hash);
157 self
158 }
159
160 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 pub fn human_approved(mut self) -> Self {
170 self.metadata = self.metadata.with_human_approval();
171 self
172 }
173
174 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 #[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 #[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 let meta = AgentEventMetadata::from_bytes(&event.metadata).unwrap();
313 assert_eq!(meta.session_id, session);
314 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 let id1 = event.id();
378 let id2 = event.id();
379 assert_eq!(id1, id2);
381 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}