1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, Read, Write};
5use std::path::{Path, PathBuf};
6
7mod checkpoint;
8mod segments;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AuditEntry {
12 pub timestamp: String,
13 pub agent_id: String,
14 pub tool: String,
15 pub action: Option<String>,
16 pub input_hash: String,
17 pub output_tokens: u32,
18 pub role: String,
19 pub event_type: AuditEventType,
20 pub prev_hash: String,
21 pub entry_hash: String,
22 #[serde(skip_serializing_if = "Option::is_none", default)]
25 pub signature: Option<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum AuditEventType {
30 ToolCall,
31 ToolDenied,
32 PathJailViolation,
33 BudgetExceeded,
34 CrossProjectAccess,
35 RateLimited,
36 SecurityViolation,
37 RoleChanged,
38 SecretDetected,
39 AgentRegistered,
41 AgentSuspended,
42 AgentResumed,
43 AgentDecommissioned,
44 AgentDriftDetected,
48 AgentDriftAcknowledged,
52 Unknown(String),
55}
56
57impl AuditEventType {
58 fn wire_label(&self) -> &str {
59 match self {
60 Self::ToolCall => "tool_call",
61 Self::ToolDenied => "tool_denied",
62 Self::PathJailViolation => "path_jail_violation",
63 Self::BudgetExceeded => "budget_exceeded",
64 Self::CrossProjectAccess => "cross_project_access",
65 Self::RateLimited => "rate_limited",
66 Self::SecurityViolation => "security_violation",
67 Self::RoleChanged => "role_changed",
68 Self::SecretDetected => "secret_detected",
69 Self::AgentRegistered => "agent_registered",
70 Self::AgentSuspended => "agent_suspended",
71 Self::AgentResumed => "agent_resumed",
72 Self::AgentDecommissioned => "agent_decommissioned",
73 Self::AgentDriftDetected => "agent_drift_detected",
74 Self::AgentDriftAcknowledged => "agent_drift_acknowledged",
75 Self::Unknown(label) => label,
76 }
77 }
78}
79
80impl Serialize for AuditEventType {
81 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
82 where
83 S: serde::Serializer,
84 {
85 if let Self::Unknown(label) = self {
86 if !valid_unknown_event_label_for_write(label) {
87 return Err(serde::ser::Error::custom(format!(
88 "unknown event_type is not a safe additive label: {label}"
89 )));
90 }
91 }
92 serializer.serialize_str(self.wire_label())
93 }
94}
95
96impl<'de> Deserialize<'de> for AuditEventType {
97 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
98 where
99 D: serde::Deserializer<'de>,
100 {
101 let label = String::deserialize(deserializer)?;
102 let event_type = match label.as_str() {
103 "tool_call" => Self::ToolCall,
104 "tool_denied" => Self::ToolDenied,
105 "path_jail_violation" => Self::PathJailViolation,
106 "budget_exceeded" => Self::BudgetExceeded,
107 "cross_project_access" => Self::CrossProjectAccess,
108 "rate_limited" => Self::RateLimited,
109 "security_violation" => Self::SecurityViolation,
110 "role_changed" => Self::RoleChanged,
111 "secret_detected" => Self::SecretDetected,
112 "agent_registered" => Self::AgentRegistered,
113 "agent_suspended" => Self::AgentSuspended,
114 "agent_resumed" => Self::AgentResumed,
115 "agent_decommissioned" => Self::AgentDecommissioned,
116 "agent_drift_detected" => Self::AgentDriftDetected,
117 "agent_drift_acknowledged" => Self::AgentDriftAcknowledged,
118 _ if valid_extension_event_label(&label) => Self::Unknown(label),
119 _ => {
120 return Err(serde::de::Error::custom(
121 "unknown event_type must use the x- extension namespace",
122 ));
123 }
124 };
125 Ok(event_type)
126 }
127}
128
129fn valid_extension_event_label(label: &str) -> bool {
130 const MAX_EVENT_LABEL_BYTES: usize = 64;
131 if label.len() > MAX_EVENT_LABEL_BYTES {
132 return false;
133 }
134 let Some(rest) = label.strip_prefix("x-") else {
135 return false;
136 };
137 let mut chars = rest.chars();
138 chars.next().is_some_and(|c| c.is_ascii_lowercase())
139 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
140}
141
142fn valid_unknown_event_label_for_write(label: &str) -> bool {
143 valid_extension_event_label(label)
144}
145
146pub struct AuditEntryData {
147 pub agent_id: String,
148 pub tool: String,
149 pub action: Option<String>,
150 pub input_hash: String,
151 pub output_tokens: u32,
152 pub role: String,
153 pub event_type: AuditEventType,
154}
155
156pub struct ChainVerifyResult {
157 pub total_entries: usize,
158 pub valid: bool,
159 pub first_invalid_at: Option<usize>,
160 pub source_present: bool,
164 pub unknown_event_types: usize,
170}
171
172fn trail_path() -> Option<PathBuf> {
173 let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
174 let audit_dir = dir.join("audit");
175 fs::create_dir_all(&audit_dir).ok()?;
176 Some(audit_dir.join("trail.jsonl"))
177}
178
179fn read_last_hash_tail(file: &fs::File) -> Result<String, String> {
189 Ok(read_tail_entry(file)?
190 .and_then(|entry| entry.get("entry_hash")?.as_str().map(str::to_string))
191 .unwrap_or_else(|| "genesis".to_string()))
192}
193
194fn read_tail_entry(file: &fs::File) -> Result<Option<serde_json::Value>, String> {
197 use std::io::{Read, Seek, SeekFrom};
198 const TAIL: i64 = 64 * 1024;
199
200 let mut f = file;
201 let len = f
202 .seek(SeekFrom::End(0))
203 .map_err(|e| format!("audit tail seek: {e}"))?;
204 if len == 0 {
205 return Ok(None);
206 }
207 let start = if (len as i64) > TAIL {
208 -TAIL
209 } else {
210 -(len as i64)
211 };
212 f.seek(SeekFrom::End(start))
213 .map_err(|e| format!("audit tail seek: {e}"))?;
214 let mut buf = String::new();
215 f.read_to_string(&mut buf)
216 .map_err(|e| format!("audit tail read: {e}"))?;
217 for line in buf.lines().rev() {
220 if line.trim().is_empty() {
221 continue;
222 }
223 let mut last: Option<serde_json::Value> = None;
224 for parsed in serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>() {
225 let v = parsed.map_err(|e| format!("audit tail parse: {e}"))?;
226 if v.get("entry_hash").and_then(|h| h.as_str()).is_some() {
227 last = Some(v);
228 }
229 }
230 if last.is_some() {
231 return Ok(last);
232 }
233 }
234 Err("audit tail: no readable chain head".to_string())
237}
238
239fn compute_entry_hash(prev_hash: &str, data_json: &str) -> String {
240 let mut hasher = Sha256::new();
241 hasher.update(prev_hash.as_bytes());
242 hasher.update(data_json.as_bytes());
243 crate::core::agent_identity::hex_encode(&hasher.finalize())
244}
245
246pub fn record(data: AuditEntryData) {
251 let _ = try_record(data);
252}
253
254const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(750);
259const LOCK_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
260
261fn lock_trail(file: &fs::File) -> Result<(), String> {
263 lock_trail_with(file, fs2::FileExt::try_lock_exclusive)
264}
265
266fn lock_trail_shared(file: &fs::File) -> Result<(), String> {
273 lock_trail_with(file, fs2::FileExt::try_lock_shared)
274}
275
276fn release_read_lock(file: &fs::File) {
290 #[cfg(not(windows))]
291 let _ = fs2::FileExt::unlock(file);
292 #[cfg(windows)]
293 let _ = file;
294}
295
296fn still_holds_prefix(file: &fs::File, len: u64) -> bool {
299 file.metadata().is_ok_and(|meta| meta.len() >= len)
300}
301
302fn lock_trail_with(
303 file: &fs::File,
304 try_lock: fn(&fs::File) -> std::io::Result<()>,
305) -> Result<(), String> {
306 use std::time::Instant;
307
308 let deadline = Instant::now() + LOCK_TIMEOUT;
309 loop {
310 match try_lock(file) {
311 Ok(()) => return Ok(()),
312 Err(error) if super::file_lock::is_contended(&error) => {
313 if Instant::now() >= deadline {
314 return Err(format!(
315 "audit trail lock timed out after {}ms; another append is still active",
316 LOCK_TIMEOUT.as_millis()
317 ));
318 }
319 std::thread::sleep(LOCK_RETRY_INTERVAL);
320 }
321 Err(error) => return Err(format!("audit trail lock: {error}")),
322 }
323 }
324}
325
326pub fn try_record(data: AuditEntryData) -> Result<AuditEntry, String> {
337 let path = trail_path().ok_or_else(|| "audit trail unavailable".to_string())?;
338 try_record_at(&path, data)
339}
340
341pub fn record_at(path: &Path, data: AuditEntryData) {
345 let _ = try_record_at(path, data);
346}
347
348fn try_record_at(path: &Path, data: AuditEntryData) -> Result<AuditEntry, String> {
349 try_record_at_time(path, data, chrono::Utc::now())
350}
351
352fn try_record_at_time(
354 path: &Path,
355 data: AuditEntryData,
356 now: chrono::DateTime<chrono::Utc>,
357) -> Result<AuditEntry, String> {
358 let mut data = data;
360 for _ in 0..SNAPSHOT_ATTEMPTS {
361 let mut file = open_active_locked(path)?;
362 let appended = append_or_seal(path, &mut file, data, now);
363 let _ = fs2::FileExt::unlock(&file);
364 match appended? {
365 Appended::Done(entry) => return Ok(entry),
366 Appended::Moved(back) => data = back,
367 }
368 }
369 Err("audit trail: the active file kept moving".to_string())
370}
371
372enum Appended {
374 Done(AuditEntry),
375 Moved(AuditEntryData),
377}
378
379fn open_active_locked(path: &Path) -> Result<fs::File, String> {
384 for _ in 0..SNAPSHOT_ATTEMPTS {
385 let file = OpenOptions::new()
386 .create(true)
387 .append(true)
388 .read(true)
389 .open(path)
390 .map_err(|e| format!("audit trail open: {e}"))?;
391 lock_trail(&file)?;
392 if is_current(path, &file) {
393 return Ok(file);
394 }
395 let _ = fs2::FileExt::unlock(&file);
396 }
397 Err("audit trail: the active file kept moving".to_string())
398}
399
400fn is_current(path: &Path, file: &fs::File) -> bool {
402 #[cfg(unix)]
403 {
404 use std::os::unix::fs::MetadataExt;
405 match (fs::metadata(path), file.metadata()) {
406 (Ok(named), Ok(open)) => (named.dev(), named.ino()) == (open.dev(), open.ino()),
407 _ => false,
408 }
409 }
410 #[cfg(not(unix))]
413 {
414 let _ = (path, file);
415 true
416 }
417}
418
419fn append_or_seal(
422 path: &Path,
423 file: &mut fs::File,
424 data: AuditEntryData,
425 now: chrono::DateTime<chrono::Utc>,
426) -> Result<Appended, String> {
427 let Some(last) = read_tail_entry(file)? else {
428 let prev_hash = match segments::sealed(path).last() {
430 Some((_, segment)) => fs::File::open(segment)
431 .map_err(|e| format!("audit segment open: {e}"))
432 .and_then(|segment| read_last_hash_tail(&segment))?,
433 None => "genesis".to_string(),
434 };
435 return append_locked(file, data, prev_hash).map(Appended::Done);
436 };
437 let last_hash = last
438 .get("entry_hash")
439 .and_then(serde_json::Value::as_str)
440 .unwrap_or_default()
441 .to_string();
442 let next_seq = segments::sealed(path).last().map_or(0, |(seq, _)| seq + 1);
443 let sealed_seq = if last.get("event_type").and_then(serde_json::Value::as_str)
444 == Some(segments::SEAL_EVENT)
445 {
446 segments::sealed_seq(last.get("action").and_then(serde_json::Value::as_str))
447 } else {
448 None
449 };
450 let len = file
451 .metadata()
452 .map_err(|e| format!("audit stat: {e}"))?
453 .len();
454 let last_at = last.get("timestamp").and_then(serde_json::Value::as_str);
455 let seq = match sealed_seq {
456 Some(seq) if seq == next_seq => seq,
458 Some(seq) if seq < next_seq => return Ok(Appended::Moved(data)),
460 _ if segments::due(len, last_at, now)
461 && !segments::backing_off()
462 && verified_once(path, file) =>
463 {
464 append_locked(file, seal_data(next_seq), last_hash.clone())?;
465 next_seq
466 }
467 _ => return append_locked(file, data, last_hash).map(Appended::Done),
468 };
469 match segments::retire(path, seq) {
470 Ok(()) => Ok(Appended::Moved(data)),
471 Err(error) => {
472 tracing::warn!("audit trail not sealed: {error}");
475 segments::back_off();
476 let prev_hash = read_last_hash_tail(file)?;
477 append_locked(file, data, prev_hash).map(Appended::Done)
478 }
479 }
480}
481
482fn verified_once(path: &Path, file: &fs::File) -> bool {
486 let Some(store) = checkpoint::Store::for_trail(path) else {
487 return false;
488 };
489 checkpoint::FileStamp::of(file).is_some_and(|stamp| store.holds_prefix_of(file, &stamp))
490}
491
492fn seal_data(seq: u64) -> AuditEntryData {
493 AuditEntryData {
494 agent_id: "lean-ctx".to_string(),
495 tool: "audit_trail".to_string(),
496 action: Some(segments::seal_action(seq)),
497 input_hash: String::new(),
498 output_tokens: 0,
499 role: "system".to_string(),
500 event_type: AuditEventType::Unknown(segments::SEAL_EVENT.to_string()),
501 }
502}
503
504pub fn is_seal(event_type: &AuditEventType) -> bool {
507 matches!(event_type, AuditEventType::Unknown(label) if label == segments::SEAL_EVENT)
508}
509
510fn append_locked(
513 file: &mut fs::File,
514 data: AuditEntryData,
515 prev_hash: String,
516) -> Result<AuditEntry, String> {
517 use std::io::{Seek, SeekFrom};
518
519 let authenticated_drift = matches!(
520 &data.event_type,
521 AuditEventType::AgentDriftDetected | AuditEventType::AgentDriftAcknowledged
522 ) || is_seal(&data.event_type);
523
524 let partial = serde_json::json!({
525 "agent_id": data.agent_id,
526 "tool": data.tool,
527 "action": data.action,
528 "input_hash": data.input_hash,
529 "output_tokens": data.output_tokens,
530 "role": data.role,
531 "event_type": data.event_type,
532 });
533 let data_json = serde_json::to_string(&partial).map_err(|e| format!("audit serialize: {e}"))?;
534 let entry_hash = compute_entry_hash(&prev_hash, &data_json);
535
536 let signature_result =
537 crate::core::agent_identity::sign_bytes("lean-ctx", entry_hash.as_bytes())
538 .map(|sig| crate::core::agent_identity::hex_encode(&sig));
539 let signature = if authenticated_drift {
540 Some(signature_result.map_err(|e| format!("audit signature: {e}"))?)
541 } else {
542 signature_result.ok()
543 };
544
545 let entry = AuditEntry {
546 timestamp: chrono::Utc::now().to_rfc3339(),
547 agent_id: data.agent_id,
548 tool: data.tool,
549 action: data.action,
550 input_hash: data.input_hash,
551 output_tokens: data.output_tokens,
552 role: data.role,
553 event_type: data.event_type,
554 prev_hash,
555 entry_hash,
556 signature,
557 };
558
559 let mut line = serde_json::to_string(&entry).map_err(|e| format!("audit serialize: {e}"))?;
560 line.push('\n');
561 let rollback_len = file
565 .seek(SeekFrom::End(0))
566 .map_err(|e| format!("audit seek: {e}"))?;
567 if let Err(e) = file
568 .write_all(line.as_bytes())
569 .and_then(|()| file.flush())
570 .and_then(|()| file.sync_data())
571 {
572 let _ = file.set_len(rollback_len);
573 return Err(format!("audit append: {e}"));
574 }
575 Ok(entry)
576}
577
578pub fn default_trail_path() -> Option<PathBuf> {
580 trail_path()
581}
582
583pub fn load_recent(limit: usize) -> Vec<AuditEntry> {
585 let Some(path) = trail_path() else {
586 return Vec::new();
587 };
588 load_recent_at(&path, limit)
589}
590
591const TAIL_BLOCK: u64 = 256 * 1024;
593
594pub fn load_recent_at(path: &Path, limit: usize) -> Vec<AuditEntry> {
598 let mut entries: Vec<AuditEntry> = Vec::new();
599 for file in segments::chain(path).iter().rev() {
600 if entries.len() >= limit {
601 break;
602 }
603 let mut older = load_recent_in(file, limit - entries.len());
604 older.append(&mut entries);
605 entries = older;
606 }
607 entries
608}
609
610fn load_recent_in(path: &Path, limit: usize) -> Vec<AuditEntry> {
611 use std::io::{Seek, SeekFrom};
612
613 if limit == 0 {
614 return Vec::new();
615 }
616 let Some((file, len)) = open_for_listing(path) else {
617 return Vec::new();
618 };
619 let mut start = len;
620 let mut blocks: Vec<Vec<u8>> = Vec::new();
621 let mut newlines = 0usize;
622 while start > 0 && newlines <= limit {
623 let from = start.saturating_sub(TAIL_BLOCK);
624 let mut block = Vec::new();
625 let read = (&file)
626 .seek(SeekFrom::Start(from))
627 .and_then(|_| (&file).take(start - from).read_to_end(&mut block));
628 if read.is_err() {
629 break;
630 }
631 newlines += bytecount::count(&block, b'\n');
632 blocks.push(block);
633 start = from;
634 }
635 let mut tail: Vec<u8> = blocks.into_iter().rev().flatten().collect();
636 if start > 0 {
638 let cut = tail
639 .iter()
640 .position(|&byte| byte == b'\n')
641 .map_or(tail.len(), |at| at + 1);
642 tail.drain(..cut);
643 }
644 let entries = parse_entries(std::io::BufReader::new(tail.as_slice()));
645 let skip = entries.len().saturating_sub(limit);
646 entries.into_iter().skip(skip).collect()
647}
648
649pub fn load_all_at(path: &Path) -> Vec<AuditEntry> {
651 let mut entries = Vec::new();
652 for file in segments::chain(path) {
653 if let Some((file, len)) = open_for_listing(&file) {
654 entries.extend(parse_entries(std::io::BufReader::new(file.take(len))));
655 }
656 }
657 entries
658}
659
660pub fn trail_files_since(
666 trail: &Path,
667 since: Option<chrono::DateTime<chrono::Utc>>,
668) -> Vec<PathBuf> {
669 let mut files: Vec<PathBuf> = segments::sealed(trail)
670 .into_iter()
671 .map(|(_, path)| path)
672 .filter(|path| {
673 since.is_none_or(|since| {
674 fs::File::open(path)
675 .ok()
676 .and_then(|file| read_tail_entry(&file).ok().flatten())
677 .and_then(|last| entry_time(&last))
678 .is_none_or(|sealed_at| sealed_at >= since)
679 })
680 })
681 .collect();
682 if trail.exists() {
683 files.push(trail.to_path_buf());
684 }
685 files
686}
687
688pub fn read_trail_since(
693 trail: &Path,
694 since: Option<chrono::DateTime<chrono::Utc>>,
695) -> std::io::Result<Option<String>> {
696 for _ in 0..SNAPSHOT_ATTEMPTS {
697 let before = segments::sealed(trail);
698 let files = trail_files_since(trail, since);
699 if files.is_empty() {
700 return Ok(None);
701 }
702 let mut text = String::new();
703 for file in files {
704 match fs::read_to_string(&file) {
705 Ok(part) => {
706 text.push_str(&part);
707 if !text.is_empty() && !text.ends_with('\n') {
708 text.push('\n');
709 }
710 }
711 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
712 Err(error) => return Err(error),
713 }
714 }
715 if segments::sealed(trail) == before {
716 return Ok(Some(text));
717 }
718 }
719 Err(std::io::Error::other(
720 "the audit trail kept being sealed while it was read",
721 ))
722}
723
724const SNAPSHOT_ATTEMPTS: usize = 3;
726
727fn entry_time(entry: &serde_json::Value) -> Option<chrono::DateTime<chrono::Utc>> {
728 let stamp = entry.get("timestamp")?.as_str()?;
729 Some(chrono::DateTime::parse_from_rfc3339(stamp).ok()?.to_utc())
730}
731
732fn open_for_listing(path: &Path) -> Option<(fs::File, u64)> {
735 let file = fs::File::open(path).ok()?;
736 let locked = lock_trail_shared(&file);
739 if let Err(error) = &locked {
740 tracing::debug!("audit listing reads unlocked and may end early: {error}");
741 }
742 let len = file.metadata().ok()?.len();
743 if locked.is_ok() {
744 release_read_lock(&file);
745 }
746 Some((file, len))
747}
748
749fn parse_entries(reader: impl BufRead) -> Vec<AuditEntry> {
750 let mut entries = Vec::new();
751 for line in reader.lines().map_while(Result::ok) {
752 entries.extend(
753 serde_json::Deserializer::from_str(&line)
754 .into_iter::<AuditEntry>()
755 .map_while(Result::ok),
756 );
757 }
758 entries
759}
760
761pub fn latest_unacknowledged_drift(agent_id: &str) -> Result<Option<(String, String)>, String> {
766 let (chain, pending) = verify_chain_with_drift(Some(agent_id));
767 if !chain.source_present {
768 return Err("audit trail unavailable".to_string());
769 }
770 if !chain.valid {
771 return Err(format!(
772 "audit chain invalid at entry {}",
773 chain.first_invalid_at.unwrap_or(chain.total_entries)
774 ));
775 }
776 if chain.total_entries == 0 {
777 return Err("audit trail unavailable".to_string());
783 }
784
785 Ok(pending)
786}
787
788fn hashed_fields(value: &serde_json::Value) -> Option<serde_json::Value> {
795 let get = |key: &str| value.get(key).cloned();
796 Some(serde_json::json!({
797 "agent_id": get("agent_id")?,
798 "tool": get("tool")?,
799 "action": value.get("action").cloned().unwrap_or(serde_json::Value::Null),
800 "input_hash": get("input_hash")?,
801 "output_tokens": get("output_tokens")?,
802 "role": get("role")?,
803 "event_type": get("event_type")?,
804 }))
805}
806
807fn invalid_at(total: usize, unknown: usize) -> ChainVerifyResult {
808 ChainVerifyResult {
809 total_entries: total,
810 valid: false,
811 first_invalid_at: Some(total),
812 source_present: true,
813 unknown_event_types: unknown,
814 }
815}
816
817fn unavailable() -> ChainVerifyResult {
818 ChainVerifyResult {
819 total_entries: 0,
820 valid: false,
821 first_invalid_at: Some(0),
822 source_present: false,
823 unknown_event_types: 0,
824 }
825}
826
827fn verify_chain_with_drift(
836 agent_id: Option<&str>,
837) -> (ChainVerifyResult, Option<(String, String)>) {
838 let Some(path) = trail_path() else {
839 return (unavailable(), None);
840 };
841 verify_chain_with_drift_at(&path, agent_id)
842}
843
844fn verify_chain_with_drift_at(
845 path: &Path,
846 agent_id: Option<&str>,
847) -> (ChainVerifyResult, Option<(String, String)>) {
848 verify_chain_mode(path, agent_id, false)
849}
850
851#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
854pub(super) struct ChainState {
855 prev_hash: String,
856 total: usize,
857 unknown: usize,
858 pending: std::collections::BTreeMap<String, (String, String)>,
862 tally: SignedTallyEvidence,
864 security: SignedTallyEvidence,
867 sealed: Option<u64>,
869}
870
871impl ChainState {
872 fn genesis() -> Self {
873 Self {
874 prev_hash: "genesis".to_string(),
875 total: 0,
876 unknown: 0,
877 pending: std::collections::BTreeMap::new(),
878 tally: SignedTallyEvidence::default(),
879 security: SignedTallyEvidence::default(),
880 sealed: None,
881 }
882 }
883
884 fn invalid(&self) -> (ChainVerifyResult, Option<(String, String)>) {
885 (invalid_at(self.total, self.unknown), None)
886 }
887
888 fn valid(&self, agent_id: Option<&str>) -> (ChainVerifyResult, Option<(String, String)>) {
889 (
890 ChainVerifyResult {
891 total_entries: self.total,
892 valid: true,
893 first_invalid_at: None,
894 source_present: true,
895 unknown_event_types: self.unknown,
896 },
897 agent_id.and_then(|id| self.pending.get(id).cloned()),
898 )
899 }
900
901 fn advance(
909 &mut self,
910 value: serde_json::Value,
911 key: &mut Option<Result<ed25519_dalek::VerifyingKey, ()>>,
912 ) -> Result<(), ()> {
913 let (Some(entry_prev), Some(entry_hash)) = (
914 value.get("prev_hash").and_then(serde_json::Value::as_str),
915 value.get("entry_hash").and_then(serde_json::Value::as_str),
916 ) else {
917 return Err(());
918 };
919 let partial = hashed_fields(&value).ok_or(())?;
920 if entry_prev != self.prev_hash {
921 return Err(());
922 }
923 let data_json = serde_json::to_string(&partial).unwrap_or_default();
924 if entry_hash != compute_entry_hash(&self.prev_hash, &data_json) {
925 return Err(());
926 }
927 let entry_hash = entry_hash.to_string();
928 let entry = serde_json::from_value::<AuditEntry>(value).map_err(|_| ())?;
929 let seal = is_seal(&entry.event_type);
930 if matches!(&entry.event_type, AuditEventType::Unknown(_)) && !seal {
931 self.unknown += 1;
932 }
933 self.sealed = if seal {
934 Some(segments::sealed_seq(entry.action.as_deref()).ok_or(())?)
935 } else {
936 None
937 };
938 let signature_required = seal
939 || matches!(
940 &entry.event_type,
941 AuditEventType::AgentDriftDetected | AuditEventType::AgentDriftAcknowledged
942 );
943 if signature_required && entry.signature.is_none() {
944 return Err(());
945 }
946 if let Some(signature) = entry.signature.as_deref() {
947 let signature = crate::core::agent_identity::hex_decode(signature).map_err(|_| ())?;
948 let Ok(public_key) = key
949 .get_or_insert_with(|| {
950 crate::core::agent_identity::load_public_key("lean-ctx").map_err(|_| ())
951 })
952 .as_ref()
953 else {
954 return Err(());
955 };
956 if !crate::core::agent_identity::verify_signature(
957 public_key.as_bytes(),
958 entry.entry_hash.as_bytes(),
959 &signature,
960 ) {
961 return Err(());
962 }
963 }
964 if let Some((kind, n, _)) = entry
965 .action
966 .as_deref()
967 .and_then(crate::core::security_events::parse_action)
968 {
969 self.security.record(kind, n, &entry.entry_hash);
970 if kind.in_signed_tally() {
971 self.tally.record(kind, n, &entry.entry_hash);
972 }
973 }
974 match entry.event_type {
975 AuditEventType::AgentDriftDetected => {
976 if let Some(evidence) = entry.action {
977 self.pending.insert(
978 entry.agent_id,
979 (
980 entry.timestamp,
981 format!("{evidence} observation={}", entry.entry_hash),
982 ),
983 );
984 }
985 }
986 AuditEventType::AgentDriftAcknowledged
987 if self
988 .pending
989 .get(&entry.agent_id)
990 .is_some_and(|(_, evidence)| entry.action.as_ref() == Some(evidence)) =>
991 {
992 self.pending.remove(&entry.agent_id);
993 }
994 _ => {}
995 }
996 self.prev_hash = entry_hash;
997 self.total += 1;
998 Ok(())
999 }
1000
1001 fn verify_stream(&mut self, reader: &mut impl BufRead) -> Result<(u64, bool), ()> {
1005 let mut consumed = 0u64;
1006 let mut ends_on_newline = true;
1007 let mut buf = Vec::new();
1008 let mut key = None;
1009 loop {
1010 buf.clear();
1011 let read = reader.read_until(b'\n', &mut buf).map_err(|_| ())?;
1012 if read == 0 {
1013 return Ok((consumed, ends_on_newline));
1014 }
1015 consumed += read as u64;
1016 ends_on_newline = buf.last() == Some(&b'\n');
1017 if ends_on_newline {
1018 buf.pop();
1019 if buf.last() == Some(&b'\r') {
1020 buf.pop();
1021 }
1022 }
1023 let line = std::str::from_utf8(&buf).map_err(|_| ())?;
1024 let mut saw_value = false;
1025 for parsed in serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>()
1026 {
1027 saw_value = true;
1028 self.advance(parsed.map_err(|_| ())?, &mut key)?;
1029 }
1030 if !saw_value {
1031 return Err(());
1032 }
1033 }
1034 }
1035}
1036
1037fn verify_chain_mode(
1043 path: &Path,
1044 agent_id: Option<&str>,
1045 force_full: bool,
1046) -> (ChainVerifyResult, Option<(String, String)>) {
1047 verify_chain_mode_at(path, agent_id, force_full, chrono::Utc::now().timestamp())
1048}
1049
1050fn verify_chain_mode_at(
1052 path: &Path,
1053 agent_id: Option<&str>,
1054 force_full: bool,
1055 now: i64,
1056) -> (ChainVerifyResult, Option<(String, String)>) {
1057 match verify_state_at(path, force_full, now) {
1058 Verified::Missing => (
1059 ChainVerifyResult {
1060 total_entries: 0,
1061 valid: true,
1062 first_invalid_at: None,
1063 source_present: false,
1064 unknown_event_types: 0,
1065 },
1066 None,
1067 ),
1068 Verified::Unavailable => (unavailable(), None),
1069 Verified::Invalid(state) => state.invalid(),
1070 Verified::Valid(state) => state.valid(agent_id),
1071 }
1072}
1073
1074enum Verified {
1075 Missing,
1077 Unavailable,
1079 Invalid(ChainState),
1081 Valid(ChainState),
1082}
1083
1084fn verify_state_at(path: &Path, force_full: bool, now: i64) -> Verified {
1089 for _ in 0..SNAPSHOT_ATTEMPTS {
1090 let sealed = segments::sealed(path);
1091 let active = match fs::File::open(path) {
1092 Ok(file) => Some(file),
1093 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1094 Err(_) => return Verified::Unavailable,
1095 };
1096 if segments::sealed(path) == sealed {
1097 return verify_snapshot(path, &sealed, active, force_full, now);
1098 }
1099 }
1100 Verified::Unavailable
1101}
1102
1103fn verify_snapshot(
1104 path: &Path,
1105 sealed: &[(u64, PathBuf)],
1106 active: Option<fs::File>,
1107 force_full: bool,
1108 now: i64,
1109) -> Verified {
1110 if sealed.is_empty() && active.is_none() {
1111 return Verified::Missing;
1112 }
1113 let day = usize::try_from(now.div_euclid(24 * 60 * 60)).unwrap_or(0);
1116 let sample = day.checked_rem(sealed.len());
1117 let mut state = ChainState::genesis();
1118 for (index, (seq, segment)) in sealed.iter().enumerate() {
1119 let Ok(file) = fs::File::open(segment) else {
1120 return Verified::Unavailable;
1121 };
1122 let max_age = (Some(index) == sample).then_some(checkpoint::FULL_REVERIFY_SECS);
1123 state = match verify_file(segment, &file, &state, force_full, max_age, now) {
1124 Verified::Valid(state) if state.sealed == Some(*seq) => state,
1126 Verified::Valid(state) => return Verified::Invalid(state),
1127 other => return other,
1128 };
1129 }
1130 match active {
1131 Some(file) => verify_file(
1132 path,
1133 &file,
1134 &state,
1135 force_full,
1136 Some(checkpoint::FULL_REVERIFY_SECS),
1137 now,
1138 ),
1139 None => Verified::Valid(state),
1140 }
1141}
1142
1143fn verify_file(
1147 path: &Path,
1148 file: &fs::File,
1149 start: &ChainState,
1150 force_full: bool,
1151 max_age: Option<i64>,
1152 now: i64,
1153) -> Verified {
1154 use std::io::{Seek, SeekFrom};
1155
1156 let mut cursor = file;
1157 if lock_trail_shared(file).is_err() {
1163 return Verified::Unavailable;
1164 }
1165 let store = checkpoint::Store::for_trail(path);
1166 let stamp = checkpoint::FileStamp::of(file);
1167 let Some(len) = stamp
1168 .as_ref()
1169 .map(|stamp| stamp.size)
1170 .or_else(|| file.metadata().ok().map(|meta| meta.len()))
1171 else {
1172 return Verified::Unavailable;
1173 };
1174 release_read_lock(file);
1175
1176 if !force_full {
1177 if let (Some(store), Some(stamp)) = (&store, &stamp) {
1178 if let Some(resumed) = store.resume(file, stamp, start, max_age, now) {
1179 let mut state = resumed.state;
1180 if resumed.offset == stamp.size {
1181 return Verified::Valid(state);
1182 }
1183 if cursor.seek(SeekFrom::Start(resumed.offset)).is_ok() {
1184 let suffix = state.verify_stream(&mut std::io::BufReader::new(
1185 file.take(len - resumed.offset),
1186 ));
1187 if !still_holds_prefix(file, len) {
1188 return Verified::Unavailable;
1189 }
1190 if let Ok((read, clean)) = suffix {
1191 if clean && resumed.offset + read == stamp.size {
1192 store.save(file, stamp, start, &state, resumed.full_verified_at);
1193 }
1194 return Verified::Valid(state);
1195 }
1196 }
1197 }
1200 }
1201 }
1202
1203 if cursor.seek(SeekFrom::Start(0)).is_err() {
1204 return Verified::Unavailable;
1205 }
1206 let mut state = start.clone();
1207 let pass = state.verify_stream(&mut std::io::BufReader::new(file.take(len)));
1208 if !still_holds_prefix(file, len) {
1210 return Verified::Unavailable;
1211 }
1212 let Ok((read, clean)) = pass else {
1213 if let Some(store) = &store {
1214 store.discard();
1215 }
1216 return Verified::Invalid(state);
1217 };
1218 if let (Some(store), Some(stamp)) = (&store, &stamp) {
1219 if clean && read == stamp.size {
1220 store.save(file, stamp, start, &state, now);
1221 }
1222 }
1223 Verified::Valid(state)
1224}
1225
1226#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1230pub struct SignedTallyEvidence {
1231 pub counts: crate::core::security_events::SecurityCounts,
1232 pub entries: u64,
1233 pub first_hash: Option<String>,
1234 pub last_hash: Option<String>,
1235}
1236
1237impl SignedTallyEvidence {
1238 fn record(&mut self, kind: crate::core::security_events::SecurityKind, n: u64, hash: &str) {
1239 self.counts.add(kind, n);
1240 self.entries += 1;
1241 self.first_hash.get_or_insert_with(|| hash.to_string());
1242 self.last_hash = Some(hash.to_string());
1243 }
1244}
1245
1246pub fn verified_signed_tally_at(path: &Path) -> Option<SignedTallyEvidence> {
1250 match verify_state_at(path, false, chrono::Utc::now().timestamp()) {
1251 Verified::Missing => Some(SignedTallyEvidence::default()),
1252 Verified::Valid(state) => Some(state.tally),
1253 Verified::Unavailable | Verified::Invalid(_) => None,
1254 }
1255}
1256
1257pub fn verified_security_at(path: &Path) -> (ChainVerifyResult, Option<SignedTallyEvidence>) {
1263 match verify_state_at(path, false, chrono::Utc::now().timestamp()) {
1264 Verified::Missing => (
1265 ChainVerifyResult {
1266 total_entries: 0,
1267 valid: true,
1268 first_invalid_at: None,
1269 source_present: false,
1270 unknown_event_types: 0,
1271 },
1272 Some(SignedTallyEvidence::default()),
1273 ),
1274 Verified::Unavailable => (unavailable(), None),
1275 Verified::Invalid(state) => (state.invalid().0, None),
1276 Verified::Valid(state) => (state.valid(None).0, Some(state.security)),
1277 }
1278}
1279
1280pub fn verify_chain() -> ChainVerifyResult {
1283 verify_chain_with_drift(None).0
1284}
1285
1286pub fn verify_chain_full() -> ChainVerifyResult {
1289 let Some(path) = trail_path() else {
1290 return unavailable();
1291 };
1292 verify_chain_mode(&path, None, true).0
1293}
1294
1295pub fn verify_chain_at(path: &Path) -> ChainVerifyResult {
1298 verify_chain_with_drift_at(path, None).0
1299}
1300
1301pub fn hash_input(args: &serde_json::Map<String, serde_json::Value>) -> String {
1302 let serialized = serde_json::to_string(args).unwrap_or_default();
1303 let mut hasher = Sha256::new();
1304 hasher.update(serialized.as_bytes());
1305 crate::core::agent_identity::hex_encode(&hasher.finalize())
1306}
1307
1308#[cfg(test)]
1309#[path = "audit_trail_tests.rs"]
1310mod tests;