use chrono::{DateTime, Utc};
use serde::Serialize;
use super::CloudEvent;
#[derive(Debug, Clone, Serialize)]
pub struct TamperEvent {
pub ocsf: OcsfHeader,
pub tamper: TamperPayload,
}
#[derive(Debug, Clone, Serialize)]
pub struct OcsfHeader {
pub class_uid: u32,
pub activity_id: u32,
pub type_uid: u32,
pub severity_id: u32,
pub time: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TamperPayload {
pub event_id: String,
pub entry_id: String,
pub agent_type: String,
pub settings_path_hash: String,
pub hook_event: String,
pub detection_method: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub field_deltas: Vec<FieldDelta>,
pub heal: HealOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
pub related_event_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FieldDelta {
pub field: String,
pub change: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealOutcome {
pub outcome: String,
pub attempt: u32,
pub circuit: String,
}
#[derive(Debug, Clone, Copy)]
pub enum TamperCloudEventKind {
Detected,
Healed,
}
impl TamperCloudEventKind {
fn type_string(self) -> &'static str {
match self {
Self::Detected => "ai.openlatch.security.tamper_detected",
Self::Healed => "ai.openlatch.security.tamper_healed",
}
}
}
impl TamperEvent {
pub fn new(
entry_id: String,
agent_type: String,
settings_path_hash: String,
hook_event: String,
detection_method: String,
) -> Self {
let severity = if detection_method == "legacy_marker_upgrade" {
1
} else {
3
};
Self {
ocsf: OcsfHeader {
class_uid: 2004,
activity_id: 2,
type_uid: 200402,
severity_id: severity,
time: Utc::now(),
},
tamper: TamperPayload {
event_id: uuid::Uuid::now_v7().to_string(),
entry_id,
agent_type,
settings_path_hash,
hook_event,
detection_method,
field_deltas: Vec::new(),
heal: HealOutcome {
outcome: "pending".into(),
attempt: 0,
circuit: "closed".into(),
},
related_event_id: None,
},
}
}
pub fn with_heal(mut self, outcome: &str, attempt: u32, circuit: &str) -> Self {
self.tamper.heal = HealOutcome {
outcome: outcome.into(),
attempt,
circuit: circuit.into(),
};
self
}
pub fn with_field_deltas(mut self, deltas: Vec<FieldDelta>) -> Self {
self.tamper.field_deltas = deltas;
self
}
pub fn new_healed(original: &TamperEvent, outcome: &str, attempt: u32, circuit: &str) -> Self {
Self {
ocsf: OcsfHeader {
class_uid: original.ocsf.class_uid,
activity_id: original.ocsf.activity_id,
type_uid: original.ocsf.type_uid,
severity_id: original.ocsf.severity_id,
time: Utc::now(),
},
tamper: TamperPayload {
event_id: uuid::Uuid::now_v7().to_string(),
entry_id: original.tamper.entry_id.clone(),
agent_type: original.tamper.agent_type.clone(),
settings_path_hash: original.tamper.settings_path_hash.clone(),
hook_event: original.tamper.hook_event.clone(),
detection_method: original.tamper.detection_method.clone(),
field_deltas: original.tamper.field_deltas.clone(),
heal: HealOutcome {
outcome: outcome.into(),
attempt,
circuit: circuit.into(),
},
related_event_id: Some(original.tamper.event_id.clone()),
},
}
}
pub fn to_cloud_event(
&self,
agent_id: &str,
client_version: &str,
os: &str,
kind: TamperCloudEventKind,
) -> CloudEvent {
let payload = serde_json::json!({
"ocsf": self.ocsf,
"tamper": self.tamper,
});
let envelope = serde_json::json!({
"specversion": "1.0",
"id": format!("evt_{}", self.tamper.event_id),
"source": "openlatch-client",
"type": kind.type_string(),
"time": self.ocsf.time.to_rfc3339(),
"datacontenttype": "application/json",
"subject": self.tamper.entry_id,
"data": payload,
"clientversion": client_version,
"os": os,
});
CloudEvent {
envelope,
agent_id: agent_id.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_event() -> TamperEvent {
TamperEvent::new(
"entry-id-123".into(),
"claude-code".into(),
"sha256:abc123".into(),
"PreToolUse".into(),
"hmac_mismatch".into(),
)
}
#[test]
fn ocsf_class_uid_is_2004() {
let e = make_event();
assert_eq!(e.ocsf.class_uid, 2004);
assert_eq!(e.ocsf.activity_id, 2);
assert_eq!(e.ocsf.type_uid, 200402);
}
#[test]
fn severity_is_info_for_legacy_upgrade() {
let e = TamperEvent::new(
"id".into(),
"claude-code".into(),
"sha256:x".into(),
"PreToolUse".into(),
"legacy_marker_upgrade".into(),
);
assert_eq!(e.ocsf.severity_id, 1);
}
#[test]
fn severity_is_medium_for_hmac_mismatch() {
let e = make_event();
assert_eq!(e.ocsf.severity_id, 3);
}
#[test]
fn no_settings_values_in_serialized_event() {
let e = make_event();
let json = serde_json::to_string(&e).unwrap();
assert!(!json.contains("http://attacker"));
assert!(!json.contains("Bearer "));
assert!(!json.contains("OPENLATCH_TOKEN"));
}
#[test]
fn no_settings_path_literal_in_serialized_event() {
let e = TamperEvent::new(
"id".into(),
"claude-code".into(),
"sha256:abc123def456".into(),
"PreToolUse".into(),
"hmac_mismatch".into(),
);
let json = serde_json::to_string(&e).unwrap();
assert!(!json.contains(".claude"));
assert!(!json.contains("settings.json"));
assert!(json.contains("sha256:abc123def456"));
}
#[test]
fn with_heal_sets_outcome() {
let e = make_event().with_heal("succeeded", 2, "closed");
assert_eq!(e.tamper.heal.outcome, "succeeded");
assert_eq!(e.tamper.heal.attempt, 2);
}
#[test]
fn event_id_is_uuid_v7() {
let e = make_event();
assert!(!e.tamper.event_id.is_empty());
}
#[test]
fn related_event_id_absent_on_detection_event() {
let e = make_event();
let json = serde_json::to_string(&e).unwrap();
assert!(
!json.contains("related_event_id"),
"detection events must not serialise related_event_id"
);
}
#[test]
fn new_healed_links_to_original() {
let original = make_event();
let healed = TamperEvent::new_healed(&original, "succeeded", 1, "closed");
assert_eq!(
healed.tamper.related_event_id.as_deref(),
Some(original.tamper.event_id.as_str())
);
assert_ne!(healed.tamper.event_id, original.tamper.event_id);
assert_eq!(healed.tamper.entry_id, original.tamper.entry_id);
assert_eq!(healed.tamper.heal.outcome, "succeeded");
}
#[test]
fn new_healed_preserves_detection_metadata() {
let original = make_event();
let healed = TamperEvent::new_healed(&original, "failed", 3, "open");
assert_eq!(
healed.tamper.detection_method,
original.tamper.detection_method
);
assert_eq!(healed.tamper.agent_type, original.tamper.agent_type);
assert_eq!(healed.tamper.hook_event, original.tamper.hook_event);
assert_eq!(
healed.tamper.settings_path_hash,
original.tamper.settings_path_hash
);
}
#[test]
fn with_field_deltas_populates_vec() {
let deltas = vec![
FieldDelta {
field: "hooks[0].url".into(),
change: "modified".into(),
},
FieldDelta {
field: "timeout".into(),
change: "removed".into(),
},
];
let e = make_event().with_field_deltas(deltas);
assert_eq!(e.tamper.field_deltas.len(), 2);
assert_eq!(e.tamper.field_deltas[0].field, "hooks[0].url");
assert_eq!(e.tamper.field_deltas[0].change, "modified");
let json = serde_json::to_string(&e).unwrap();
assert!(json.contains("hooks[0].url"));
assert!(json.contains("modified"));
}
#[test]
fn field_deltas_omitted_when_empty() {
let e = make_event();
let json = serde_json::to_string(&e).unwrap();
assert!(
!json.contains("field_deltas"),
"empty field_deltas must be skipped in serialisation"
);
}
#[test]
fn to_cloud_event_detected_has_correct_type() {
let e = make_event();
let ce = e.to_cloud_event("agt_xyz", "0.2.0", "linux", TamperCloudEventKind::Detected);
assert_eq!(ce.agent_id, "agt_xyz");
assert_eq!(
ce.envelope["type"].as_str(),
Some("ai.openlatch.security.tamper_detected")
);
assert_eq!(ce.envelope["specversion"].as_str(), Some("1.0"));
assert_eq!(ce.envelope["source"].as_str(), Some("openlatch-client"));
assert_eq!(ce.envelope["subject"].as_str(), Some("entry-id-123"));
assert_eq!(ce.envelope["clientversion"].as_str(), Some("0.2.0"));
assert_eq!(ce.envelope["os"].as_str(), Some("linux"));
assert_eq!(
ce.envelope["datacontenttype"].as_str(),
Some("application/json")
);
}
#[test]
fn to_cloud_event_healed_has_correct_type() {
let e = TamperEvent::new_healed(&make_event(), "succeeded", 1, "closed");
let ce = e.to_cloud_event("agt_xyz", "0.2.0", "macos", TamperCloudEventKind::Healed);
assert_eq!(
ce.envelope["type"].as_str(),
Some("ai.openlatch.security.tamper_healed")
);
}
#[test]
fn to_cloud_event_id_has_evt_prefix() {
let e = make_event();
let ce = e.to_cloud_event("agt_xyz", "0.2.0", "linux", TamperCloudEventKind::Detected);
let id = ce.envelope["id"].as_str().unwrap();
assert!(
id.starts_with("evt_"),
"CloudEvent id must have evt_ prefix: {id}"
);
assert!(id.contains(&e.tamper.event_id));
}
#[test]
fn to_cloud_event_payload_contains_ocsf_and_tamper() {
let e = make_event();
let ce = e.to_cloud_event("agt_xyz", "0.2.0", "linux", TamperCloudEventKind::Detected);
let data = &ce.envelope["data"];
assert_eq!(data["ocsf"]["class_uid"].as_u64(), Some(2004));
assert_eq!(
data["tamper"]["detection_method"].as_str(),
Some("hmac_mismatch")
);
assert_eq!(data["tamper"]["entry_id"].as_str(), Some("entry-id-123"));
}
#[test]
fn to_cloud_event_never_stamps_agentid_in_envelope() {
let e = make_event();
let ce = e.to_cloud_event("agt_xyz", "0.2.0", "linux", TamperCloudEventKind::Detected);
assert!(
ce.envelope.get("agentid").is_none(),
"to_cloud_event must leave agentid stamping to the worker"
);
}
}