1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(tag = "type")]
22pub enum AuditAction {
23 ToolExecuted {
25 tool: String,
26 fighter_id: String,
27 success: bool,
28 },
29 ToolBlocked {
31 tool: String,
32 fighter_id: String,
33 reason: String,
34 },
35 ApprovalRequested {
37 tool: String,
38 fighter_id: String,
39 risk_level: String,
40 },
41 ApprovalGranted { tool: String, fighter_id: String },
43 ApprovalDenied {
45 tool: String,
46 fighter_id: String,
47 reason: String,
48 },
49 CapabilityGranted {
51 capability: String,
52 fighter_id: String,
53 granted_by: String,
54 },
55 CapabilityDenied {
57 capability: String,
58 fighter_id: String,
59 },
60 TaintDetected {
62 source: String,
63 value_preview: String,
64 severity: String,
65 },
66 ShellBleedDetected {
68 command_preview: String,
69 pattern: String,
70 severity: String,
71 },
72 FighterSpawned { fighter_id: String, name: String },
74 FighterKilled { fighter_id: String, name: String },
76 SessionStarted { bout_id: String, fighter_id: String },
78 ConfigChanged {
80 key: String,
81 old_preview: String,
82 new_preview: String,
83 },
84}
85
86impl AuditAction {
87 pub fn type_name(&self) -> &'static str {
89 match self {
90 AuditAction::ToolExecuted { .. } => "ToolExecuted",
91 AuditAction::ToolBlocked { .. } => "ToolBlocked",
92 AuditAction::ApprovalRequested { .. } => "ApprovalRequested",
93 AuditAction::ApprovalGranted { .. } => "ApprovalGranted",
94 AuditAction::ApprovalDenied { .. } => "ApprovalDenied",
95 AuditAction::CapabilityGranted { .. } => "CapabilityGranted",
96 AuditAction::CapabilityDenied { .. } => "CapabilityDenied",
97 AuditAction::TaintDetected { .. } => "TaintDetected",
98 AuditAction::ShellBleedDetected { .. } => "ShellBleedDetected",
99 AuditAction::FighterSpawned { .. } => "FighterSpawned",
100 AuditAction::FighterKilled { .. } => "FighterKilled",
101 AuditAction::SessionStarted { .. } => "SessionStarted",
102 AuditAction::ConfigChanged { .. } => "ConfigChanged",
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AuditEntry {
117 pub id: Uuid,
119 pub sequence: u64,
121 pub timestamp: DateTime<Utc>,
123 pub action: AuditAction,
125 pub actor: String,
127 pub metadata: serde_json::Value,
129 pub prev_hash: String,
132 pub hash: String,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub enum AuditVerifyError {
143 HashMismatch {
145 sequence: u64,
146 expected: String,
147 actual: String,
148 },
149 ChainBroken {
151 sequence: u64,
152 expected_prev: String,
153 actual_prev: String,
154 },
155 SequenceGap { expected: u64, actual: u64 },
157}
158
159impl std::fmt::Display for AuditVerifyError {
160 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 match self {
162 AuditVerifyError::HashMismatch {
163 sequence,
164 expected,
165 actual,
166 } => write!(
167 f,
168 "hash mismatch at sequence {sequence}: expected {expected}, got {actual}"
169 ),
170 AuditVerifyError::ChainBroken {
171 sequence,
172 expected_prev,
173 actual_prev,
174 } => write!(
175 f,
176 "chain broken at sequence {sequence}: expected prev_hash {expected_prev}, got {actual_prev}"
177 ),
178 AuditVerifyError::SequenceGap { expected, actual } => {
179 write!(f, "sequence gap: expected {expected}, got {actual}")
180 }
181 }
182 }
183}
184
185impl std::error::Error for AuditVerifyError {}
186
187fn compute_entry_hash(entry: &AuditEntry) -> String {
195 let action_json = serde_json::to_string(&entry.action).unwrap_or_default();
196 let metadata_json = serde_json::to_string(&entry.metadata).unwrap_or_default();
197 let timestamp_rfc3339 = entry.timestamp.to_rfc3339();
198
199 let preimage = format!(
200 "{}|{}|{}|{}|{}|{}",
201 entry.sequence, timestamp_rfc3339, action_json, entry.actor, metadata_json, entry.prev_hash
202 );
203
204 let mut hasher = Sha256::new();
205 hasher.update(preimage.as_bytes());
206 format!("{:x}", hasher.finalize())
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct AuditLog {
220 entries: Vec<AuditEntry>,
221 next_sequence: u64,
222}
223
224impl AuditLog {
225 pub fn new() -> Self {
227 Self {
228 entries: Vec::new(),
229 next_sequence: 0,
230 }
231 }
232
233 pub fn append(
238 &mut self,
239 action: AuditAction,
240 actor: &str,
241 metadata: serde_json::Value,
242 ) -> &AuditEntry {
243 let prev_hash = self
244 .entries
245 .last()
246 .map(|e| e.hash.clone())
247 .unwrap_or_default();
248
249 let sequence = self.next_sequence;
250
251 let mut entry = AuditEntry {
253 id: Uuid::new_v4(),
254 sequence,
255 timestamp: Utc::now(),
256 action,
257 actor: actor.to_string(),
258 metadata,
259 prev_hash,
260 hash: String::new(),
261 };
262
263 entry.hash = compute_entry_hash(&entry);
264 self.entries.push(entry);
265 self.next_sequence = sequence + 1;
266
267 self.entries.last().expect("just pushed")
269 }
270
271 pub fn verify_chain(&self) -> Result<(), AuditVerifyError> {
276 let mut expected_prev_hash = String::new();
277
278 for (expected_sequence, entry) in (0_u64..).zip(self.entries.iter()) {
279 if entry.sequence != expected_sequence {
281 return Err(AuditVerifyError::SequenceGap {
282 expected: expected_sequence,
283 actual: entry.sequence,
284 });
285 }
286
287 if entry.prev_hash != expected_prev_hash {
289 return Err(AuditVerifyError::ChainBroken {
290 sequence: entry.sequence,
291 expected_prev: expected_prev_hash,
292 actual_prev: entry.prev_hash.clone(),
293 });
294 }
295
296 let recomputed = compute_entry_hash(entry);
298 if entry.hash != recomputed {
299 return Err(AuditVerifyError::HashMismatch {
300 sequence: entry.sequence,
301 expected: recomputed,
302 actual: entry.hash.clone(),
303 });
304 }
305
306 expected_prev_hash = entry.hash.clone();
307 }
308
309 Ok(())
310 }
311
312 pub fn entries(&self) -> &[AuditEntry] {
314 &self.entries
315 }
316
317 pub fn last_entry(&self) -> Option<&AuditEntry> {
319 self.entries.last()
320 }
321
322 pub fn entries_since(&self, sequence: u64) -> &[AuditEntry] {
324 let start = self.entries.partition_point(|e| e.sequence <= sequence);
327 &self.entries[start..]
328 }
329
330 pub fn entries_by_actor(&self, actor: &str) -> Vec<&AuditEntry> {
332 self.entries.iter().filter(|e| e.actor == actor).collect()
333 }
334
335 pub fn entries_by_action_type(&self, action_type: &str) -> Vec<&AuditEntry> {
337 self.entries
338 .iter()
339 .filter(|e| e.action.type_name() == action_type)
340 .collect()
341 }
342
343 pub fn len(&self) -> usize {
345 self.entries.len()
346 }
347
348 pub fn is_empty(&self) -> bool {
350 self.entries.is_empty()
351 }
352}
353
354impl Default for AuditLog {
355 fn default() -> Self {
356 Self::new()
357 }
358}
359
360#[cfg(test)]
365mod tests {
366 use super::*;
367 use serde_json::json;
368
369 fn tool_executed(tool: &str, fighter: &str, success: bool) -> AuditAction {
371 AuditAction::ToolExecuted {
372 tool: tool.to_string(),
373 fighter_id: fighter.to_string(),
374 success,
375 }
376 }
377
378 #[test]
379 fn genesis_entry_has_empty_prev_hash() {
380 let mut log = AuditLog::new();
381 log.append(tool_executed("ls", "f1", true), "f1", json!({}));
382
383 let genesis = &log.entries()[0];
384 assert!(
385 genesis.prev_hash.is_empty(),
386 "genesis prev_hash must be empty"
387 );
388 assert!(!genesis.hash.is_empty(), "genesis hash must not be empty");
389 assert_eq!(genesis.sequence, 0);
390 }
391
392 #[test]
393 fn second_entry_prev_hash_matches_first_hash() {
394 let mut log = AuditLog::new();
395 log.append(tool_executed("ls", "f1", true), "f1", json!({}));
396 log.append(tool_executed("cat", "f1", true), "f1", json!({}));
397
398 let first = &log.entries()[0];
399 let second = &log.entries()[1];
400 assert_eq!(second.prev_hash, first.hash);
401 }
402
403 #[test]
404 fn chain_of_10_verifies_cleanly() {
405 let mut log = AuditLog::new();
406 for i in 0..10 {
407 log.append(
408 tool_executed(&format!("tool_{i}"), "f1", true),
409 "f1",
410 json!({ "i": i }),
411 );
412 }
413 assert_eq!(log.len(), 10);
414 assert!(log.verify_chain().is_ok());
415 }
416
417 #[test]
418 fn tampered_content_detected() {
419 let mut log = AuditLog::new();
420 log.append(tool_executed("ls", "f1", true), "f1", json!({}));
421 log.append(tool_executed("cat", "f1", true), "f1", json!({}));
422
423 log.entries[1].actor = "evil".to_string();
425
426 let err = log.verify_chain().unwrap_err();
427 match err {
428 AuditVerifyError::HashMismatch { sequence, .. } => assert_eq!(sequence, 1),
429 other => panic!("expected HashMismatch, got {other:?}"),
430 }
431 }
432
433 #[test]
434 fn tampered_hash_detected() {
435 let mut log = AuditLog::new();
436 log.append(tool_executed("ls", "f1", true), "f1", json!({}));
437 log.append(tool_executed("cat", "f1", true), "f1", json!({}));
438
439 log.entries[0].hash = "deadbeef".to_string();
441
442 let err = log.verify_chain().unwrap_err();
443 match err {
445 AuditVerifyError::HashMismatch { sequence, .. } => assert_eq!(sequence, 0),
446 AuditVerifyError::ChainBroken { sequence, .. } => assert_eq!(sequence, 1),
447 other => panic!("unexpected error: {other:?}"),
448 }
449 }
450
451 #[test]
452 fn broken_chain_swap_entries() {
453 let mut log = AuditLog::new();
454 log.append(tool_executed("a", "f1", true), "f1", json!({}));
455 log.append(tool_executed("b", "f1", true), "f1", json!({}));
456 log.append(tool_executed("c", "f1", true), "f1", json!({}));
457
458 log.entries.swap(1, 2);
460
461 assert!(log.verify_chain().is_err());
462 }
463
464 #[test]
465 fn sequence_gap_detected() {
466 let mut log = AuditLog::new();
467 log.append(tool_executed("a", "f1", true), "f1", json!({}));
468 log.append(tool_executed("b", "f1", true), "f1", json!({}));
469
470 log.entries[1].sequence = 5;
472
473 let err = log.verify_chain().unwrap_err();
474 match err {
475 AuditVerifyError::SequenceGap {
476 expected, actual, ..
477 } => {
478 assert_eq!(expected, 1);
479 assert_eq!(actual, 5);
480 }
481 other => panic!("expected SequenceGap, got {other:?}"),
482 }
483 }
484
485 #[test]
486 fn entries_since_returns_correct_subset() {
487 let mut log = AuditLog::new();
488 for i in 0..5 {
489 log.append(tool_executed(&format!("t{i}"), "f1", true), "f1", json!({}));
490 }
491
492 let since_2 = log.entries_since(2);
493 assert_eq!(since_2.len(), 2); assert_eq!(since_2[0].sequence, 3);
495 assert_eq!(since_2[1].sequence, 4);
496 }
497
498 #[test]
499 fn entries_by_actor_filters_correctly() {
500 let mut log = AuditLog::new();
501 log.append(tool_executed("a", "f1", true), "f1", json!({}));
502 log.append(tool_executed("b", "f2", true), "f2", json!({}));
503 log.append(tool_executed("c", "f1", true), "f1", json!({}));
504
505 let f1_entries = log.entries_by_actor("f1");
506 assert_eq!(f1_entries.len(), 2);
507 assert!(f1_entries.iter().all(|e| e.actor == "f1"));
508
509 let f2_entries = log.entries_by_actor("f2");
510 assert_eq!(f2_entries.len(), 1);
511 }
512
513 #[test]
514 fn entries_by_action_type_filters_correctly() {
515 let mut log = AuditLog::new();
516 log.append(tool_executed("a", "f1", true), "f1", json!({}));
517 log.append(
518 AuditAction::ToolBlocked {
519 tool: "rm".to_string(),
520 fighter_id: "f1".to_string(),
521 reason: "dangerous".to_string(),
522 },
523 "system",
524 json!({}),
525 );
526 log.append(tool_executed("b", "f1", true), "f1", json!({}));
527
528 let executed = log.entries_by_action_type("ToolExecuted");
529 assert_eq!(executed.len(), 2);
530
531 let blocked = log.entries_by_action_type("ToolBlocked");
532 assert_eq!(blocked.len(), 1);
533 }
534
535 #[test]
536 fn empty_audit_log_verifies_cleanly() {
537 let log = AuditLog::new();
538 assert!(log.verify_chain().is_ok());
539 assert!(log.is_empty());
540 assert_eq!(log.len(), 0);
541 assert!(log.last_entry().is_none());
542 }
543
544 #[test]
545 fn last_entry_returns_correct_entry() {
546 let mut log = AuditLog::new();
547 log.append(tool_executed("first", "f1", true), "f1", json!({}));
548 log.append(tool_executed("second", "f1", true), "f1", json!({}));
549 log.append(tool_executed("third", "f1", true), "f1", json!({}));
550
551 let last = log.last_entry().unwrap();
552 assert_eq!(last.sequence, 2);
553 match &last.action {
554 AuditAction::ToolExecuted { tool, .. } => assert_eq!(tool, "third"),
555 other => panic!("unexpected action: {other:?}"),
556 }
557 }
558
559 #[test]
560 fn serialization_roundtrip_preserves_hashes() {
561 let mut log = AuditLog::new();
562 log.append(tool_executed("ls", "f1", true), "f1", json!({"key": "val"}));
563 log.append(
564 AuditAction::FighterSpawned {
565 fighter_id: "f2".to_string(),
566 name: "challenger".to_string(),
567 },
568 "system",
569 json!({}),
570 );
571
572 let serialized = serde_json::to_string(&log).unwrap();
573 let deserialized: AuditLog = serde_json::from_str(&serialized).unwrap();
574
575 assert!(deserialized.verify_chain().is_ok());
576 assert_eq!(deserialized.len(), log.len());
577 for (orig, deser) in log.entries().iter().zip(deserialized.entries().iter()) {
578 assert_eq!(orig.hash, deser.hash);
579 assert_eq!(orig.prev_hash, deser.prev_hash);
580 assert_eq!(orig.sequence, deser.sequence);
581 }
582 }
583
584 #[test]
585 fn multiple_action_types_coexist() {
586 let mut log = AuditLog::new();
587 log.append(tool_executed("ls", "f1", true), "f1", json!({}));
588 log.append(
589 AuditAction::ToolBlocked {
590 tool: "rm".to_string(),
591 fighter_id: "f1".to_string(),
592 reason: "forbidden".to_string(),
593 },
594 "system",
595 json!({}),
596 );
597 log.append(
598 AuditAction::ApprovalRequested {
599 tool: "deploy".to_string(),
600 fighter_id: "f1".to_string(),
601 risk_level: "high".to_string(),
602 },
603 "f1",
604 json!({}),
605 );
606 log.append(
607 AuditAction::ApprovalGranted {
608 tool: "deploy".to_string(),
609 fighter_id: "f1".to_string(),
610 },
611 "user",
612 json!({}),
613 );
614 log.append(
615 AuditAction::CapabilityGranted {
616 capability: "file_write".to_string(),
617 fighter_id: "f1".to_string(),
618 granted_by: "user".to_string(),
619 },
620 "user",
621 json!({}),
622 );
623 log.append(
624 AuditAction::TaintDetected {
625 source: "env".to_string(),
626 value_preview: "SECRET_K***".to_string(),
627 severity: "high".to_string(),
628 },
629 "system",
630 json!({}),
631 );
632 log.append(
633 AuditAction::FighterSpawned {
634 fighter_id: "f2".to_string(),
635 name: "contender".to_string(),
636 },
637 "system",
638 json!({}),
639 );
640 log.append(
641 AuditAction::SessionStarted {
642 bout_id: "bout-1".to_string(),
643 fighter_id: "f2".to_string(),
644 },
645 "system",
646 json!({}),
647 );
648 log.append(
649 AuditAction::ConfigChanged {
650 key: "max_tokens".to_string(),
651 old_preview: "4096".to_string(),
652 new_preview: "8192".to_string(),
653 },
654 "user",
655 json!({}),
656 );
657
658 assert_eq!(log.len(), 9);
659 assert!(log.verify_chain().is_ok());
660
661 assert_eq!(log.entries_by_action_type("ToolExecuted").len(), 1);
663 assert_eq!(log.entries_by_action_type("ToolBlocked").len(), 1);
664 assert_eq!(log.entries_by_action_type("ApprovalRequested").len(), 1);
665 assert_eq!(log.entries_by_action_type("ApprovalGranted").len(), 1);
666 assert_eq!(log.entries_by_action_type("CapabilityGranted").len(), 1);
667 assert_eq!(log.entries_by_action_type("TaintDetected").len(), 1);
668 assert_eq!(log.entries_by_action_type("FighterSpawned").len(), 1);
669 assert_eq!(log.entries_by_action_type("SessionStarted").len(), 1);
670 assert_eq!(log.entries_by_action_type("ConfigChanged").len(), 1);
671 }
672}