Skip to main content

cortex_audit/
lib.rs

1//! TIBET Cortex Audit — Blackbox-met-window audit trails
2//!
3//! The auditor can see:
4//! - WHO accessed data (actor)
5//! - WHEN it was accessed (timestamp)
6//! - HOW MUCH data was touched (chunks accessed/denied)
7//! - WHAT JIS level was used (clearance)
8//! - WHETHER integrity holds (TIBET hash chain)
9//!
10//! The auditor CANNOT see:
11//! - The actual content (unless they have matching JIS level)
12//! - The query itself (only its hash)
13//!
14//! Audit trails are append-only TIBET chains stored in sled,
15//! with optional tibet-vault integration for time-locked compliance.
16
17use cortex_core::tibet::{TibetToken, Provenance};
18use cortex_core::crypto::ContentHash;
19use cortex_core::error::{CortexError, CortexResult};
20use cortex_airlock::AirlockSession;
21use chrono::{DateTime, Utc};
22use serde::{Serialize, Deserialize};
23
24/// An audit entry — the "window" into the blackbox
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct AuditEntry {
27    pub token: TibetToken,
28    pub query_hash: ContentHash,
29    pub chunks_accessed: usize,
30    pub chunks_denied: usize,
31    pub response_hash: ContentHash,
32    pub airlock_duration_ms: f64,
33    pub timestamp: DateTime<Utc>,
34}
35
36/// The Audit Trail — append-only log backed by sled
37pub struct AuditTrail {
38    db: sled::Db,
39    chain: Provenance,
40}
41
42/// Audit statistics for compliance reporting
43#[derive(Clone, Debug, Serialize, Deserialize)]
44pub struct AuditStats {
45    pub total_queries: usize,
46    pub total_chunks_accessed: usize,
47    pub total_chunks_denied: usize,
48    pub unique_actors: usize,
49    pub chain_intact: bool,
50    pub first_entry: Option<DateTime<Utc>>,
51    pub last_entry: Option<DateTime<Utc>>,
52}
53
54impl AuditTrail {
55    pub fn open(path: &str) -> CortexResult<Self> {
56        let db = sled::open(path)
57            .map_err(|e| CortexError::Storage(e.to_string()))?;
58
59        // Load existing chain
60        let chain = if let Some(bytes) = db.get(b"__chain__")
61            .map_err(|e| CortexError::Storage(e.to_string()))? {
62            serde_json::from_slice(&bytes).unwrap_or_default()
63        } else {
64            Provenance::new()
65        };
66
67        Ok(Self { db, chain })
68    }
69
70    /// Record an airlock session as an audit entry
71    pub fn record_session(
72        &mut self,
73        session: &AirlockSession,
74        query_hash: ContentHash,
75        response_hash: ContentHash,
76    ) -> CortexResult<AuditEntry> {
77        let mut token = TibetToken::new(
78            query_hash.clone(),
79            format!("Query processed via airlock {}", session.session_id),
80            &session.actor,
81            session.jis_level,
82        )
83        .with_access_stats(session.chunks_processed, session.chunks_denied)
84        .with_airlock_time(session.duration_ms);
85
86        // Chain to previous token
87        if let Some(prev) = self.chain.latest() {
88            token = token.with_parent(&prev.token_id);
89        }
90
91        let entry = AuditEntry {
92            token: token.clone(),
93            query_hash,
94            chunks_accessed: session.chunks_processed,
95            chunks_denied: session.chunks_denied,
96            response_hash,
97            airlock_duration_ms: session.duration_ms,
98            timestamp: Utc::now(),
99        };
100
101        // Store entry
102        let entry_bytes = serde_json::to_vec(&entry)?;
103        self.db
104            .insert(token.token_id.as_bytes(), entry_bytes)
105            .map_err(|e| CortexError::Storage(e.to_string()))?;
106
107        // Update chain
108        self.chain.append(token);
109        let chain_bytes = serde_json::to_vec(&self.chain)?;
110        self.db
111            .insert(b"__chain__", chain_bytes)
112            .map_err(|e| CortexError::Storage(e.to_string()))?;
113
114        self.db
115            .flush()
116            .map_err(|e| CortexError::Storage(e.to_string()))?;
117
118        Ok(entry)
119    }
120
121    /// Record a custom audit event (e.g., system prompt modification)
122    pub fn record_event(
123        &mut self,
124        actor: &str,
125        jis_level: u8,
126        event_hash: ContentHash,
127        description: &str,
128    ) -> CortexResult<AuditEntry> {
129        let mut token = TibetToken::new(
130            event_hash.clone(),
131            description,
132            actor,
133            jis_level,
134        );
135
136        if let Some(prev) = self.chain.latest() {
137            token = token.with_parent(&prev.token_id);
138        }
139
140        let entry = AuditEntry {
141            token: token.clone(),
142            query_hash: event_hash,
143            chunks_accessed: 0,
144            chunks_denied: 0,
145            response_hash: ContentHash("sha256:event".into()),
146            airlock_duration_ms: 0.0,
147            timestamp: Utc::now(),
148        };
149
150        let entry_bytes = serde_json::to_vec(&entry)?;
151        self.db
152            .insert(token.token_id.as_bytes(), entry_bytes)
153            .map_err(|e| CortexError::Storage(e.to_string()))?;
154
155        self.chain.append(token);
156        let chain_bytes = serde_json::to_vec(&self.chain)?;
157        self.db
158            .insert(b"__chain__", chain_bytes)
159            .map_err(|e| CortexError::Storage(e.to_string()))?;
160
161        Ok(entry)
162    }
163
164    /// Verify the entire audit chain is unbroken
165    pub fn verify_chain(&self) -> bool {
166        self.chain.verify_chain()
167    }
168
169    /// Get audit statistics
170    pub fn stats(&self) -> CortexResult<AuditStats> {
171        let mut total_accessed = 0usize;
172        let mut total_denied = 0usize;
173        let mut actors = std::collections::HashSet::new();
174
175        let entries: Vec<AuditEntry> = self.db
176            .iter()
177            .filter_map(|r| r.ok())
178            .filter(|(k, _)| k.as_ref() != b"__chain__")
179            .filter_map(|(_, v)| serde_json::from_slice(&v).ok())
180            .collect();
181
182        for entry in &entries {
183            total_accessed += entry.chunks_accessed;
184            total_denied += entry.chunks_denied;
185            actors.insert(entry.token.eromheen.actor.clone());
186        }
187
188        Ok(AuditStats {
189            total_queries: entries.len(),
190            total_chunks_accessed: total_accessed,
191            total_chunks_denied: total_denied,
192            unique_actors: actors.len(),
193            chain_intact: self.verify_chain(),
194            first_entry: entries.iter().map(|e| e.timestamp).min(),
195            last_entry: entries.iter().map(|e| e.timestamp).max(),
196        })
197    }
198
199    /// Get the full provenance chain (for auditors with sufficient JIS level)
200    pub fn chain(&self) -> &Provenance {
201        &self.chain
202    }
203
204    /// Get chain length
205    pub fn chain_len(&self) -> usize {
206        self.chain.len()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn temp_trail() -> AuditTrail {
215        let dir = tempfile::tempdir().unwrap();
216        AuditTrail::open(dir.path().to_str().unwrap()).unwrap()
217    }
218
219    #[test]
220    fn test_record_and_verify() {
221        let mut trail = temp_trail();
222
223        let session = AirlockSession {
224            session_id: "test_001".into(),
225            actor: "analyst@company.com".into(),
226            jis_level: 2,
227            chunks_processed: 5,
228            chunks_denied: 3,
229            duration_ms: 12.5,
230            input_hash: ContentHash("sha256:input".into()),
231            output_hash: ContentHash("sha256:output".into()),
232        };
233
234        let entry = trail.record_session(
235            &session,
236            ContentHash("sha256:query_hash".into()),
237            ContentHash("sha256:response_hash".into()),
238        ).unwrap();
239
240        assert_eq!(entry.chunks_accessed, 5);
241        assert_eq!(entry.chunks_denied, 3);
242        assert_eq!(trail.chain_len(), 1);
243        assert!(trail.verify_chain());
244    }
245
246    #[test]
247    fn test_chain_integrity() {
248        let mut trail = temp_trail();
249
250        for i in 0..5 {
251            let session = AirlockSession {
252                session_id: format!("session_{i}"),
253                actor: "user@test.com".into(),
254                jis_level: 1,
255                chunks_processed: i,
256                chunks_denied: 0,
257                duration_ms: 1.0,
258                input_hash: ContentHash(format!("sha256:input_{i}")),
259                output_hash: ContentHash(format!("sha256:output_{i}")),
260            };
261
262            trail.record_session(
263                &session,
264                ContentHash(format!("sha256:query_{i}")),
265                ContentHash(format!("sha256:response_{i}")),
266            ).unwrap();
267        }
268
269        assert_eq!(trail.chain_len(), 5);
270        assert!(trail.verify_chain());
271    }
272
273    #[test]
274    fn test_audit_stats() {
275        let mut trail = temp_trail();
276
277        for actor in &["alice@co.com", "bob@co.com", "alice@co.com"] {
278            let session = AirlockSession {
279                session_id: "s".into(),
280                actor: actor.to_string(),
281                jis_level: 1,
282                chunks_processed: 10,
283                chunks_denied: 5,
284                duration_ms: 1.0,
285                input_hash: ContentHash("sha256:in".into()),
286                output_hash: ContentHash("sha256:out".into()),
287            };
288
289            trail.record_session(
290                &session,
291                ContentHash("sha256:q".into()),
292                ContentHash("sha256:r".into()),
293            ).unwrap();
294        }
295
296        let stats = trail.stats().unwrap();
297        assert_eq!(stats.total_queries, 3);
298        assert_eq!(stats.total_chunks_accessed, 30);
299        assert_eq!(stats.total_chunks_denied, 15);
300        assert_eq!(stats.unique_actors, 2);
301        assert!(stats.chain_intact);
302    }
303}