use std::path::{Component, Path, PathBuf};
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 = 1;
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>,
}
#[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>,
}
impl EnforcementEvent {
pub fn compute_hash(&self) -> String {
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(),
};
let json =
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())
}
}
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,
}
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,
})
}
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,
};
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 old_str = match old {
EnforcementMode::Advisory => "advisory",
EnforcementMode::Strict => "strict",
};
let _ = record_event(
store,
EnforcementEventType::EnforcementConfigChanged {
setting: "enforcement.mode".to_string(),
old_value: old_str.to_string(),
new_value: value.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())
}
#[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>> {
let mode = get_enforcement_mode(store).await;
let result = async {
let mut writer = EnforcementEventWriter::new(store).await?;
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 mut writer = EnforcementEventWriter::new(store).await?;
writer
.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?;
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 { .. } => counts.controls_changed += 1,
EnforcementEventType::EnforcementConfigChanged { .. } => counts.config_changes += 1,
EnforcementEventType::RecordingGap { .. } => counts.gaps += 1,
EnforcementEventType::RetentionPruned { .. } => counts.retention_prunes += 1,
}
}
Ok(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 config_changes: u64,
pub gaps: u64,
pub retention_prunes: u64,
}
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()),
}
}
#[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");
}
#[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_one() {
assert_eq!(SCHEMA_VERSION, 1);
assert_eq!(HASH_ALGORITHM, "sha256");
}
}