Skip to main content

symbi_runtime/reasoning/
critic_audit.rs

1//! Cryptographic audit for director-critic exchanges
2//!
3//! Provides hash-chained, Ed25519-signed audit entries for every
4//! director-critic interaction, enabling tamper-evident review trails.
5
6use chrono::{DateTime, Utc};
7use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::HashMap;
11
12/// Identity of who produced an artifact in the audit chain.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(tag = "type")]
15pub enum AuditIdentity {
16    /// An LLM model acted as critic.
17    #[serde(rename = "llm")]
18    Llm { model_id: String },
19    /// A human acted as critic.
20    #[serde(rename = "human")]
21    Human { user_id: String, name: String },
22}
23
24/// Verdict of a critic evaluation.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum AuditVerdict {
28    Approved,
29    Rejected,
30    NeedsRevision,
31}
32
33/// A single auditable director-critic exchange.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct CriticAuditEntry {
36    /// Unique ID for this entry.
37    pub entry_id: String,
38    /// SHA-256 hash of the director's output.
39    pub director_output_hash: String,
40    /// SHA-256 hash of the critic's assessment.
41    pub critic_assessment_hash: String,
42    /// The critic's verdict.
43    pub verdict: AuditVerdict,
44    /// Per-dimension scores (for rubric evaluations).
45    pub dimension_scores: HashMap<String, f64>,
46    /// Overall score (0.0 - 1.0).
47    pub score: f64,
48    /// Identity of the critic.
49    pub critic_identity: AuditIdentity,
50    /// Timestamp of the exchange.
51    pub timestamp: DateTime<Utc>,
52    /// Chain hash: SHA-256(previous_chain_hash || entry_data).
53    pub chain_hash: String,
54    /// Ed25519 signature over the chain hash (hex-encoded).
55    pub signature: String,
56    /// Iteration number in the director-critic loop.
57    pub iteration: u32,
58}
59
60/// Parameters for recording a director-critic exchange.
61pub struct RecordParams<'a> {
62    /// The director's output text.
63    pub director_output: &'a str,
64    /// The critic's assessment text.
65    pub critic_assessment: &'a str,
66    /// The critic's verdict.
67    pub verdict: AuditVerdict,
68    /// Per-dimension scores (for rubric evaluations).
69    pub dimension_scores: HashMap<String, f64>,
70    /// Overall score (0.0 - 1.0).
71    pub score: f64,
72    /// Identity of the critic.
73    pub critic_identity: AuditIdentity,
74    /// Iteration number in the director-critic loop.
75    pub iteration: u32,
76}
77
78/// Maintains a hash-chained, Ed25519-signed audit trail for director-critic exchanges.
79pub struct AuditChain {
80    entries: Vec<CriticAuditEntry>,
81    signing_key: SigningKey,
82    last_chain_hash: String,
83}
84
85impl AuditChain {
86    /// Create a new audit chain with the given signing key.
87    pub fn new(signing_key: SigningKey) -> Self {
88        let genesis = sha256_hex(b"genesis");
89        Self {
90            entries: Vec::new(),
91            signing_key,
92            last_chain_hash: genesis,
93        }
94    }
95
96    /// Record a director-critic exchange and append it to the chain.
97    pub fn record(&mut self, params: RecordParams<'_>) -> CriticAuditEntry {
98        let entry_id = uuid::Uuid::new_v4().to_string();
99        let director_output_hash = sha256_hex(params.director_output.as_bytes());
100        let critic_assessment_hash = sha256_hex(params.critic_assessment.as_bytes());
101        let timestamp = Utc::now();
102
103        // Compute chain hash: SHA-256(previous_chain_hash || entry_data)
104        let entry_data = format!(
105            "{}|{}|{}|{:?}|{}|{}|{}",
106            entry_id,
107            director_output_hash,
108            critic_assessment_hash,
109            params.verdict,
110            params.score,
111            timestamp.to_rfc3339(),
112            params.iteration
113        );
114        let chain_input = format!("{}{}", self.last_chain_hash, entry_data);
115        let chain_hash = sha256_hex(chain_input.as_bytes());
116
117        // Sign the chain hash with Ed25519
118        let signature_bytes = self.signing_key.sign(chain_hash.as_bytes());
119        let signature = hex::encode(signature_bytes.to_bytes());
120
121        let entry = CriticAuditEntry {
122            entry_id,
123            director_output_hash,
124            critic_assessment_hash,
125            verdict: params.verdict,
126            dimension_scores: params.dimension_scores,
127            score: params.score,
128            critic_identity: params.critic_identity,
129            timestamp,
130            chain_hash: chain_hash.clone(),
131            signature,
132            iteration: params.iteration,
133        };
134
135        self.last_chain_hash = chain_hash;
136        self.entries.push(entry.clone());
137        entry
138    }
139
140    /// Get all entries in the chain.
141    pub fn entries(&self) -> &[CriticAuditEntry] {
142        &self.entries
143    }
144
145    /// Get the verifying (public) key for this chain.
146    pub fn verifying_key(&self) -> VerifyingKey {
147        self.signing_key.verifying_key()
148    }
149
150    /// Verify the integrity of the entire chain (hashes + signatures).
151    pub fn verify(&self, verifying_key: &VerifyingKey) -> Result<(), AuditError> {
152        verify_chain(&self.entries, verifying_key)
153    }
154
155    /// Get the number of entries.
156    pub fn len(&self) -> usize {
157        self.entries.len()
158    }
159
160    /// Check if the chain is empty.
161    pub fn is_empty(&self) -> bool {
162        self.entries.is_empty()
163    }
164}
165
166/// Verify a chain of audit entries against a verifying key.
167///
168/// Checks both hash chain integrity and Ed25519 signatures on every entry.
169pub fn verify_chain(
170    entries: &[CriticAuditEntry],
171    verifying_key: &VerifyingKey,
172) -> Result<(), AuditError> {
173    let mut expected_prev_hash = sha256_hex(b"genesis");
174
175    for (i, entry) in entries.iter().enumerate() {
176        // Recompute chain hash from entry data
177        let entry_data = format!(
178            "{}|{}|{}|{:?}|{}|{}|{}",
179            entry.entry_id,
180            entry.director_output_hash,
181            entry.critic_assessment_hash,
182            entry.verdict,
183            entry.score,
184            entry.timestamp.to_rfc3339(),
185            entry.iteration
186        );
187        let chain_input = format!("{}{}", expected_prev_hash, entry_data);
188        let expected_chain_hash = sha256_hex(chain_input.as_bytes());
189
190        if entry.chain_hash != expected_chain_hash {
191            return Err(AuditError::ChainIntegrity {
192                entry_index: i,
193                expected: expected_chain_hash,
194                found: entry.chain_hash.clone(),
195            });
196        }
197
198        // Verify Ed25519 signature
199        let sig_bytes =
200            hex::decode(&entry.signature).map_err(|e| AuditError::InvalidSignature {
201                entry_index: i,
202                message: format!("hex decode failed: {}", e),
203            })?;
204
205        let sig_array: [u8; 64] =
206            sig_bytes
207                .as_slice()
208                .try_into()
209                .map_err(|_| AuditError::InvalidSignature {
210                    entry_index: i,
211                    message: "signature must be 64 bytes".into(),
212                })?;
213
214        let signature = Signature::from_bytes(&sig_array);
215
216        verifying_key
217            .verify(entry.chain_hash.as_bytes(), &signature)
218            .map_err(|e| AuditError::InvalidSignature {
219                entry_index: i,
220                message: e.to_string(),
221            })?;
222
223        expected_prev_hash = entry.chain_hash.clone();
224    }
225
226    Ok(())
227}
228
229/// Compute SHA-256 and return hex-encoded string.
230fn sha256_hex(data: &[u8]) -> String {
231    let mut hasher = Sha256::new();
232    hasher.update(data);
233    hex::encode(hasher.finalize())
234}
235
236/// Errors from the audit system.
237#[derive(Debug, thiserror::Error)]
238pub enum AuditError {
239    #[error(
240        "Chain integrity violation at entry {entry_index}: expected {expected}, found {found}"
241    )]
242    ChainIntegrity {
243        entry_index: usize,
244        expected: String,
245        found: String,
246    },
247
248    #[error("Invalid signature at entry {entry_index}: {message}")]
249    InvalidSignature { entry_index: usize, message: String },
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn test_signing_key() -> SigningKey {
257        use rand::RngCore;
258        let mut secret = [0u8; 32];
259        rand::thread_rng().fill_bytes(&mut secret);
260        SigningKey::from_bytes(&secret)
261    }
262
263    #[test]
264    fn test_record_and_verify() {
265        let key = test_signing_key();
266        let mut chain = AuditChain::new(key);
267
268        chain.record(RecordParams {
269            director_output: "The analysis shows...",
270            critic_assessment: "Good analysis, approved.",
271            verdict: AuditVerdict::Approved,
272            dimension_scores: HashMap::new(),
273            score: 0.9,
274            critic_identity: AuditIdentity::Llm {
275                model_id: "claude-sonnet".into(),
276            },
277            iteration: 1,
278        });
279
280        assert_eq!(chain.len(), 1);
281        assert!(chain.verify(&chain.verifying_key()).is_ok());
282    }
283
284    #[test]
285    fn test_multi_entry_chain() {
286        let key = test_signing_key();
287        let mut chain = AuditChain::new(key);
288
289        for i in 0..5 {
290            chain.record(RecordParams {
291                director_output: &format!("Director output {}", i),
292                critic_assessment: &format!("Critic review {}", i),
293                verdict: if i < 4 {
294                    AuditVerdict::NeedsRevision
295                } else {
296                    AuditVerdict::Approved
297                },
298                dimension_scores: {
299                    let mut scores = HashMap::new();
300                    scores.insert("accuracy".into(), 0.5 + (i as f64) * 0.1);
301                    scores
302                },
303                score: 0.5 + (i as f64) * 0.1,
304                critic_identity: AuditIdentity::Llm {
305                    model_id: "claude-sonnet".into(),
306                },
307                iteration: i as u32 + 1,
308            });
309        }
310
311        assert_eq!(chain.len(), 5);
312        assert!(chain.verify(&chain.verifying_key()).is_ok());
313    }
314
315    #[test]
316    fn test_tampered_chain_hash_detected() {
317        let key = test_signing_key();
318        let verifying_key = key.verifying_key();
319        let mut chain = AuditChain::new(key);
320
321        chain.record(RecordParams {
322            director_output: "output 1",
323            critic_assessment: "review 1",
324            verdict: AuditVerdict::Approved,
325            dimension_scores: HashMap::new(),
326            score: 0.8,
327            critic_identity: AuditIdentity::Llm {
328                model_id: "test".into(),
329            },
330            iteration: 1,
331        });
332        chain.record(RecordParams {
333            director_output: "output 2",
334            critic_assessment: "review 2",
335            verdict: AuditVerdict::Approved,
336            dimension_scores: HashMap::new(),
337            score: 0.9,
338            critic_identity: AuditIdentity::Llm {
339                model_id: "test".into(),
340            },
341            iteration: 2,
342        });
343
344        // Tamper with first entry's chain hash
345        let mut tampered = chain.entries().to_vec();
346        tampered[0].chain_hash = sha256_hex(b"tampered");
347
348        let result = verify_chain(&tampered, &verifying_key);
349        assert!(result.is_err());
350        match result.unwrap_err() {
351            AuditError::ChainIntegrity { entry_index, .. } => assert_eq!(entry_index, 0),
352            other => panic!("Expected ChainIntegrity, got {:?}", other),
353        }
354    }
355
356    #[test]
357    fn test_wrong_key_rejected() {
358        let key = test_signing_key();
359        let wrong_key = test_signing_key();
360        let mut chain = AuditChain::new(key);
361
362        chain.record(RecordParams {
363            director_output: "output",
364            critic_assessment: "review",
365            verdict: AuditVerdict::Approved,
366            dimension_scores: HashMap::new(),
367            score: 0.9,
368            critic_identity: AuditIdentity::Human {
369                user_id: "user-1".into(),
370                name: "Alice".into(),
371            },
372            iteration: 1,
373        });
374
375        let result = verify_chain(chain.entries(), &wrong_key.verifying_key());
376        assert!(result.is_err());
377        match result.unwrap_err() {
378            AuditError::InvalidSignature { entry_index, .. } => assert_eq!(entry_index, 0),
379            other => panic!("Expected InvalidSignature, got {:?}", other),
380        }
381    }
382
383    #[test]
384    fn test_entry_serialization() {
385        let key = test_signing_key();
386        let mut chain = AuditChain::new(key);
387
388        let entry = chain.record(RecordParams {
389            director_output: "test output",
390            critic_assessment: "test review",
391            verdict: AuditVerdict::NeedsRevision,
392            dimension_scores: {
393                let mut m = HashMap::new();
394                m.insert("accuracy".into(), 0.7);
395                m.insert("completeness".into(), 0.8);
396                m
397            },
398            score: 0.75,
399            critic_identity: AuditIdentity::Llm {
400                model_id: "claude-sonnet".into(),
401            },
402            iteration: 1,
403        });
404
405        let json = serde_json::to_string(&entry).unwrap();
406        let restored: CriticAuditEntry = serde_json::from_str(&json).unwrap();
407        assert_eq!(restored.entry_id, entry.entry_id);
408        assert_eq!(restored.verdict, AuditVerdict::NeedsRevision);
409        assert_eq!(restored.dimension_scores.len(), 2);
410    }
411
412    #[test]
413    fn test_empty_chain_verifies() {
414        let key = test_signing_key();
415        let chain = AuditChain::new(key);
416        assert!(chain.is_empty());
417        assert!(chain.verify(&chain.verifying_key()).is_ok());
418    }
419
420    #[test]
421    fn test_sha256_deterministic() {
422        let hash1 = sha256_hex(b"hello world");
423        let hash2 = sha256_hex(b"hello world");
424        assert_eq!(hash1, hash2);
425        assert_ne!(hash1, sha256_hex(b"different input"));
426    }
427
428    #[test]
429    fn test_chain_order_matters() {
430        let key = test_signing_key();
431        let verifying_key = key.verifying_key();
432        let mut chain = AuditChain::new(key);
433
434        chain.record(RecordParams {
435            director_output: "first",
436            critic_assessment: "review first",
437            verdict: AuditVerdict::NeedsRevision,
438            dimension_scores: HashMap::new(),
439            score: 0.5,
440            critic_identity: AuditIdentity::Llm {
441                model_id: "test".into(),
442            },
443            iteration: 1,
444        });
445        chain.record(RecordParams {
446            director_output: "second",
447            critic_assessment: "review second",
448            verdict: AuditVerdict::Approved,
449            dimension_scores: HashMap::new(),
450            score: 0.9,
451            critic_identity: AuditIdentity::Llm {
452                model_id: "test".into(),
453            },
454            iteration: 2,
455        });
456
457        // Swap entries — should fail chain verification
458        let mut swapped = chain.entries().to_vec();
459        swapped.swap(0, 1);
460
461        let result = verify_chain(&swapped, &verifying_key);
462        assert!(result.is_err());
463    }
464}