Skip to main content

punch_types/
audit.rs

1//! Tamper-evident audit log for the Punch Agent Combat System.
2//!
3//! Every security-relevant action in the ring is recorded as an [`AuditEntry`]
4//! whose SHA-256 hash incorporates the previous entry's hash, forming a Merkle
5//! hash chain — the fight record that cannot be rewritten after the fact.
6//!
7//! Think of it as the official bout log: once a punch is thrown, the record is
8//! sealed and any attempt to alter history breaks the chain.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use uuid::Uuid;
14
15// ---------------------------------------------------------------------------
16// AuditAction — what happened in the ring
17// ---------------------------------------------------------------------------
18
19/// A security-relevant action recorded in the bout log.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(tag = "type")]
22pub enum AuditAction {
23    /// A tool (move) was executed by a fighter.
24    ToolExecuted {
25        tool: String,
26        fighter_id: String,
27        success: bool,
28    },
29    /// A tool (move) was blocked before execution.
30    ToolBlocked {
31        tool: String,
32        fighter_id: String,
33        reason: String,
34    },
35    /// An approval request was raised for a risky move.
36    ApprovalRequested {
37        tool: String,
38        fighter_id: String,
39        risk_level: String,
40    },
41    /// Approval was granted for a move.
42    ApprovalGranted { tool: String, fighter_id: String },
43    /// Approval was denied for a move.
44    ApprovalDenied {
45        tool: String,
46        fighter_id: String,
47        reason: String,
48    },
49    /// A capability was granted to a fighter.
50    CapabilityGranted {
51        capability: String,
52        fighter_id: String,
53        granted_by: String,
54    },
55    /// A capability request was denied.
56    CapabilityDenied {
57        capability: String,
58        fighter_id: String,
59    },
60    /// Tainted data was detected in the ring.
61    TaintDetected {
62        source: String,
63        value_preview: String,
64        severity: String,
65    },
66    /// A shell-bleed injection pattern was detected.
67    ShellBleedDetected {
68        command_preview: String,
69        pattern: String,
70        severity: String,
71    },
72    /// A new fighter entered the ring.
73    FighterSpawned { fighter_id: String, name: String },
74    /// A fighter was knocked out (terminated).
75    FighterKilled { fighter_id: String, name: String },
76    /// A new bout (session) started.
77    SessionStarted { bout_id: String, fighter_id: String },
78    /// A configuration value was changed.
79    ConfigChanged {
80        key: String,
81        old_preview: String,
82        new_preview: String,
83    },
84}
85
86impl AuditAction {
87    /// Returns the action type name used for filtering the bout log.
88    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// ---------------------------------------------------------------------------
108// AuditEntry — a single record in the fight log
109// ---------------------------------------------------------------------------
110
111/// A single, hash-chained record in the bout log.
112///
113/// Each entry's [`hash`](AuditEntry::hash) is computed over its content *and*
114/// the previous entry's hash, making the log tamper-evident.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AuditEntry {
117    /// Unique identifier for this record.
118    pub id: Uuid,
119    /// Monotonically increasing sequence number within the bout log.
120    pub sequence: u64,
121    /// When the action occurred.
122    pub timestamp: DateTime<Utc>,
123    /// What happened.
124    pub action: AuditAction,
125    /// Who performed the action (fighter ID, "system", "user", etc.).
126    pub actor: String,
127    /// Additional context attached to this record.
128    pub metadata: serde_json::Value,
129    /// Hex-encoded SHA-256 hash of the previous entry (empty string for the
130    /// genesis entry).
131    pub prev_hash: String,
132    /// Hex-encoded SHA-256 hash of this entry's content plus `prev_hash`.
133    pub hash: String,
134}
135
136// ---------------------------------------------------------------------------
137// AuditVerifyError — chain integrity violations
138// ---------------------------------------------------------------------------
139
140/// Errors detected when verifying the integrity of the bout log's hash chain.
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub enum AuditVerifyError {
143    /// The stored hash for an entry does not match the recomputed hash.
144    HashMismatch {
145        sequence: u64,
146        expected: String,
147        actual: String,
148    },
149    /// The `prev_hash` of an entry does not point to the preceding entry's hash.
150    ChainBroken {
151        sequence: u64,
152        expected_prev: String,
153        actual_prev: String,
154    },
155    /// A gap was found in the sequence numbering.
156    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
187// ---------------------------------------------------------------------------
188// Hash computation — the seal on each record
189// ---------------------------------------------------------------------------
190
191/// Compute the deterministic SHA-256 hash for an audit entry.
192///
193/// The hash covers: `sequence|timestamp_rfc3339|action_json|actor|metadata_json|prev_hash`
194fn 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// ---------------------------------------------------------------------------
210// AuditLog — the append-only bout log
211// ---------------------------------------------------------------------------
212
213/// An append-only, tamper-evident fight record.
214///
215/// Entries form a Merkle hash chain: each entry's hash incorporates the
216/// previous entry's hash. Call [`verify_chain`](AuditLog::verify_chain) to
217/// confirm the log has not been altered since it was written.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct AuditLog {
220    entries: Vec<AuditEntry>,
221    next_sequence: u64,
222}
223
224impl AuditLog {
225    /// Create a fresh, empty bout log.
226    pub fn new() -> Self {
227        Self {
228            entries: Vec::new(),
229            next_sequence: 0,
230        }
231    }
232
233    /// Record a new action in the bout log.
234    ///
235    /// The entry is appended with a hash that chains to the previous entry,
236    /// making the entire log tamper-evident.
237    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        // Build the entry with a placeholder hash so we can compute the real one.
252        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        // SAFETY: we just pushed, so last() is always Some.
268        self.entries.last().expect("just pushed")
269    }
270
271    /// Verify the entire hash chain from genesis to the latest entry.
272    ///
273    /// Returns `Ok(())` if the bout log is intact, or an error describing the
274    /// first inconsistency found.
275    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            // Check sequence continuity.
280            if entry.sequence != expected_sequence {
281                return Err(AuditVerifyError::SequenceGap {
282                    expected: expected_sequence,
283                    actual: entry.sequence,
284                });
285            }
286
287            // Check the chain link.
288            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            // Recompute and compare the hash.
297            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    /// Return all entries in the bout log.
313    pub fn entries(&self) -> &[AuditEntry] {
314        &self.entries
315    }
316
317    /// Return the most recent entry, if any.
318    pub fn last_entry(&self) -> Option<&AuditEntry> {
319        self.entries.last()
320    }
321
322    /// Return all entries with a sequence number strictly greater than `sequence`.
323    pub fn entries_since(&self, sequence: u64) -> &[AuditEntry] {
324        // Entries are ordered by sequence, so we can binary-search for the
325        // first entry whose sequence > the given value.
326        let start = self.entries.partition_point(|e| e.sequence <= sequence);
327        &self.entries[start..]
328    }
329
330    /// Return entries performed by the given `actor`.
331    pub fn entries_by_actor(&self, actor: &str) -> Vec<&AuditEntry> {
332        self.entries.iter().filter(|e| e.actor == actor).collect()
333    }
334
335    /// Return entries matching the given action type name (e.g. `"ToolExecuted"`).
336    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    /// Number of entries in the bout log.
344    pub fn len(&self) -> usize {
345        self.entries.len()
346    }
347
348    /// Whether the bout log is empty.
349    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// ===========================================================================
361// Tests
362// ===========================================================================
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use serde_json::json;
368
369    /// Helper: create a simple tool-executed action.
370    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        // Tamper with the actor field of the second entry.
424        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        // Overwrite the first entry's hash with garbage.
440        log.entries[0].hash = "deadbeef".to_string();
441
442        let err = log.verify_chain().unwrap_err();
443        // Could be HashMismatch on entry 0 or ChainBroken on entry 1.
444        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        // Swap entries 1 and 2.
459        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        // Introduce a gap by bumping the second entry's sequence.
471        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); // sequences 3 and 4
494        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        // Verify various action type queries return correct counts.
662        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}