use aios_protocol::EventKind;
use lago_core::{BranchId, EventEnvelope, EventId, EventPayload, SessionId};
use serde::{Deserialize, Serialize, Serializer};
use serde_json::json;
use std::collections::HashMap;
pub const EVENT_TYPE: &str = "policy.violation";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViolationType {
CapabilityBlocked,
PathTraversal,
CommandNotAllowed,
EventBudgetExceeded,
RateLimitExceeded,
ModelNotAllowed,
SkillNotAllowed,
TokenExpired,
AuthenticationError,
}
impl ViolationType {
pub fn as_str(self) -> &'static str {
match self {
Self::CapabilityBlocked => "capability_blocked",
Self::PathTraversal => "path_traversal",
Self::CommandNotAllowed => "command_not_allowed",
Self::EventBudgetExceeded => "event_budget_exceeded",
Self::RateLimitExceeded => "rate_limit_exceeded",
Self::ModelNotAllowed => "model_not_allowed",
Self::SkillNotAllowed => "skill_not_allowed",
Self::TokenExpired => "token_expired",
Self::AuthenticationError => "authentication_error",
}
}
}
fn serialize_redacted_path<S>(value: &Option<String>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
None => serializer.serialize_none(),
Some(s) if s.starts_with('/') => {
let last = s.rsplit('/').next().unwrap_or(s.as_str());
serializer.serialize_some(last)
}
Some(s) => serializer.serialize_some(s.as_str()),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyViolationData {
pub violation_type: ViolationType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "serialize_redacted_path"
)]
pub attempted_value: Option<String>,
pub tier: String,
pub subject: String,
}
pub fn event_kind(data: &PolicyViolationData) -> EventKind {
EventKind::Custom {
event_type: EVENT_TYPE.to_string(),
data: json!(data),
}
}
pub fn build_event(
session_id: &SessionId,
branch_id: &BranchId,
data: &PolicyViolationData,
) -> EventEnvelope {
EventEnvelope {
event_id: EventId::new(),
session_id: session_id.clone(),
branch_id: branch_id.clone(),
run_id: None,
seq: 0, timestamp: EventEnvelope::now_micros(),
parent_id: None,
payload: EventPayload::Custom {
event_type: EVENT_TYPE.to_string(),
data: json!(data),
},
metadata: HashMap::new(),
schema_version: 1,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn violation_type_serialises_to_snake_case() {
let v = ViolationType::RateLimitExceeded;
let s = serde_json::to_string(&v).unwrap();
assert_eq!(s, r#""rate_limit_exceeded""#);
}
#[test]
fn violation_type_as_str_matches_serde() {
let cases = [
(ViolationType::CapabilityBlocked, "capability_blocked"),
(ViolationType::PathTraversal, "path_traversal"),
(ViolationType::CommandNotAllowed, "command_not_allowed"),
(ViolationType::EventBudgetExceeded, "event_budget_exceeded"),
(ViolationType::RateLimitExceeded, "rate_limit_exceeded"),
(ViolationType::ModelNotAllowed, "model_not_allowed"),
(ViolationType::SkillNotAllowed, "skill_not_allowed"),
(ViolationType::TokenExpired, "token_expired"),
(ViolationType::AuthenticationError, "authentication_error"),
];
for (variant, expected) in cases {
assert_eq!(variant.as_str(), expected);
let serialised = serde_json::to_string(&variant).unwrap();
assert_eq!(serialised, format!(r#""{expected}""#));
}
}
#[test]
fn event_kind_encodes_violation_type_in_data() {
let data = PolicyViolationData {
violation_type: ViolationType::RateLimitExceeded,
capability: None,
attempted_value: None,
tier: "anonymous".to_string(),
subject: "session-abc".to_string(),
};
let kind = event_kind(&data);
let EventKind::Custom {
event_type,
data: payload,
} = kind
else {
panic!("expected Custom variant");
};
assert_eq!(event_type, EVENT_TYPE);
assert_eq!(payload["violation_type"], "rate_limit_exceeded");
assert_eq!(payload["tier"], "anonymous");
assert_eq!(payload["subject"], "session-abc");
}
#[test]
fn optional_fields_are_omitted_when_none() {
let data = PolicyViolationData {
violation_type: ViolationType::AuthenticationError,
capability: None,
attempted_value: None,
tier: "free".to_string(),
subject: "user-xyz".to_string(),
};
let json = serde_json::to_value(&data).unwrap();
assert!(!json.as_object().unwrap().contains_key("capability"));
assert!(!json.as_object().unwrap().contains_key("attempted_value"));
}
#[test]
fn optional_fields_are_present_when_set() {
let data = PolicyViolationData {
violation_type: ViolationType::SkillNotAllowed,
capability: Some("exec:cmd:*".to_string()),
attempted_value: Some("deep-research".to_string()),
tier: "free".to_string(),
subject: "user-abc".to_string(),
};
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["capability"], "exec:cmd:*");
assert_eq!(json["attempted_value"], "deep-research");
}
#[test]
fn absolute_path_is_redacted_to_last_component() {
let data = PolicyViolationData {
violation_type: ViolationType::PathTraversal,
capability: None,
attempted_value: Some("/home/user/.secret/key.pem".to_string()),
tier: "free".to_string(),
subject: "user-xyz".to_string(),
};
let json = serde_json::to_value(&data).unwrap();
assert_eq!(
json["attempted_value"], "key.pem",
"absolute path must be redacted to last component"
);
}
#[test]
fn relative_path_is_not_redacted() {
let data = PolicyViolationData {
violation_type: ViolationType::PathTraversal,
capability: None,
attempted_value: Some("relative/path/file.txt".to_string()),
tier: "free".to_string(),
subject: "user-xyz".to_string(),
};
let json = serde_json::to_value(&data).unwrap();
assert_eq!(
json["attempted_value"], "relative/path/file.txt",
"relative paths must be preserved as-is"
);
}
#[test]
fn non_path_value_is_not_redacted() {
let data = PolicyViolationData {
violation_type: ViolationType::CommandNotAllowed,
capability: None,
attempted_value: Some("rm -rf /".to_string()),
tier: "anonymous".to_string(),
subject: "session-abc".to_string(),
};
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["attempted_value"], "rm -rf /");
}
#[test]
fn deeply_nested_absolute_path_is_redacted_to_filename_only() {
let data = PolicyViolationData {
violation_type: ViolationType::PathTraversal,
capability: None,
attempted_value: Some("/var/run/arcan/sessions/s-abc/journal.redb".to_string()),
tier: "pro".to_string(),
subject: "user-def".to_string(),
};
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["attempted_value"], "journal.redb");
}
#[test]
fn build_event_sets_correct_payload_type() {
let session_id = SessionId::new();
let branch_id = BranchId::from_string("main");
let data = PolicyViolationData {
violation_type: ViolationType::TokenExpired,
capability: None,
attempted_value: None,
tier: "pro".to_string(),
subject: "user-def".to_string(),
};
let envelope = build_event(&session_id, &branch_id, &data);
let EventPayload::Custom {
event_type,
data: payload,
} = &envelope.payload
else {
panic!("expected Custom payload");
};
assert_eq!(event_type, EVENT_TYPE);
assert_eq!(payload["violation_type"], "token_expired");
assert_eq!(envelope.session_id, session_id);
assert_eq!(envelope.seq, 0); }
}