use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::db::Store;
pub const SCHEMA_VERSION: u8 = 2;
pub const HASH_ALGORITHM: &str = "sha256";
const SEQ_KEY: &str = "enforcement:seq";
pub const INSTALLATION_ID_KEY: &str = "system:installation_id";
pub const EVENT_PREFIX: &str = "enforcement:event:";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnforcementEvent {
pub event_id: String,
pub schema_version: u8,
pub seq_no: u64,
pub recorded_at_ms: u64,
pub event_type: EnforcementEventType,
pub event_hash: String,
pub prev_hash: String,
pub installation_id: String,
pub actor_local: Option<ActorLocal>,
pub agent_type: String,
pub subject_kind: SubjectKind,
pub subject_key: String,
pub canonical_subject_hash: Option<String>,
pub receipt_id: Option<String>,
pub decision_reason_code: String,
pub decision_basis_hash: Option<String>,
pub agent_session: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActorLocal {
pub username: String,
pub uid: Option<u32>,
pub verified: bool, }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SubjectKind {
File,
Control,
Config,
System,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EnforcementEventType {
Deny,
AllowAfterReceipt,
ReceiptMinted,
BypassDetected,
ControlChanged {
change_kind: ControlChangeKind,
},
EnforcementConfigChanged {
setting: String,
old_value: String,
new_value: String,
},
RecordingGap {
gap_start_ms: u64,
gap_end_ms: u64,
cause: GapCause,
enforcement_mode_during_gap: EnforcementMode,
missed_event_count: MissedEventCount,
certainty: GapCertainty,
},
RetentionPruned {
pruned_count: u64,
oldest_pruned_seq: u64,
newest_pruned_seq: u64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ControlChangeKind {
Created,
Confirmed,
Updated,
Deleted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GapCause {
DaemonUnreachable,
StoreWriteFailure,
StoreLocked,
CorruptionRecovery,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnforcementMode {
Advisory,
Strict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MissedEventCount {
Known(u64),
Zero,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GapCertainty {
Exact,
Inferred,
}
#[derive(Serialize)]
struct CanonicalEvent<'a> {
event_id: &'a str,
schema_version: u8,
seq_no: u64,
recorded_at_ms: u64,
event_type: &'a EnforcementEventType,
prev_hash: &'a str,
installation_id: &'a str,
actor_local: &'a Option<ActorLocal>,
agent_type: &'a str,
subject_kind: SubjectKind,
subject_key: &'a str,
canonical_subject_hash: Option<&'a str>,
receipt_id: Option<&'a str>,
decision_reason_code: &'a str,
decision_basis_hash: Option<&'a str>,
}
#[derive(Serialize)]
struct CanonicalEventV2<'a> {
event_id: &'a str,
schema_version: u8,
seq_no: u64,
recorded_at_ms: u64,
event_type: &'a EnforcementEventType,
prev_hash: &'a str,
installation_id: &'a str,
actor_local: &'a Option<ActorLocal>,
agent_type: &'a str,
subject_kind: SubjectKind,
subject_key: &'a str,
canonical_subject_hash: Option<&'a str>,
receipt_id: Option<&'a str>,
decision_reason_code: &'a str,
decision_basis_hash: Option<&'a str>,
agent_session: Option<&'a str>,
}
impl EnforcementEvent {
pub fn compute_hash(&self) -> String {
let json = if self.schema_version >= 2 {
let canonical = CanonicalEventV2 {
event_id: &self.event_id,
schema_version: self.schema_version,
seq_no: self.seq_no,
recorded_at_ms: self.recorded_at_ms,
event_type: &self.event_type,
prev_hash: &self.prev_hash,
installation_id: &self.installation_id,
actor_local: &self.actor_local,
agent_type: &self.agent_type,
subject_kind: self.subject_kind,
subject_key: &self.subject_key,
canonical_subject_hash: self.canonical_subject_hash.as_deref(),
receipt_id: self.receipt_id.as_deref(),
decision_reason_code: &self.decision_reason_code,
decision_basis_hash: self.decision_basis_hash.as_deref(),
agent_session: self.agent_session.as_deref(),
};
serde_json::to_string(&canonical).expect("canonical serialization must not fail")
} else {
let canonical = CanonicalEvent {
event_id: &self.event_id,
schema_version: self.schema_version,
seq_no: self.seq_no,
recorded_at_ms: self.recorded_at_ms,
event_type: &self.event_type,
prev_hash: &self.prev_hash,
installation_id: &self.installation_id,
actor_local: &self.actor_local,
agent_type: &self.agent_type,
subject_kind: self.subject_kind,
subject_key: &self.subject_key,
canonical_subject_hash: self.canonical_subject_hash.as_deref(),
receipt_id: self.receipt_id.as_deref(),
decision_reason_code: &self.decision_reason_code,
decision_basis_hash: self.decision_basis_hash.as_deref(),
};
serde_json::to_string(&canonical).expect("canonical serialization must not fail")
};
let mut hasher = Sha256::new();
hasher.update(json.as_bytes());
format!("{:x}", hasher.finalize())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChainBreakKind {
Linkage,
Tampered,
UnknownSchema,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainBreak {
pub kind: ChainBreakKind,
pub seq_no: u64,
pub recorded_at_ms: u64,
pub event_type: String,
pub prev_seq_no: Option<u64>,
pub prev_recorded_at_ms: Option<u64>,
pub prev_event_type: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainVerification {
pub checked: usize,
pub tampered_events: usize,
pub linkage_breaks: usize,
pub unknown_schema: usize,
pub breaks: Vec<ChainBreak>,
}
impl ChainVerification {
pub fn is_valid(&self) -> bool {
self.tampered_events == 0 && self.linkage_breaks == 0 && self.unknown_schema == 0
}
}
pub fn verify_chain(events: &[EnforcementEvent]) -> ChainVerification {
let mut sorted: Vec<&EnforcementEvent> = events.iter().collect();
sorted.sort_by_key(|e| e.seq_no);
let mut result = ChainVerification::default();
let mut prev: Option<&EnforcementEvent> = None;
for e in sorted {
if let Some(p) = prev {
if e.prev_hash != p.event_hash {
result.linkage_breaks += 1;
result.breaks.push(ChainBreak {
kind: ChainBreakKind::Linkage,
seq_no: e.seq_no,
recorded_at_ms: e.recorded_at_ms,
event_type: event_type_label(&e.event_type).to_string(),
prev_seq_no: Some(p.seq_no),
prev_recorded_at_ms: Some(p.recorded_at_ms),
prev_event_type: Some(event_type_label(&p.event_type).to_string()),
});
}
}
if e.schema_version > SCHEMA_VERSION {
result.unknown_schema += 1;
result.breaks.push(ChainBreak {
kind: ChainBreakKind::UnknownSchema,
seq_no: e.seq_no,
recorded_at_ms: e.recorded_at_ms,
event_type: event_type_label(&e.event_type).to_string(),
prev_seq_no: None,
prev_recorded_at_ms: None,
prev_event_type: None,
});
} else {
result.checked += 1;
if e.event_hash != e.compute_hash() {
result.tampered_events += 1;
result.breaks.push(ChainBreak {
kind: ChainBreakKind::Tampered,
seq_no: e.seq_no,
recorded_at_ms: e.recorded_at_ms,
event_type: event_type_label(&e.event_type).to_string(),
prev_seq_no: None,
prev_recorded_at_ms: None,
prev_event_type: None,
});
}
}
prev = Some(e);
}
result
}
pub struct SeqAllocator {
current: u64,
}
impl SeqAllocator {
pub async fn load(store: &Store) -> Self {
let current = match store.get_raw_bytes(SEQ_KEY).await {
Ok(Some(bytes)) if bytes.len() == 8 => {
u64::from_be_bytes(bytes[..8].try_into().unwrap_or([0; 8]))
}
_ => 0,
};
Self { current }
}
pub async fn next(&mut self, store: &Store) -> Result<u64> {
self.current += 1;
store.put_raw(SEQ_KEY, &self.current.to_be_bytes()).await?;
Ok(self.current)
}
pub fn current(&self) -> u64 {
self.current
}
}
pub async fn get_or_create_installation_id(store: &Store) -> Result<String> {
if let Ok(Some(bytes)) = store.get_raw_bytes(INSTALLATION_ID_KEY).await {
if let Ok(id) = std::str::from_utf8(&bytes) {
if !id.is_empty() {
return Ok(id.to_string());
}
}
}
let id = uuid::Uuid::new_v4().to_string();
store.put_raw(INSTALLATION_ID_KEY, id.as_bytes()).await?;
Ok(id)
}
pub fn get_local_actor() -> Option<ActorLocal> {
let username = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok()?;
#[cfg(unix)]
let uid = Some(unsafe { libc::getuid() } as u32);
#[cfg(not(unix))]
let uid = None;
Some(ActorLocal {
username,
uid,
verified: false,
})
}
pub fn canonicalize_file_key(path: &str, repo_root: &Path) -> String {
let abs_path = if Path::new(path).is_relative() {
repo_root.join(path)
} else {
PathBuf::from(path)
};
let normalized = normalize_components(&abs_path);
let resolved = std::fs::canonicalize(&normalized).unwrap_or(normalized);
let repo_root_canonical =
std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
let relative = resolved
.strip_prefix(&repo_root_canonical)
.unwrap_or(&resolved);
let mut key = relative
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
if is_case_insensitive() {
key = key.to_lowercase();
}
key
}
fn normalize_components(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
Component::CurDir => {} Component::ParentDir => {
if matches!(components.last(), Some(Component::Normal(_))) {
components.pop();
} else {
components.push(component);
}
}
_ => components.push(component),
}
}
components.iter().collect()
}
fn is_case_insensitive() -> bool {
cfg!(target_os = "macos") || cfg!(target_os = "windows")
}
pub fn canonical_subject_hash(canonical_key: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(canonical_key.as_bytes());
format!("{:x}", hasher.finalize())
}
fn uuid7_string() -> String {
uuid::Uuid::now_v7().to_string()
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
pub struct EnforcementEventWriter {
seq: SeqAllocator,
installation_id: String,
prev_hash: String,
agent_session: Option<String>,
}
impl EnforcementEventWriter {
pub async fn new(store: &Store) -> Result<Self> {
let seq = SeqAllocator::load(store).await;
let installation_id = get_or_create_installation_id(store).await?;
let prev_hash = Self::load_last_hash(store).await;
Ok(Self {
seq,
installation_id,
prev_hash,
agent_session: None,
})
}
async fn load_last_hash(store: &Store) -> String {
let keys = match store.scan_keys(EVENT_PREFIX).await {
Ok(k) => k,
Err(_) => return String::new(),
};
if keys.is_empty() {
return String::new();
}
let last_key = keys
.iter()
.max_by_key(|k| {
k.strip_prefix(EVENT_PREFIX)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0)
})
.cloned();
if let Some(key) = last_key {
if let Ok(Some(bytes)) = store.get_raw_bytes(&key).await {
if let Ok(event) = serde_json::from_slice::<EnforcementEvent>(&bytes) {
return event.event_hash;
}
}
}
String::new()
}
#[allow(clippy::too_many_arguments)]
pub async fn write(
&mut self,
store: &Store,
event_type: EnforcementEventType,
subject_kind: SubjectKind,
subject_key: String,
agent_type: String,
receipt_id: Option<String>,
decision_reason_code: String,
decision_basis_hash: Option<String>,
) -> Result<EnforcementEvent> {
let seq_no = self.seq.next(store).await?;
let canonical_subject_hash_value = if subject_kind == SubjectKind::File {
Some(canonical_subject_hash(&subject_key))
} else {
None
};
let mut event = EnforcementEvent {
event_id: uuid7_string(),
schema_version: SCHEMA_VERSION,
seq_no,
recorded_at_ms: now_ms(),
event_type,
event_hash: String::new(), prev_hash: self.prev_hash.clone(),
installation_id: self.installation_id.clone(),
actor_local: get_local_actor(),
agent_type,
subject_kind,
subject_key,
canonical_subject_hash: canonical_subject_hash_value,
receipt_id,
decision_reason_code,
decision_basis_hash,
agent_session: self.agent_session.clone(),
};
event.event_hash = event.compute_hash();
let key = format!("{EVENT_PREFIX}{:020}", seq_no);
let json = serde_json::to_vec(&event)?;
store.put_raw(&key, &json).await?;
self.prev_hash = event.event_hash.clone();
Ok(event)
}
pub fn installation_id(&self) -> &str {
&self.installation_id
}
pub fn current_seq(&self) -> u64 {
self.seq.current()
}
pub fn prev_hash(&self) -> &str {
&self.prev_hash
}
pub async fn detect_and_record_gap(
&mut self,
store: &Store,
gap_start_ms: u64,
gap_end_ms: u64,
cause: GapCause,
) -> Result<EnforcementEvent> {
self.write(
store,
EnforcementEventType::RecordingGap {
gap_start_ms,
gap_end_ms,
cause,
enforcement_mode_during_gap: EnforcementMode::Advisory,
missed_event_count: MissedEventCount::Unknown,
certainty: GapCertainty::Inferred,
},
SubjectKind::System,
"enforcement:stream".to_string(),
"system".to_string(),
None,
"recording_gap_detected".to_string(),
None,
)
.await
}
}
pub async fn scan_enforcement_events(
store: &Store,
since_seq: u64,
until_seq: u64,
) -> Result<Vec<EnforcementEvent>> {
let keys = store.scan_keys(EVENT_PREFIX).await?;
let mut events = Vec::new();
for key in &keys {
let seq = match key
.strip_prefix(EVENT_PREFIX)
.and_then(|s| s.parse::<u64>().ok())
{
Some(s) => s,
None => continue,
};
if seq < since_seq || seq > until_seq {
continue;
}
if let Ok(Some(bytes)) = store.get_raw_bytes(key).await {
match serde_json::from_slice::<EnforcementEvent>(&bytes) {
Ok(event) => events.push(event),
Err(e) => {
tracing::warn!(key, "skipping corrupt enforcement event: {e}");
}
}
}
}
events.sort_by_key(|e| e.seq_no);
Ok(events)
}
const ENFORCEMENT_MODE_KEY: &str = "enforcement:mode";
const DEFAULT_RETENTION_DAYS: u64 = 365;
const RETENTION_DAYS_KEY: &str = "enforcement:retention_days";
pub async fn get_enforcement_mode(store: &Store) -> EnforcementMode {
match store.get_raw_bytes(ENFORCEMENT_MODE_KEY).await {
Ok(Some(bytes)) => match std::str::from_utf8(&bytes) {
Ok("strict") => EnforcementMode::Strict,
_ => EnforcementMode::Advisory,
},
_ => EnforcementMode::Advisory,
}
}
pub async fn set_enforcement_mode(store: &Store, mode: EnforcementMode) -> Result<EnforcementMode> {
let old = get_enforcement_mode(store).await;
let value = match mode {
EnforcementMode::Advisory => "advisory",
EnforcementMode::Strict => "strict",
};
store
.put_raw(ENFORCEMENT_MODE_KEY, value.as_bytes())
.await?;
if old != mode {
let user_label = |m: EnforcementMode| match m {
EnforcementMode::Advisory => "best_effort",
EnforcementMode::Strict => "strict",
};
let _ = record_event(
store,
EnforcementEventType::EnforcementConfigChanged {
setting: "audit.write_durability".to_string(),
old_value: user_label(old).to_string(),
new_value: user_label(mode).to_string(),
},
SubjectKind::Config,
"enforcement:mode".to_string(),
"developer".to_string(),
None,
"config_changed".to_string(),
None,
)
.await;
}
Ok(old)
}
pub async fn get_retention_days(store: &Store) -> u64 {
match store.get_raw_bytes(RETENTION_DAYS_KEY).await {
Ok(Some(bytes)) => std::str::from_utf8(&bytes)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_RETENTION_DAYS),
_ => DEFAULT_RETENTION_DAYS,
}
}
pub async fn set_retention_days(store: &Store, days: u64) -> Result<()> {
store
.put_raw(RETENTION_DAYS_KEY, days.to_string().as_bytes())
.await
}
pub fn compute_decision_basis_hash(gotchas: &[(String, serde_json::Value)]) -> String {
let mut hasher = Sha256::new();
for (key, record_json) in gotchas {
hasher.update(key.as_bytes());
let rule = record_json
.pointer("/value")
.and_then(|v| v.as_str())
.unwrap_or("");
hasher.update(rule.as_bytes());
let conf = record_json
.pointer("/confidence/value")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
hasher.update(format!("{conf}").as_bytes());
}
format!("{:x}", hasher.finalize())
}
static ENFORCEMENT_WRITERS: OnceLock<
StdMutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<EnforcementEventWriter>>>>,
> = OnceLock::new();
async fn shared_writer(store: &Store) -> Result<Arc<tokio::sync::Mutex<EnforcementEventWriter>>> {
let registry = ENFORCEMENT_WRITERS.get_or_init(|| StdMutex::new(HashMap::new()));
if let Some(writer) = registry
.lock()
.expect("enforcement writer registry poisoned")
.get(&store.root)
.cloned()
{
return Ok(writer);
}
let writer = Arc::new(tokio::sync::Mutex::new(
EnforcementEventWriter::new(store).await?,
));
Ok(registry
.lock()
.expect("enforcement writer registry poisoned")
.entry(store.root.clone())
.or_insert(writer)
.clone())
}
#[allow(clippy::too_many_arguments)]
pub async fn record_event(
store: &Store,
event_type: EnforcementEventType,
subject_kind: SubjectKind,
subject_key: String,
agent_type: String,
receipt_id: Option<String>,
decision_reason_code: String,
decision_basis_hash: Option<String>,
) -> Result<Option<EnforcementEvent>> {
record_event_with_session(
store,
event_type,
subject_kind,
subject_key,
agent_type,
receipt_id,
decision_reason_code,
decision_basis_hash,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn record_event_with_session(
store: &Store,
event_type: EnforcementEventType,
subject_kind: SubjectKind,
subject_key: String,
agent_type: String,
receipt_id: Option<String>,
decision_reason_code: String,
decision_basis_hash: Option<String>,
agent_session: Option<String>,
) -> Result<Option<EnforcementEvent>> {
let mode = get_enforcement_mode(store).await;
let result = async {
let writer = shared_writer(store).await?;
let mut writer = writer.lock().await;
writer.agent_session = agent_session;
writer
.write(
store,
event_type,
subject_kind,
subject_key,
agent_type,
receipt_id,
decision_reason_code,
decision_basis_hash,
)
.await
}
.await;
match result {
Ok(event) => Ok(Some(event)),
Err(e) => match mode {
EnforcementMode::Advisory => {
tracing::warn!("enforcement event write failed (advisory mode): {e}");
Ok(None)
}
EnforcementMode::Strict => Err(e),
},
}
}
#[derive(Debug)]
pub enum PruneResult {
NothingToPrune,
Pruned {
count: u64,
oldest_seq: u64,
newest_seq: u64,
},
}
pub async fn enforce_retention(store: &Store) -> Result<PruneResult> {
let retention_days = get_retention_days(store).await;
let cutoff_ms = now_ms().saturating_sub(retention_days * 86_400_000);
let all_events = scan_enforcement_events(store, 0, u64::MAX).await?;
let old_events: Vec<&EnforcementEvent> = all_events
.iter()
.filter(|e| e.recorded_at_ms < cutoff_ms)
.collect();
if old_events.is_empty() {
return Ok(PruneResult::NothingToPrune);
}
let count = old_events.len() as u64;
let oldest_seq = old_events.first().expect("checked non-empty above").seq_no;
let newest_seq = old_events.last().expect("checked non-empty above").seq_no;
for event in &old_events {
let key = format!("{EVENT_PREFIX}{:020}", event.seq_no);
store.delete(&key).await?;
}
record_event(
store,
EnforcementEventType::RetentionPruned {
pruned_count: count,
oldest_pruned_seq: oldest_seq,
newest_pruned_seq: newest_seq,
},
SubjectKind::System,
"enforcement:retention".to_string(),
"system".to_string(),
None,
"retention_policy_enforced".to_string(),
None,
)
.await?;
Ok(PruneResult::Pruned {
count,
oldest_seq,
newest_seq,
})
}
pub async fn detect_startup_gap(store: &Store, gap_threshold_ms: u64) -> Result<()> {
let events = scan_enforcement_events(store, 0, u64::MAX).await?;
if events.is_empty() {
return Ok(());
}
let last = events.last().expect("checked non-empty above");
let current = now_ms();
let age = current.saturating_sub(last.recorded_at_ms);
if age > gap_threshold_ms {
let writer = shared_writer(store).await?;
writer
.lock()
.await
.detect_and_record_gap(store, last.recorded_at_ms, current, GapCause::Unknown)
.await?;
}
Ok(())
}
pub async fn scan_events_since(store: &Store, since_ms: u64) -> Result<Vec<EnforcementEvent>> {
let all = scan_enforcement_events(store, 0, u64::MAX).await?;
Ok(all
.into_iter()
.filter(|e| e.recorded_at_ms >= since_ms)
.collect())
}
pub async fn count_events_by_type(store: &Store, since_ms: u64) -> Result<EnforcementEventCounts> {
let events = scan_events_since(store, since_ms).await?;
Ok(aggregate_event_counts(&events))
}
pub fn aggregate_event_counts(events: &[EnforcementEvent]) -> EnforcementEventCounts {
let mut counts = EnforcementEventCounts {
total: events.len() as u64,
..Default::default()
};
for e in events {
match &e.event_type {
EnforcementEventType::Deny => counts.denials += 1,
EnforcementEventType::AllowAfterReceipt => counts.allowed_after_receipt += 1,
EnforcementEventType::ReceiptMinted => counts.receipts_minted += 1,
EnforcementEventType::BypassDetected => counts.bypasses += 1,
EnforcementEventType::ControlChanged { change_kind } => {
counts.controls_changed += 1;
match change_kind {
ControlChangeKind::Created => counts.controls_created += 1,
ControlChangeKind::Confirmed => counts.controls_confirmed += 1,
ControlChangeKind::Updated => counts.controls_updated += 1,
ControlChangeKind::Deleted => counts.controls_removed += 1,
}
}
EnforcementEventType::EnforcementConfigChanged { .. } => counts.config_changes += 1,
EnforcementEventType::RecordingGap { .. } => counts.gaps += 1,
EnforcementEventType::RetentionPruned { .. } => counts.retention_prunes += 1,
}
}
counts
}
#[derive(Debug, Default)]
pub struct EnforcementEventCounts {
pub total: u64,
pub denials: u64,
pub allowed_after_receipt: u64,
pub receipts_minted: u64,
pub bypasses: u64,
pub controls_changed: u64,
pub controls_created: u64,
pub controls_confirmed: u64,
pub controls_updated: u64,
pub controls_removed: u64,
pub config_changes: u64,
pub gaps: u64,
pub retention_prunes: u64,
}
#[derive(Debug, Default)]
pub struct DerivedEnforcementMetrics {
pub blocked_sessions: u64,
pub attributed_denials: u64,
pub blocks_per_session: Option<f64>,
pub median_time_to_consult_ms: Option<u64>,
pub consult_pairs: u64,
}
pub fn derive_enforcement_metrics(events: &[EnforcementEvent]) -> DerivedEnforcementMetrics {
use std::collections::{BTreeSet, HashMap};
let mut blocked_sessions: BTreeSet<&str> = BTreeSet::new();
let mut attributed_denials = 0u64;
for e in events {
if matches!(e.event_type, EnforcementEventType::Deny) {
if let Some(sid) = e.agent_session.as_deref() {
blocked_sessions.insert(sid);
attributed_denials += 1;
}
}
}
let blocks_per_session = if blocked_sessions.is_empty() {
None
} else {
Some(attributed_denials as f64 / blocked_sessions.len() as f64)
};
let mut receipts_by_subject: HashMap<&str, Vec<u64>> = HashMap::new();
for e in events {
if matches!(e.event_type, EnforcementEventType::ReceiptMinted) {
receipts_by_subject
.entry(e.subject_key.as_str())
.or_default()
.push(e.recorded_at_ms);
}
}
for times in receipts_by_subject.values_mut() {
times.sort_unstable();
}
let window_ms = crate::store::session::CONSULTED_RECENT_TTL_SECS * 1_000;
let mut deltas: Vec<u64> = Vec::new();
for e in events {
if matches!(e.event_type, EnforcementEventType::Deny) {
if let Some(times) = receipts_by_subject.get(e.subject_key.as_str()) {
if let Some(&t) = times.iter().find(|&&t| t >= e.recorded_at_ms) {
let delta = t - e.recorded_at_ms;
if delta <= window_ms {
deltas.push(delta);
}
}
}
}
}
let consult_pairs = deltas.len() as u64;
let median_time_to_consult_ms = median_u64(&mut deltas);
DerivedEnforcementMetrics {
blocked_sessions: blocked_sessions.len() as u64,
attributed_denials,
blocks_per_session,
median_time_to_consult_ms,
consult_pairs,
}
}
fn median_u64(values: &mut [u64]) -> Option<u64> {
if values.is_empty() {
return None;
}
values.sort_unstable();
let n = values.len();
let mid = n / 2;
if n % 2 == 1 {
Some(values[mid])
} else {
Some((values[mid - 1] + values[mid]) / 2)
}
}
pub fn event_type_label(event_type: &EnforcementEventType) -> &'static str {
match event_type {
EnforcementEventType::Deny => "deny",
EnforcementEventType::AllowAfterReceipt => "allow_receipt",
EnforcementEventType::ReceiptMinted => "receipt_minted",
EnforcementEventType::BypassDetected => "bypass",
EnforcementEventType::ControlChanged { .. } => "control_changed",
EnforcementEventType::EnforcementConfigChanged { .. } => "config_changed",
EnforcementEventType::RecordingGap { .. } => "gap",
EnforcementEventType::RetentionPruned { .. } => "retention_pruned",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frozen_test_event() -> EnforcementEvent {
EnforcementEvent {
event_id: "01900000-0000-7000-8000-000000000001".to_string(),
schema_version: 1,
seq_no: 1,
recorded_at_ms: 1700000000000,
event_type: EnforcementEventType::Deny,
event_hash: String::new(),
prev_hash: String::new(),
installation_id: "test-install-id".to_string(),
actor_local: Some(ActorLocal {
username: "testuser".to_string(),
uid: Some(1000),
verified: false,
}),
agent_type: "claude".to_string(),
subject_kind: SubjectKind::File,
subject_key: "file:src/billing/charges.rs".to_string(),
canonical_subject_hash: Some("abc123".to_string()),
receipt_id: None,
decision_reason_code: "gotcha_above_threshold".to_string(),
decision_basis_hash: Some("def456".to_string()),
agent_session: None,
}
}
#[test]
fn canonical_hash_is_deterministic_and_frozen() {
let event = frozen_test_event();
let hash = event.compute_hash();
assert_eq!(
hash,
"e8a42cb3c1c4dde12f807f46678c5d4393466a831007540a85ff84a003203e37"
);
assert_eq!(hash, event.compute_hash());
assert_eq!(hash, event.compute_hash());
}
#[test]
fn hash_changes_when_field_changes() {
let mut event = frozen_test_event();
let hash1 = event.compute_hash();
event.seq_no = 2;
let hash2 = event.compute_hash();
assert_ne!(hash1, hash2, "changing seq_no must change the hash");
}
fn event_of(event_type: EnforcementEventType) -> EnforcementEvent {
EnforcementEvent {
event_type,
..frozen_test_event()
}
}
fn chained(n: u64) -> Vec<EnforcementEvent> {
let mut out = Vec::new();
let mut prev = String::new();
for i in 1..=n {
let mut e = EnforcementEvent {
seq_no: i,
prev_hash: prev.clone(),
event_hash: String::new(),
..frozen_test_event()
};
e.event_hash = e.compute_hash();
prev = e.event_hash.clone();
out.push(e);
}
out
}
#[test]
fn verify_chain_accepts_intact_chain() {
let events = chained(4);
let v = verify_chain(&events);
assert!(v.is_valid());
assert_eq!(v.checked, 4);
assert_eq!(v.tampered_events, 0);
assert_eq!(v.linkage_breaks, 0);
assert_eq!(v.unknown_schema, 0);
}
#[test]
fn verify_chain_is_order_independent() {
let mut events = chained(4);
events.reverse();
assert!(verify_chain(&events).is_valid());
}
#[test]
fn verify_chain_detects_content_tamper_without_rehash() {
let mut events = chained(3);
events[1].subject_key = "file:src/evil.rs".to_string();
let v = verify_chain(&events);
assert_eq!(v.tampered_events, 1);
assert_eq!(v.linkage_breaks, 0);
assert!(!v.is_valid());
}
#[test]
fn verify_chain_detects_linkage_break_from_deleted_event() {
let mut events = chained(3);
events.remove(1); let v = verify_chain(&events);
assert_eq!(v.linkage_breaks, 1);
assert_eq!(v.tampered_events, 0);
assert!(!v.is_valid());
}
#[test]
fn verify_chain_ignores_retention_pruned_prefix() {
let mut events = chained(3);
events.remove(0); let v = verify_chain(&events);
assert!(
v.is_valid(),
"pruned prefix must not be a false linkage break"
);
assert_eq!(v.linkage_breaks, 0);
assert_eq!(v.tampered_events, 0);
}
#[test]
fn verify_chain_flags_unknown_schema_version() {
let mut e = frozen_test_event();
e.schema_version = SCHEMA_VERSION + 1;
e.event_hash = e.compute_hash();
let v = verify_chain(&[e]);
assert_eq!(v.unknown_schema, 1);
assert_eq!(v.checked, 0);
assert!(!v.is_valid());
}
#[test]
fn verify_chain_verifies_v2_events_clean() {
let mut e = frozen_test_event();
e.schema_version = 2;
e.agent_session = Some("session-xyz".to_string());
e.event_hash = e.compute_hash();
let v = verify_chain(&[e]);
assert!(v.is_valid());
assert_eq!(v.checked, 1);
}
#[test]
fn verify_chain_empty_is_valid() {
let v = verify_chain(&[]);
assert!(v.is_valid());
assert_eq!(v.checked, 0);
}
#[test]
fn verify_chain_records_tampered_break_location() {
let mut events = chained(3);
events[2].subject_key = "file:src/evil.rs".to_string();
let v = verify_chain(&events);
assert_eq!(v.breaks.len(), 1);
let b = &v.breaks[0];
assert_eq!(b.kind, ChainBreakKind::Tampered);
assert_eq!(b.seq_no, 3);
assert!(b.prev_seq_no.is_none());
}
#[test]
fn verify_chain_records_linkage_break_with_predecessor() {
let mut events = chained(3);
events.remove(1); let v = verify_chain(&events);
assert_eq!(v.breaks.len(), 1);
let b = &v.breaks[0];
assert_eq!(b.kind, ChainBreakKind::Linkage);
assert_eq!(b.seq_no, 3);
assert_eq!(b.prev_seq_no, Some(1));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_record_event_keeps_chain_intact() {
use std::sync::Arc;
let dir = tempfile::TempDir::new().unwrap();
let store = Arc::new(Store::open(dir.path()).await.unwrap());
let n: u64 = 64;
let mut handles = Vec::new();
for i in 0..n {
let s = store.clone();
handles.push(tokio::spawn(async move {
record_event(
&s,
EnforcementEventType::Deny,
SubjectKind::File,
format!("file:src/f{i}.rs"),
"claude".to_string(),
None,
"gotcha_above_threshold".to_string(),
None,
)
.await
.expect("record_event")
}));
}
for h in handles {
h.await.expect("task join");
}
let events = scan_enforcement_events(&store, 0, u64::MAX).await.unwrap();
assert_eq!(
events.len() as u64,
n,
"all {n} concurrent writes must persist (no seq collision / event loss)"
);
let v = verify_chain(&events);
assert!(
v.is_valid(),
"concurrent writes must yield an intact chain, got {v:?}"
);
}
#[test]
fn aggregate_event_counts_breaks_out_control_lifecycle() {
use EnforcementEventType::*;
let events = vec![
event_of(Deny),
event_of(Deny),
event_of(AllowAfterReceipt),
event_of(ReceiptMinted),
event_of(BypassDetected),
event_of(ControlChanged {
change_kind: ControlChangeKind::Created,
}),
event_of(ControlChanged {
change_kind: ControlChangeKind::Confirmed,
}),
event_of(ControlChanged {
change_kind: ControlChangeKind::Confirmed,
}),
event_of(ControlChanged {
change_kind: ControlChangeKind::Updated,
}),
event_of(ControlChanged {
change_kind: ControlChangeKind::Deleted,
}),
];
let counts = aggregate_event_counts(&events);
assert_eq!(counts.total, 10);
assert_eq!(counts.denials, 2);
assert_eq!(counts.allowed_after_receipt, 1);
assert_eq!(counts.receipts_minted, 1);
assert_eq!(counts.bypasses, 1);
assert_eq!(counts.controls_created, 1);
assert_eq!(counts.controls_confirmed, 2);
assert_eq!(counts.controls_updated, 1);
assert_eq!(counts.controls_removed, 1);
assert_eq!(counts.controls_changed, 5);
assert_eq!(
counts.controls_changed,
counts.controls_created
+ counts.controls_confirmed
+ counts.controls_updated
+ counts.controls_removed
);
}
#[test]
fn aggregate_event_counts_empty_is_all_zero() {
let counts = aggregate_event_counts(&[]);
assert_eq!(counts.total, 0);
assert_eq!(counts.controls_changed, 0);
assert_eq!(counts.denials, 0);
}
fn ev(
event_type: EnforcementEventType,
subject: &str,
at_ms: u64,
session: Option<&str>,
) -> EnforcementEvent {
EnforcementEvent {
event_type,
subject_key: subject.to_string(),
recorded_at_ms: at_ms,
agent_session: session.map(str::to_string),
..frozen_test_event()
}
}
#[test]
fn derive_metrics_blocks_per_session_and_time_to_consult() {
use EnforcementEventType::*;
let events = vec![
ev(Deny, "file:x.rs", 1000, Some("sessA")),
ev(ReceiptMinted, "file:x.rs", 1500, None),
ev(Deny, "file:y.rs", 2000, Some("sessB")),
ev(ReceiptMinted, "file:y.rs", 2300, None),
ev(Deny, "file:y.rs", 3000, Some("sessA")),
];
let m = derive_enforcement_metrics(&events);
assert_eq!(m.blocked_sessions, 2, "distinct sessions with a deny");
assert_eq!(m.attributed_denials, 3);
assert_eq!(m.blocks_per_session, Some(1.5)); assert_eq!(m.consult_pairs, 2); assert_eq!(m.median_time_to_consult_ms, Some(400)); }
#[test]
fn derive_metrics_no_sessioned_denials_yields_none() {
use EnforcementEventType::*;
let events = vec![
ev(Deny, "file:x.rs", 1000, None),
ev(ReceiptMinted, "file:x.rs", 1200, None),
];
let m = derive_enforcement_metrics(&events);
assert_eq!(m.blocked_sessions, 0);
assert_eq!(m.attributed_denials, 0);
assert_eq!(m.blocks_per_session, None);
assert_eq!(m.consult_pairs, 1);
assert_eq!(m.median_time_to_consult_ms, Some(200));
}
#[test]
fn derive_metrics_excludes_consults_beyond_window() {
use EnforcementEventType::*;
let window_ms = crate::store::session::CONSULTED_RECENT_TTL_SECS * 1_000;
let events = vec![
ev(Deny, "file:a.rs", 0, Some("s1")),
ev(ReceiptMinted, "file:a.rs", window_ms, None),
ev(Deny, "file:b.rs", 0, Some("s2")),
ev(ReceiptMinted, "file:b.rs", window_ms + 1, None),
];
let m = derive_enforcement_metrics(&events);
assert_eq!(m.consult_pairs, 1, "only the in-window pair counts");
assert_eq!(m.median_time_to_consult_ms, Some(window_ms));
}
#[test]
fn median_u64_odd_even_and_empty() {
assert_eq!(median_u64(&mut []), None);
assert_eq!(median_u64(&mut [5]), Some(5));
assert_eq!(median_u64(&mut [3, 1, 2]), Some(2)); assert_eq!(median_u64(&mut [4, 1, 3, 2]), Some(2)); }
#[test]
fn hash_excludes_event_hash_field() {
let mut event = frozen_test_event();
let hash1 = event.compute_hash();
event.event_hash = "something_completely_different".to_string();
let hash2 = event.compute_hash();
assert_eq!(
hash1, hash2,
"event_hash field must be excluded from canonical form"
);
}
#[test]
fn canonical_path_aliasing_produces_same_key() {
let repo_root = PathBuf::from("/tmp/test-repo");
let paths = [
"src/billing/charges.rs",
"./src/billing/charges.rs",
"src/billing/../billing/charges.rs",
"src/./billing/charges.rs",
];
let canonical_keys: Vec<String> = paths
.iter()
.map(|p| {
let abs = repo_root.join(p);
let normalized = normalize_components(&abs);
let relative = normalized
.strip_prefix(&repo_root)
.unwrap_or(&normalized)
.to_string_lossy()
.replace('\\', "/");
if is_case_insensitive() {
relative.to_lowercase()
} else {
relative
}
})
.collect();
for key in &canonical_keys {
assert_eq!(
key, &canonical_keys[0],
"Path aliasing produced different keys"
);
}
assert_eq!(canonical_keys[0], "src/billing/charges.rs");
}
#[test]
fn canonical_subject_hash_is_deterministic() {
let hash1 = canonical_subject_hash("src/billing/charges.rs");
let hash2 = canonical_subject_hash("src/billing/charges.rs");
assert_eq!(hash1, hash2);
let hash3 = canonical_subject_hash("src/billing/other.rs");
assert_ne!(hash1, hash3);
}
#[test]
fn schema_version_is_two() {
assert_eq!(SCHEMA_VERSION, 2);
assert_eq!(HASH_ALGORITHM, "sha256");
}
#[test]
fn v2_hash_includes_agent_session() {
let mut e_none = frozen_test_event();
e_none.schema_version = 2;
e_none.agent_session = None;
let h_none = e_none.compute_hash();
let mut e_session = e_none.clone();
e_session.agent_session = Some("sess-abc".to_string());
let h_session = e_session.compute_hash();
assert_ne!(
h_none, h_session,
"agent_session must be part of the v2 canonical hash"
);
assert_eq!(h_session, e_session.compute_hash());
let mut as_v1 = e_none.clone();
as_v1.schema_version = 1;
assert_ne!(
h_none,
as_v1.compute_hash(),
"v1 and v2 canonical forms must differ (14 vs 15 fields)"
);
}
}