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;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct AuditEntry {
9    pub timestamp: String,
10    pub agent_id: String,
11    pub tool: String,
12    pub action: Option<String>,
13    pub input_hash: String,
14    pub output_tokens: u32,
15    pub role: String,
16    pub event_type: AuditEventType,
17    pub prev_hash: String,
18    pub entry_hash: String,
19    /// Ed25519 signature over `entry_hash`, proving provenance from the local
20    /// lean-ctx identity. `None` only when the keypair is unavailable (early boot).
21    #[serde(skip_serializing_if = "Option::is_none", default)]
22    pub signature: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum AuditEventType {
28    ToolCall,
29    ToolDenied,
30    PathJailViolation,
31    BudgetExceeded,
32    CrossProjectAccess,
33    RateLimited,
34    SecurityViolation,
35    RoleChanged,
36    SecretDetected,
37    // Agent identity lifecycle (GL #433) — additive OCP Part 4 evolution.
38    AgentRegistered,
39    AgentSuspended,
40    AgentResumed,
41    AgentDecommissioned,
42}
43
44pub struct AuditEntryData {
45    pub agent_id: String,
46    pub tool: String,
47    pub action: Option<String>,
48    pub input_hash: String,
49    pub output_tokens: u32,
50    pub role: String,
51    pub event_type: AuditEventType,
52}
53
54pub struct ChainVerifyResult {
55    pub total_entries: usize,
56    pub valid: bool,
57    pub first_invalid_at: Option<usize>,
58}
59
60fn trail_path() -> Option<PathBuf> {
61    let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
62    let audit_dir = dir.join("audit");
63    fs::create_dir_all(&audit_dir).ok()?;
64    Some(audit_dir.join("trail.jsonl"))
65}
66
67/// Read the chain tail from the file itself. Called under the exclusive
68/// file lock — the file is the ONLY source of truth for `prev_hash`. (A
69/// per-process cache forked the chain whenever two processes appended
70/// concurrently — found by `leanctx-verify` on a real trail, GL #425.)
71fn read_last_hash_tail(file: &fs::File) -> String {
72    use std::io::{Read, Seek, SeekFrom};
73    const TAIL: i64 = 64 * 1024;
74
75    let mut f = file;
76    let Ok(len) = f.seek(SeekFrom::End(0)) else {
77        return "genesis".to_string();
78    };
79    if len == 0 {
80        return "genesis".to_string();
81    }
82    let start = if (len as i64) > TAIL {
83        -TAIL
84    } else {
85        -(len as i64)
86    };
87    if f.seek(SeekFrom::End(start)).is_err() {
88        return "genesis".to_string();
89    }
90    let mut buf = String::new();
91    if f.read_to_string(&mut buf).is_err() {
92        return "genesis".to_string();
93    }
94    // Concurrent-append history may hold multiple objects per line; a
95    // stream parse of the last non-empty line yields the true tail entry.
96    for line in buf.lines().rev() {
97        if line.trim().is_empty() {
98            continue;
99        }
100        let mut last: Option<String> = None;
101        for v in serde_json::Deserializer::from_str(line)
102            .into_iter::<serde_json::Value>()
103            .flatten()
104        {
105            if let Some(h) = v.get("entry_hash").and_then(|h| h.as_str()) {
106                last = Some(h.to_string());
107            }
108        }
109        if let Some(h) = last {
110            return h;
111        }
112    }
113    "genesis".to_string()
114}
115
116fn compute_entry_hash(prev_hash: &str, data_json: &str) -> String {
117    let mut hasher = Sha256::new();
118    hasher.update(prev_hash.as_bytes());
119    hasher.update(data_json.as_bytes());
120    crate::core::agent_identity::hex_encode(&hasher.finalize())
121}
122
123pub fn record(data: AuditEntryData) {
124    use fs2::FileExt;
125
126    let Some(path) = trail_path() else { return };
127    let Ok(mut file) = OpenOptions::new()
128        .create(true)
129        .append(true)
130        .read(true)
131        .open(&path)
132    else {
133        return;
134    };
135    // Advisory lock serializes appends ACROSS processes; prev_hash is read
136    // from the file under the same lock, so the chain cannot fork.
137    if file.lock_exclusive().is_err() {
138        return;
139    }
140    let prev_hash = read_last_hash_tail(&file);
141
142    let partial = serde_json::json!({
143        "agent_id": data.agent_id,
144        "tool": data.tool,
145        "action": data.action,
146        "input_hash": data.input_hash,
147        "output_tokens": data.output_tokens,
148        "role": data.role,
149        "event_type": data.event_type,
150    });
151    let data_json = serde_json::to_string(&partial).unwrap_or_default();
152    let entry_hash = compute_entry_hash(&prev_hash, &data_json);
153
154    let signature = crate::core::agent_identity::sign_bytes("lean-ctx", entry_hash.as_bytes())
155        .map(|sig| crate::core::agent_identity::hex_encode(&sig))
156        .ok();
157
158    let entry = AuditEntry {
159        timestamp: chrono::Utc::now().to_rfc3339(),
160        agent_id: data.agent_id,
161        tool: data.tool,
162        action: data.action,
163        input_hash: data.input_hash,
164        output_tokens: data.output_tokens,
165        role: data.role,
166        event_type: data.event_type,
167        prev_hash,
168        entry_hash,
169        signature,
170    };
171
172    if let Ok(line) = serde_json::to_string(&entry) {
173        let _ = writeln!(file, "{line}");
174    }
175    let _ = FileExt::unlock(&file);
176}
177
178pub fn load_recent(limit: usize) -> Vec<AuditEntry> {
179    let Some(path) = trail_path() else {
180        return Vec::new();
181    };
182    let Ok(file) = fs::File::open(&path) else {
183        return Vec::new();
184    };
185    let reader = std::io::BufReader::new(file);
186    let entries: Vec<AuditEntry> = reader
187        .lines()
188        .map_while(Result::ok)
189        .filter_map(|line| serde_json::from_str(&line).ok())
190        .collect();
191    let skip = entries.len().saturating_sub(limit);
192    entries.into_iter().skip(skip).collect()
193}
194
195pub fn verify_chain() -> ChainVerifyResult {
196    let Some(path) = trail_path() else {
197        return ChainVerifyResult {
198            total_entries: 0,
199            valid: true,
200            first_invalid_at: None,
201        };
202    };
203    let Ok(file) = fs::File::open(&path) else {
204        return ChainVerifyResult {
205            total_entries: 0,
206            valid: true,
207            first_invalid_at: None,
208        };
209    };
210    let reader = std::io::BufReader::new(file);
211    let mut prev_hash = "genesis".to_string();
212    let mut total = 0usize;
213
214    for line in reader.lines().map_while(Result::ok) {
215        let entry: AuditEntry = match serde_json::from_str(&line) {
216            Ok(e) => e,
217            Err(_) => {
218                return ChainVerifyResult {
219                    total_entries: total,
220                    valid: false,
221                    first_invalid_at: Some(total),
222                };
223            }
224        };
225
226        if entry.prev_hash != prev_hash {
227            return ChainVerifyResult {
228                total_entries: total,
229                valid: false,
230                first_invalid_at: Some(total),
231            };
232        }
233
234        let partial = serde_json::json!({
235            "agent_id": entry.agent_id,
236            "tool": entry.tool,
237            "action": entry.action,
238            "input_hash": entry.input_hash,
239            "output_tokens": entry.output_tokens,
240            "role": entry.role,
241            "event_type": entry.event_type,
242        });
243        let data_json = serde_json::to_string(&partial).unwrap_or_default();
244        let expected = compute_entry_hash(&prev_hash, &data_json);
245
246        if entry.entry_hash != expected {
247            return ChainVerifyResult {
248                total_entries: total,
249                valid: false,
250                first_invalid_at: Some(total),
251            };
252        }
253
254        prev_hash = entry.entry_hash;
255        total += 1;
256    }
257
258    ChainVerifyResult {
259        total_entries: total,
260        valid: true,
261        first_invalid_at: None,
262    }
263}
264
265pub fn hash_input(args: &serde_json::Map<String, serde_json::Value>) -> String {
266    let serialized = serde_json::to_string(args).unwrap_or_default();
267    let mut hasher = Sha256::new();
268    hasher.update(serialized.as_bytes());
269    crate::core::agent_identity::hex_encode(&hasher.finalize())
270}