use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::{self, OpenOptions};
use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
mod checkpoint;
mod segments;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: String,
pub agent_id: String,
pub tool: String,
pub action: Option<String>,
pub input_hash: String,
pub output_tokens: u32,
pub role: String,
pub event_type: AuditEventType,
pub prev_hash: String,
pub entry_hash: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub signature: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuditEventType {
ToolCall,
ToolDenied,
PathJailViolation,
BudgetExceeded,
CrossProjectAccess,
RateLimited,
SecurityViolation,
RoleChanged,
SecretDetected,
AgentRegistered,
AgentSuspended,
AgentResumed,
AgentDecommissioned,
AgentDriftDetected,
AgentDriftAcknowledged,
Unknown(String),
}
impl AuditEventType {
fn wire_label(&self) -> &str {
match self {
Self::ToolCall => "tool_call",
Self::ToolDenied => "tool_denied",
Self::PathJailViolation => "path_jail_violation",
Self::BudgetExceeded => "budget_exceeded",
Self::CrossProjectAccess => "cross_project_access",
Self::RateLimited => "rate_limited",
Self::SecurityViolation => "security_violation",
Self::RoleChanged => "role_changed",
Self::SecretDetected => "secret_detected",
Self::AgentRegistered => "agent_registered",
Self::AgentSuspended => "agent_suspended",
Self::AgentResumed => "agent_resumed",
Self::AgentDecommissioned => "agent_decommissioned",
Self::AgentDriftDetected => "agent_drift_detected",
Self::AgentDriftAcknowledged => "agent_drift_acknowledged",
Self::Unknown(label) => label,
}
}
}
impl Serialize for AuditEventType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if let Self::Unknown(label) = self {
if !valid_unknown_event_label_for_write(label) {
return Err(serde::ser::Error::custom(format!(
"unknown event_type is not a safe additive label: {label}"
)));
}
}
serializer.serialize_str(self.wire_label())
}
}
impl<'de> Deserialize<'de> for AuditEventType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let label = String::deserialize(deserializer)?;
let event_type = match label.as_str() {
"tool_call" => Self::ToolCall,
"tool_denied" => Self::ToolDenied,
"path_jail_violation" => Self::PathJailViolation,
"budget_exceeded" => Self::BudgetExceeded,
"cross_project_access" => Self::CrossProjectAccess,
"rate_limited" => Self::RateLimited,
"security_violation" => Self::SecurityViolation,
"role_changed" => Self::RoleChanged,
"secret_detected" => Self::SecretDetected,
"agent_registered" => Self::AgentRegistered,
"agent_suspended" => Self::AgentSuspended,
"agent_resumed" => Self::AgentResumed,
"agent_decommissioned" => Self::AgentDecommissioned,
"agent_drift_detected" => Self::AgentDriftDetected,
"agent_drift_acknowledged" => Self::AgentDriftAcknowledged,
_ if valid_extension_event_label(&label) => Self::Unknown(label),
_ => {
return Err(serde::de::Error::custom(
"unknown event_type must use the x- extension namespace",
));
}
};
Ok(event_type)
}
}
fn valid_extension_event_label(label: &str) -> bool {
const MAX_EVENT_LABEL_BYTES: usize = 64;
if label.len() > MAX_EVENT_LABEL_BYTES {
return false;
}
let Some(rest) = label.strip_prefix("x-") else {
return false;
};
let mut chars = rest.chars();
chars.next().is_some_and(|c| c.is_ascii_lowercase())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn valid_unknown_event_label_for_write(label: &str) -> bool {
valid_extension_event_label(label)
}
pub struct AuditEntryData {
pub agent_id: String,
pub tool: String,
pub action: Option<String>,
pub input_hash: String,
pub output_tokens: u32,
pub role: String,
pub event_type: AuditEventType,
}
pub struct ChainVerifyResult {
pub total_entries: usize,
pub valid: bool,
pub first_invalid_at: Option<usize>,
pub source_present: bool,
pub unknown_event_types: usize,
}
fn trail_path() -> Option<PathBuf> {
let dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
let audit_dir = dir.join("audit");
fs::create_dir_all(&audit_dir).ok()?;
Some(audit_dir.join("trail.jsonl"))
}
fn read_last_hash_tail(file: &fs::File) -> Result<String, String> {
Ok(read_tail_entry(file)?
.and_then(|entry| entry.get("entry_hash")?.as_str().map(str::to_string))
.unwrap_or_else(|| "genesis".to_string()))
}
fn read_tail_entry(file: &fs::File) -> Result<Option<serde_json::Value>, String> {
use std::io::{Read, Seek, SeekFrom};
const TAIL: i64 = 64 * 1024;
let mut f = file;
let len = f
.seek(SeekFrom::End(0))
.map_err(|e| format!("audit tail seek: {e}"))?;
if len == 0 {
return Ok(None);
}
let start = if (len as i64) > TAIL {
-TAIL
} else {
-(len as i64)
};
f.seek(SeekFrom::End(start))
.map_err(|e| format!("audit tail seek: {e}"))?;
let mut buf = String::new();
f.read_to_string(&mut buf)
.map_err(|e| format!("audit tail read: {e}"))?;
for line in buf.lines().rev() {
if line.trim().is_empty() {
continue;
}
let mut last: Option<serde_json::Value> = None;
for parsed in serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>() {
let v = parsed.map_err(|e| format!("audit tail parse: {e}"))?;
if v.get("entry_hash").and_then(|h| h.as_str()).is_some() {
last = Some(v);
}
}
if last.is_some() {
return Ok(last);
}
}
Err("audit tail: no readable chain head".to_string())
}
fn compute_entry_hash(prev_hash: &str, data_json: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(prev_hash.as_bytes());
hasher.update(data_json.as_bytes());
crate::core::agent_identity::hex_encode(&hasher.finalize())
}
pub fn record(data: AuditEntryData) {
let _ = try_record(data);
}
const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(750);
const LOCK_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
fn lock_trail(file: &fs::File) -> Result<(), String> {
lock_trail_with(file, fs2::FileExt::try_lock_exclusive)
}
fn lock_trail_shared(file: &fs::File) -> Result<(), String> {
lock_trail_with(file, fs2::FileExt::try_lock_shared)
}
fn release_read_lock(file: &fs::File) {
#[cfg(not(windows))]
let _ = fs2::FileExt::unlock(file);
#[cfg(windows)]
let _ = file;
}
fn still_holds_prefix(file: &fs::File, len: u64) -> bool {
file.metadata().is_ok_and(|meta| meta.len() >= len)
}
fn lock_trail_with(
file: &fs::File,
try_lock: fn(&fs::File) -> std::io::Result<()>,
) -> Result<(), String> {
use std::time::Instant;
let deadline = Instant::now() + LOCK_TIMEOUT;
loop {
match try_lock(file) {
Ok(()) => return Ok(()),
Err(error) if super::file_lock::is_contended(&error) => {
if Instant::now() >= deadline {
return Err(format!(
"audit trail lock timed out after {}ms; another append is still active",
LOCK_TIMEOUT.as_millis()
));
}
std::thread::sleep(LOCK_RETRY_INTERVAL);
}
Err(error) => return Err(format!("audit trail lock: {error}")),
}
}
}
pub fn try_record(data: AuditEntryData) -> Result<AuditEntry, String> {
let path = trail_path().ok_or_else(|| "audit trail unavailable".to_string())?;
try_record_at(&path, data)
}
pub fn record_at(path: &Path, data: AuditEntryData) {
let _ = try_record_at(path, data);
}
fn try_record_at(path: &Path, data: AuditEntryData) -> Result<AuditEntry, String> {
try_record_at_time(path, data, chrono::Utc::now())
}
fn try_record_at_time(
path: &Path,
data: AuditEntryData,
now: chrono::DateTime<chrono::Utc>,
) -> Result<AuditEntry, String> {
let mut data = data;
for _ in 0..SNAPSHOT_ATTEMPTS {
let mut file = open_active_locked(path)?;
let appended = append_or_seal(path, &mut file, data, now);
let _ = fs2::FileExt::unlock(&file);
match appended? {
Appended::Done(entry) => return Ok(entry),
Appended::Moved(back) => data = back,
}
}
Err("audit trail: the active file kept moving".to_string())
}
enum Appended {
Done(AuditEntry),
Moved(AuditEntryData),
}
fn open_active_locked(path: &Path) -> Result<fs::File, String> {
for _ in 0..SNAPSHOT_ATTEMPTS {
let file = OpenOptions::new()
.create(true)
.append(true)
.read(true)
.open(path)
.map_err(|e| format!("audit trail open: {e}"))?;
lock_trail(&file)?;
if is_current(path, &file) {
return Ok(file);
}
let _ = fs2::FileExt::unlock(&file);
}
Err("audit trail: the active file kept moving".to_string())
}
fn is_current(path: &Path, file: &fs::File) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match (fs::metadata(path), file.metadata()) {
(Ok(named), Ok(open)) => (named.dev(), named.ino()) == (open.dev(), open.ino()),
_ => false,
}
}
#[cfg(not(unix))]
{
let _ = (path, file);
true
}
}
fn append_or_seal(
path: &Path,
file: &mut fs::File,
data: AuditEntryData,
now: chrono::DateTime<chrono::Utc>,
) -> Result<Appended, String> {
let Some(last) = read_tail_entry(file)? else {
let prev_hash = match segments::sealed(path).last() {
Some((_, segment)) => fs::File::open(segment)
.map_err(|e| format!("audit segment open: {e}"))
.and_then(|segment| read_last_hash_tail(&segment))?,
None => "genesis".to_string(),
};
return append_locked(file, data, prev_hash).map(Appended::Done);
};
let last_hash = last
.get("entry_hash")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let next_seq = segments::sealed(path).last().map_or(0, |(seq, _)| seq + 1);
let sealed_seq = if last.get("event_type").and_then(serde_json::Value::as_str)
== Some(segments::SEAL_EVENT)
{
segments::sealed_seq(last.get("action").and_then(serde_json::Value::as_str))
} else {
None
};
let len = file
.metadata()
.map_err(|e| format!("audit stat: {e}"))?
.len();
let last_at = last.get("timestamp").and_then(serde_json::Value::as_str);
let seq = match sealed_seq {
Some(seq) if seq == next_seq => seq,
Some(seq) if seq < next_seq => return Ok(Appended::Moved(data)),
_ if segments::due(len, last_at, now)
&& !segments::backing_off()
&& verified_once(path, file) =>
{
append_locked(file, seal_data(next_seq), last_hash.clone())?;
next_seq
}
_ => return append_locked(file, data, last_hash).map(Appended::Done),
};
match segments::retire(path, seq) {
Ok(()) => Ok(Appended::Moved(data)),
Err(error) => {
tracing::warn!("audit trail not sealed: {error}");
segments::back_off();
let prev_hash = read_last_hash_tail(file)?;
append_locked(file, data, prev_hash).map(Appended::Done)
}
}
}
fn verified_once(path: &Path, file: &fs::File) -> bool {
let Some(store) = checkpoint::Store::for_trail(path) else {
return false;
};
checkpoint::FileStamp::of(file).is_some_and(|stamp| store.holds_prefix_of(file, &stamp))
}
fn seal_data(seq: u64) -> AuditEntryData {
AuditEntryData {
agent_id: "lean-ctx".to_string(),
tool: "audit_trail".to_string(),
action: Some(segments::seal_action(seq)),
input_hash: String::new(),
output_tokens: 0,
role: "system".to_string(),
event_type: AuditEventType::Unknown(segments::SEAL_EVENT.to_string()),
}
}
pub fn is_seal(event_type: &AuditEventType) -> bool {
matches!(event_type, AuditEventType::Unknown(label) if label == segments::SEAL_EVENT)
}
fn append_locked(
file: &mut fs::File,
data: AuditEntryData,
prev_hash: String,
) -> Result<AuditEntry, String> {
use std::io::{Seek, SeekFrom};
let authenticated_drift = matches!(
&data.event_type,
AuditEventType::AgentDriftDetected | AuditEventType::AgentDriftAcknowledged
) || is_seal(&data.event_type);
let partial = serde_json::json!({
"agent_id": data.agent_id,
"tool": data.tool,
"action": data.action,
"input_hash": data.input_hash,
"output_tokens": data.output_tokens,
"role": data.role,
"event_type": data.event_type,
});
let data_json = serde_json::to_string(&partial).map_err(|e| format!("audit serialize: {e}"))?;
let entry_hash = compute_entry_hash(&prev_hash, &data_json);
let signature_result =
crate::core::agent_identity::sign_bytes("lean-ctx", entry_hash.as_bytes())
.map(|sig| crate::core::agent_identity::hex_encode(&sig));
let signature = if authenticated_drift {
Some(signature_result.map_err(|e| format!("audit signature: {e}"))?)
} else {
signature_result.ok()
};
let entry = AuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
agent_id: data.agent_id,
tool: data.tool,
action: data.action,
input_hash: data.input_hash,
output_tokens: data.output_tokens,
role: data.role,
event_type: data.event_type,
prev_hash,
entry_hash,
signature,
};
let mut line = serde_json::to_string(&entry).map_err(|e| format!("audit serialize: {e}"))?;
line.push('\n');
let rollback_len = file
.seek(SeekFrom::End(0))
.map_err(|e| format!("audit seek: {e}"))?;
if let Err(e) = file
.write_all(line.as_bytes())
.and_then(|()| file.flush())
.and_then(|()| file.sync_data())
{
let _ = file.set_len(rollback_len);
return Err(format!("audit append: {e}"));
}
Ok(entry)
}
pub fn default_trail_path() -> Option<PathBuf> {
trail_path()
}
pub fn load_recent(limit: usize) -> Vec<AuditEntry> {
let Some(path) = trail_path() else {
return Vec::new();
};
load_recent_at(&path, limit)
}
const TAIL_BLOCK: u64 = 256 * 1024;
pub fn load_recent_at(path: &Path, limit: usize) -> Vec<AuditEntry> {
let mut entries: Vec<AuditEntry> = Vec::new();
for file in segments::chain(path).iter().rev() {
if entries.len() >= limit {
break;
}
let mut older = load_recent_in(file, limit - entries.len());
older.append(&mut entries);
entries = older;
}
entries
}
fn load_recent_in(path: &Path, limit: usize) -> Vec<AuditEntry> {
use std::io::{Seek, SeekFrom};
if limit == 0 {
return Vec::new();
}
let Some((file, len)) = open_for_listing(path) else {
return Vec::new();
};
let mut start = len;
let mut blocks: Vec<Vec<u8>> = Vec::new();
let mut newlines = 0usize;
while start > 0 && newlines <= limit {
let from = start.saturating_sub(TAIL_BLOCK);
let mut block = Vec::new();
let read = (&file)
.seek(SeekFrom::Start(from))
.and_then(|_| (&file).take(start - from).read_to_end(&mut block));
if read.is_err() {
break;
}
newlines += bytecount::count(&block, b'\n');
blocks.push(block);
start = from;
}
let mut tail: Vec<u8> = blocks.into_iter().rev().flatten().collect();
if start > 0 {
let cut = tail
.iter()
.position(|&byte| byte == b'\n')
.map_or(tail.len(), |at| at + 1);
tail.drain(..cut);
}
let entries = parse_entries(std::io::BufReader::new(tail.as_slice()));
let skip = entries.len().saturating_sub(limit);
entries.into_iter().skip(skip).collect()
}
pub fn load_all_at(path: &Path) -> Vec<AuditEntry> {
let mut entries = Vec::new();
for file in segments::chain(path) {
if let Some((file, len)) = open_for_listing(&file) {
entries.extend(parse_entries(std::io::BufReader::new(file.take(len))));
}
}
entries
}
pub fn trail_files_since(
trail: &Path,
since: Option<chrono::DateTime<chrono::Utc>>,
) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = segments::sealed(trail)
.into_iter()
.map(|(_, path)| path)
.filter(|path| {
since.is_none_or(|since| {
fs::File::open(path)
.ok()
.and_then(|file| read_tail_entry(&file).ok().flatten())
.and_then(|last| entry_time(&last))
.is_none_or(|sealed_at| sealed_at >= since)
})
})
.collect();
if trail.exists() {
files.push(trail.to_path_buf());
}
files
}
pub fn read_trail_since(
trail: &Path,
since: Option<chrono::DateTime<chrono::Utc>>,
) -> std::io::Result<Option<String>> {
for _ in 0..SNAPSHOT_ATTEMPTS {
let before = segments::sealed(trail);
let files = trail_files_since(trail, since);
if files.is_empty() {
return Ok(None);
}
let mut text = String::new();
for file in files {
match fs::read_to_string(&file) {
Ok(part) => {
text.push_str(&part);
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
if segments::sealed(trail) == before {
return Ok(Some(text));
}
}
Err(std::io::Error::other(
"the audit trail kept being sealed while it was read",
))
}
const SNAPSHOT_ATTEMPTS: usize = 3;
fn entry_time(entry: &serde_json::Value) -> Option<chrono::DateTime<chrono::Utc>> {
let stamp = entry.get("timestamp")?.as_str()?;
Some(chrono::DateTime::parse_from_rfc3339(stamp).ok()?.to_utc())
}
fn open_for_listing(path: &Path) -> Option<(fs::File, u64)> {
let file = fs::File::open(path).ok()?;
let locked = lock_trail_shared(&file);
if let Err(error) = &locked {
tracing::debug!("audit listing reads unlocked and may end early: {error}");
}
let len = file.metadata().ok()?.len();
if locked.is_ok() {
release_read_lock(&file);
}
Some((file, len))
}
fn parse_entries(reader: impl BufRead) -> Vec<AuditEntry> {
let mut entries = Vec::new();
for line in reader.lines().map_while(Result::ok) {
entries.extend(
serde_json::Deserializer::from_str(&line)
.into_iter::<AuditEntry>()
.map_while(Result::ok),
);
}
entries
}
pub fn latest_unacknowledged_drift(agent_id: &str) -> Result<Option<(String, String)>, String> {
let (chain, pending) = verify_chain_with_drift(Some(agent_id));
if !chain.source_present {
return Err("audit trail unavailable".to_string());
}
if !chain.valid {
return Err(format!(
"audit chain invalid at entry {}",
chain.first_invalid_at.unwrap_or(chain.total_entries)
));
}
if chain.total_entries == 0 {
return Err("audit trail unavailable".to_string());
}
Ok(pending)
}
fn hashed_fields(value: &serde_json::Value) -> Option<serde_json::Value> {
let get = |key: &str| value.get(key).cloned();
Some(serde_json::json!({
"agent_id": get("agent_id")?,
"tool": get("tool")?,
"action": value.get("action").cloned().unwrap_or(serde_json::Value::Null),
"input_hash": get("input_hash")?,
"output_tokens": get("output_tokens")?,
"role": get("role")?,
"event_type": get("event_type")?,
}))
}
fn invalid_at(total: usize, unknown: usize) -> ChainVerifyResult {
ChainVerifyResult {
total_entries: total,
valid: false,
first_invalid_at: Some(total),
source_present: true,
unknown_event_types: unknown,
}
}
fn unavailable() -> ChainVerifyResult {
ChainVerifyResult {
total_entries: 0,
valid: false,
first_invalid_at: Some(0),
source_present: false,
unknown_event_types: 0,
}
}
fn verify_chain_with_drift(
agent_id: Option<&str>,
) -> (ChainVerifyResult, Option<(String, String)>) {
let Some(path) = trail_path() else {
return (unavailable(), None);
};
verify_chain_with_drift_at(&path, agent_id)
}
fn verify_chain_with_drift_at(
path: &Path,
agent_id: Option<&str>,
) -> (ChainVerifyResult, Option<(String, String)>) {
verify_chain_mode(path, agent_id, false)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct ChainState {
prev_hash: String,
total: usize,
unknown: usize,
pending: std::collections::BTreeMap<String, (String, String)>,
tally: SignedTallyEvidence,
security: SignedTallyEvidence,
sealed: Option<u64>,
}
impl ChainState {
fn genesis() -> Self {
Self {
prev_hash: "genesis".to_string(),
total: 0,
unknown: 0,
pending: std::collections::BTreeMap::new(),
tally: SignedTallyEvidence::default(),
security: SignedTallyEvidence::default(),
sealed: None,
}
}
fn invalid(&self) -> (ChainVerifyResult, Option<(String, String)>) {
(invalid_at(self.total, self.unknown), None)
}
fn valid(&self, agent_id: Option<&str>) -> (ChainVerifyResult, Option<(String, String)>) {
(
ChainVerifyResult {
total_entries: self.total,
valid: true,
first_invalid_at: None,
source_present: true,
unknown_event_types: self.unknown,
},
agent_id.and_then(|id| self.pending.get(id).cloned()),
)
}
fn advance(
&mut self,
value: serde_json::Value,
key: &mut Option<Result<ed25519_dalek::VerifyingKey, ()>>,
) -> Result<(), ()> {
let (Some(entry_prev), Some(entry_hash)) = (
value.get("prev_hash").and_then(serde_json::Value::as_str),
value.get("entry_hash").and_then(serde_json::Value::as_str),
) else {
return Err(());
};
let partial = hashed_fields(&value).ok_or(())?;
if entry_prev != self.prev_hash {
return Err(());
}
let data_json = serde_json::to_string(&partial).unwrap_or_default();
if entry_hash != compute_entry_hash(&self.prev_hash, &data_json) {
return Err(());
}
let entry_hash = entry_hash.to_string();
let entry = serde_json::from_value::<AuditEntry>(value).map_err(|_| ())?;
let seal = is_seal(&entry.event_type);
if matches!(&entry.event_type, AuditEventType::Unknown(_)) && !seal {
self.unknown += 1;
}
self.sealed = if seal {
Some(segments::sealed_seq(entry.action.as_deref()).ok_or(())?)
} else {
None
};
let signature_required = seal
|| matches!(
&entry.event_type,
AuditEventType::AgentDriftDetected | AuditEventType::AgentDriftAcknowledged
);
if signature_required && entry.signature.is_none() {
return Err(());
}
if let Some(signature) = entry.signature.as_deref() {
let signature = crate::core::agent_identity::hex_decode(signature).map_err(|_| ())?;
let Ok(public_key) = key
.get_or_insert_with(|| {
crate::core::agent_identity::load_public_key("lean-ctx").map_err(|_| ())
})
.as_ref()
else {
return Err(());
};
if !crate::core::agent_identity::verify_signature(
public_key.as_bytes(),
entry.entry_hash.as_bytes(),
&signature,
) {
return Err(());
}
}
if let Some((kind, n, _)) = entry
.action
.as_deref()
.and_then(crate::core::security_events::parse_action)
{
self.security.record(kind, n, &entry.entry_hash);
if kind.in_signed_tally() {
self.tally.record(kind, n, &entry.entry_hash);
}
}
match entry.event_type {
AuditEventType::AgentDriftDetected => {
if let Some(evidence) = entry.action {
self.pending.insert(
entry.agent_id,
(
entry.timestamp,
format!("{evidence} observation={}", entry.entry_hash),
),
);
}
}
AuditEventType::AgentDriftAcknowledged
if self
.pending
.get(&entry.agent_id)
.is_some_and(|(_, evidence)| entry.action.as_ref() == Some(evidence)) =>
{
self.pending.remove(&entry.agent_id);
}
_ => {}
}
self.prev_hash = entry_hash;
self.total += 1;
Ok(())
}
fn verify_stream(&mut self, reader: &mut impl BufRead) -> Result<(u64, bool), ()> {
let mut consumed = 0u64;
let mut ends_on_newline = true;
let mut buf = Vec::new();
let mut key = None;
loop {
buf.clear();
let read = reader.read_until(b'\n', &mut buf).map_err(|_| ())?;
if read == 0 {
return Ok((consumed, ends_on_newline));
}
consumed += read as u64;
ends_on_newline = buf.last() == Some(&b'\n');
if ends_on_newline {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
}
let line = std::str::from_utf8(&buf).map_err(|_| ())?;
let mut saw_value = false;
for parsed in serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>()
{
saw_value = true;
self.advance(parsed.map_err(|_| ())?, &mut key)?;
}
if !saw_value {
return Err(());
}
}
}
}
fn verify_chain_mode(
path: &Path,
agent_id: Option<&str>,
force_full: bool,
) -> (ChainVerifyResult, Option<(String, String)>) {
verify_chain_mode_at(path, agent_id, force_full, chrono::Utc::now().timestamp())
}
fn verify_chain_mode_at(
path: &Path,
agent_id: Option<&str>,
force_full: bool,
now: i64,
) -> (ChainVerifyResult, Option<(String, String)>) {
match verify_state_at(path, force_full, now) {
Verified::Missing => (
ChainVerifyResult {
total_entries: 0,
valid: true,
first_invalid_at: None,
source_present: false,
unknown_event_types: 0,
},
None,
),
Verified::Unavailable => (unavailable(), None),
Verified::Invalid(state) => state.invalid(),
Verified::Valid(state) => state.valid(agent_id),
}
}
enum Verified {
Missing,
Unavailable,
Invalid(ChainState),
Valid(ChainState),
}
fn verify_state_at(path: &Path, force_full: bool, now: i64) -> Verified {
for _ in 0..SNAPSHOT_ATTEMPTS {
let sealed = segments::sealed(path);
let active = match fs::File::open(path) {
Ok(file) => Some(file),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => return Verified::Unavailable,
};
if segments::sealed(path) == sealed {
return verify_snapshot(path, &sealed, active, force_full, now);
}
}
Verified::Unavailable
}
fn verify_snapshot(
path: &Path,
sealed: &[(u64, PathBuf)],
active: Option<fs::File>,
force_full: bool,
now: i64,
) -> Verified {
if sealed.is_empty() && active.is_none() {
return Verified::Missing;
}
let day = usize::try_from(now.div_euclid(24 * 60 * 60)).unwrap_or(0);
let sample = day.checked_rem(sealed.len());
let mut state = ChainState::genesis();
for (index, (seq, segment)) in sealed.iter().enumerate() {
let Ok(file) = fs::File::open(segment) else {
return Verified::Unavailable;
};
let max_age = (Some(index) == sample).then_some(checkpoint::FULL_REVERIFY_SECS);
state = match verify_file(segment, &file, &state, force_full, max_age, now) {
Verified::Valid(state) if state.sealed == Some(*seq) => state,
Verified::Valid(state) => return Verified::Invalid(state),
other => return other,
};
}
match active {
Some(file) => verify_file(
path,
&file,
&state,
force_full,
Some(checkpoint::FULL_REVERIFY_SECS),
now,
),
None => Verified::Valid(state),
}
}
fn verify_file(
path: &Path,
file: &fs::File,
start: &ChainState,
force_full: bool,
max_age: Option<i64>,
now: i64,
) -> Verified {
use std::io::{Seek, SeekFrom};
let mut cursor = file;
if lock_trail_shared(file).is_err() {
return Verified::Unavailable;
}
let store = checkpoint::Store::for_trail(path);
let stamp = checkpoint::FileStamp::of(file);
let Some(len) = stamp
.as_ref()
.map(|stamp| stamp.size)
.or_else(|| file.metadata().ok().map(|meta| meta.len()))
else {
return Verified::Unavailable;
};
release_read_lock(file);
if !force_full {
if let (Some(store), Some(stamp)) = (&store, &stamp) {
if let Some(resumed) = store.resume(file, stamp, start, max_age, now) {
let mut state = resumed.state;
if resumed.offset == stamp.size {
return Verified::Valid(state);
}
if cursor.seek(SeekFrom::Start(resumed.offset)).is_ok() {
let suffix = state.verify_stream(&mut std::io::BufReader::new(
file.take(len - resumed.offset),
));
if !still_holds_prefix(file, len) {
return Verified::Unavailable;
}
if let Ok((read, clean)) = suffix {
if clean && resumed.offset + read == stamp.size {
store.save(file, stamp, start, &state, resumed.full_verified_at);
}
return Verified::Valid(state);
}
}
}
}
}
if cursor.seek(SeekFrom::Start(0)).is_err() {
return Verified::Unavailable;
}
let mut state = start.clone();
let pass = state.verify_stream(&mut std::io::BufReader::new(file.take(len)));
if !still_holds_prefix(file, len) {
return Verified::Unavailable;
}
let Ok((read, clean)) = pass else {
if let Some(store) = &store {
store.discard();
}
return Verified::Invalid(state);
};
if let (Some(store), Some(stamp)) = (&store, &stamp) {
if clean && read == stamp.size {
store.save(file, stamp, start, &state, now);
}
}
Verified::Valid(state)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedTallyEvidence {
pub counts: crate::core::security_events::SecurityCounts,
pub entries: u64,
pub first_hash: Option<String>,
pub last_hash: Option<String>,
}
impl SignedTallyEvidence {
fn record(&mut self, kind: crate::core::security_events::SecurityKind, n: u64, hash: &str) {
self.counts.add(kind, n);
self.entries += 1;
self.first_hash.get_or_insert_with(|| hash.to_string());
self.last_hash = Some(hash.to_string());
}
}
pub fn verified_signed_tally_at(path: &Path) -> Option<SignedTallyEvidence> {
match verify_state_at(path, false, chrono::Utc::now().timestamp()) {
Verified::Missing => Some(SignedTallyEvidence::default()),
Verified::Valid(state) => Some(state.tally),
Verified::Unavailable | Verified::Invalid(_) => None,
}
}
pub fn verified_security_at(path: &Path) -> (ChainVerifyResult, Option<SignedTallyEvidence>) {
match verify_state_at(path, false, chrono::Utc::now().timestamp()) {
Verified::Missing => (
ChainVerifyResult {
total_entries: 0,
valid: true,
first_invalid_at: None,
source_present: false,
unknown_event_types: 0,
},
Some(SignedTallyEvidence::default()),
),
Verified::Unavailable => (unavailable(), None),
Verified::Invalid(state) => (state.invalid().0, None),
Verified::Valid(state) => (state.valid(None).0, Some(state.security)),
}
}
pub fn verify_chain() -> ChainVerifyResult {
verify_chain_with_drift(None).0
}
pub fn verify_chain_full() -> ChainVerifyResult {
let Some(path) = trail_path() else {
return unavailable();
};
verify_chain_mode(&path, None, true).0
}
pub fn verify_chain_at(path: &Path) -> ChainVerifyResult {
verify_chain_with_drift_at(path, None).0
}
pub fn hash_input(args: &serde_json::Map<String, serde_json::Value>) -> String {
let serialized = serde_json::to_string(args).unwrap_or_default();
let mut hasher = Sha256::new();
hasher.update(serialized.as_bytes());
crate::core::agent_identity::hex_encode(&hasher.finalize())
}
#[cfg(test)]
#[path = "audit_trail_tests.rs"]
mod tests;