Skip to main content

lean_ctx/core/
audit_trail.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, Write};
5use std::path::PathBuf;
6use std::sync::Mutex;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AuditEntry {
10    pub timestamp: String,
11    pub agent_id: String,
12    pub tool: String,
13    pub action: Option<String>,
14    pub input_hash: String,
15    pub output_tokens: u32,
16    pub role: String,
17    pub event_type: AuditEventType,
18    pub prev_hash: String,
19    pub entry_hash: String,
20    /// Ed25519 signature over `entry_hash`, proving provenance from the local
21    /// lean-ctx identity. `None` only when the keypair is unavailable (early boot).
22    #[serde(skip_serializing_if = "Option::is_none", default)]
23    pub signature: Option<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum AuditEventType {
29    ToolCall,
30    ToolDenied,
31    PathJailViolation,
32    BudgetExceeded,
33    CrossProjectAccess,
34    RateLimited,
35    SecurityViolation,
36    RoleChanged,
37    SecretDetected,
38}
39
40pub struct AuditEntryData {
41    pub agent_id: String,
42    pub tool: String,
43    pub action: Option<String>,
44    pub input_hash: String,
45    pub output_tokens: u32,
46    pub role: String,
47    pub event_type: AuditEventType,
48}
49
50pub struct ChainVerifyResult {
51    pub total_entries: usize,
52    pub valid: bool,
53    pub first_invalid_at: Option<usize>,
54}
55
56static LAST_HASH: Mutex<Option<String>> = Mutex::new(None);
57
58fn trail_path() -> Option<PathBuf> {
59    let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
60    let audit_dir = dir.join("audit");
61    fs::create_dir_all(&audit_dir).ok()?;
62    Some(audit_dir.join("trail.jsonl"))
63}
64
65fn init_last_hash(path: &PathBuf) -> String {
66    let mut guard = LAST_HASH
67        .lock()
68        .unwrap_or_else(std::sync::PoisonError::into_inner);
69    if let Some(ref h) = *guard {
70        return h.clone();
71    }
72    let hash = read_last_hash_from_file(path);
73    *guard = Some(hash.clone());
74    hash
75}
76
77fn read_last_hash_from_file(path: &PathBuf) -> String {
78    let Ok(file) = fs::File::open(path) else {
79        return "genesis".to_string();
80    };
81    let reader = std::io::BufReader::new(file);
82    let mut last_hash = "genesis".to_string();
83    for line in reader.lines().map_while(Result::ok) {
84        if let Ok(entry) = serde_json::from_str::<AuditEntry>(&line) {
85            last_hash = entry.entry_hash;
86        }
87    }
88    last_hash
89}
90
91fn compute_entry_hash(prev_hash: &str, data_json: &str) -> String {
92    let mut hasher = Sha256::new();
93    hasher.update(prev_hash.as_bytes());
94    hasher.update(data_json.as_bytes());
95    format!("{:x}", hasher.finalize())
96}
97
98pub fn record(data: AuditEntryData) {
99    let Some(path) = trail_path() else { return };
100    let prev_hash = init_last_hash(&path);
101
102    let partial = serde_json::json!({
103        "agent_id": data.agent_id,
104        "tool": data.tool,
105        "action": data.action,
106        "input_hash": data.input_hash,
107        "output_tokens": data.output_tokens,
108        "role": data.role,
109        "event_type": data.event_type,
110    });
111    let data_json = serde_json::to_string(&partial).unwrap_or_default();
112    let entry_hash = compute_entry_hash(&prev_hash, &data_json);
113
114    let signature = crate::core::agent_identity::sign_bytes("lean-ctx", entry_hash.as_bytes())
115        .map(|sig| crate::core::agent_identity::hex_encode(&sig))
116        .ok();
117
118    let entry = AuditEntry {
119        timestamp: chrono::Utc::now().to_rfc3339(),
120        agent_id: data.agent_id,
121        tool: data.tool,
122        action: data.action,
123        input_hash: data.input_hash,
124        output_tokens: data.output_tokens,
125        role: data.role,
126        event_type: data.event_type,
127        prev_hash,
128        entry_hash: entry_hash.clone(),
129        signature,
130    };
131
132    if let Ok(line) = serde_json::to_string(&entry) {
133        if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) {
134            let _ = writeln!(file, "{line}");
135        }
136    }
137
138    if let Ok(mut guard) = LAST_HASH.lock() {
139        *guard = Some(entry_hash);
140    }
141}
142
143pub fn load_recent(limit: usize) -> Vec<AuditEntry> {
144    let Some(path) = trail_path() else {
145        return Vec::new();
146    };
147    let Ok(file) = fs::File::open(&path) else {
148        return Vec::new();
149    };
150    let reader = std::io::BufReader::new(file);
151    let entries: Vec<AuditEntry> = reader
152        .lines()
153        .map_while(Result::ok)
154        .filter_map(|line| serde_json::from_str(&line).ok())
155        .collect();
156    let skip = entries.len().saturating_sub(limit);
157    entries.into_iter().skip(skip).collect()
158}
159
160pub fn verify_chain() -> ChainVerifyResult {
161    let Some(path) = trail_path() else {
162        return ChainVerifyResult {
163            total_entries: 0,
164            valid: true,
165            first_invalid_at: None,
166        };
167    };
168    let Ok(file) = fs::File::open(&path) else {
169        return ChainVerifyResult {
170            total_entries: 0,
171            valid: true,
172            first_invalid_at: None,
173        };
174    };
175    let reader = std::io::BufReader::new(file);
176    let mut prev_hash = "genesis".to_string();
177    let mut total = 0usize;
178
179    for line in reader.lines().map_while(Result::ok) {
180        let entry: AuditEntry = match serde_json::from_str(&line) {
181            Ok(e) => e,
182            Err(_) => {
183                return ChainVerifyResult {
184                    total_entries: total,
185                    valid: false,
186                    first_invalid_at: Some(total),
187                }
188            }
189        };
190
191        if entry.prev_hash != prev_hash {
192            return ChainVerifyResult {
193                total_entries: total,
194                valid: false,
195                first_invalid_at: Some(total),
196            };
197        }
198
199        let partial = serde_json::json!({
200            "agent_id": entry.agent_id,
201            "tool": entry.tool,
202            "action": entry.action,
203            "input_hash": entry.input_hash,
204            "output_tokens": entry.output_tokens,
205            "role": entry.role,
206            "event_type": entry.event_type,
207        });
208        let data_json = serde_json::to_string(&partial).unwrap_or_default();
209        let expected = compute_entry_hash(&prev_hash, &data_json);
210
211        if entry.entry_hash != expected {
212            return ChainVerifyResult {
213                total_entries: total,
214                valid: false,
215                first_invalid_at: Some(total),
216            };
217        }
218
219        prev_hash = entry.entry_hash;
220        total += 1;
221    }
222
223    ChainVerifyResult {
224        total_entries: total,
225        valid: true,
226        first_invalid_at: None,
227    }
228}
229
230pub fn hash_input(args: &serde_json::Map<String, serde_json::Value>) -> String {
231    let serialized = serde_json::to_string(args).unwrap_or_default();
232    let mut hasher = Sha256::new();
233    hasher.update(serialized.as_bytes());
234    format!("{:x}", hasher.finalize())
235}