openlatch-client 0.1.5

The open-source security layer for AI agents — client forwarder
//! Envelope module — core data types for event wrapping, agent identification, and verdict responses.
//!
//! The wire envelope is a **CloudEvents v1.0.2 structured-mode** object. Types
//! are generated from JSON Schema files in `schemas/` at build time via
//! `typify`, with the HookEventType and AgentType CloudEvents lens enums
//! hand-written in `known_types.rs` (registered via `build.rs`
//! `with_replacement`).
//!
//! See `extensions.rs` for constructor methods and helper functions.
//!
//! This is a leaf module with zero internal dependencies. Every event flowing
//! through the daemon gets wrapped in an [`EventEnvelope`]. The types here
//! are consumed by daemon handlers, the privacy filter, and the logging
//! module.

// Hand-written CloudEvents lens enums — registered with typify via build.rs.
// Must come before the generated re-export so typify-generated references to
// HookEventType / AgentType resolve to this module's types.
pub mod known_types;
pub use known_types::{AgentType, HookEventType, KNOWN_AGENT_TYPES, KNOWN_HOOK_EVENT_TYPES};

// Re-export generated types from src/generated/types.rs
// WARNING: Do NOT add struct/enum definitions here — edit schemas/ and rebuild.
pub use crate::generated::types::*;

mod extensions;
pub use extensions::*;

#[cfg(test)]
mod tests {
    use super::*;

    // Verdict response + id helpers — VerdictResponse is unchanged by the
    // CloudEvents envelope migration; these tests guard its wire contract.

    #[test]
    fn test_event_id_has_evt_prefix_and_valid_uuid_v7() {
        let id = new_event_id();
        assert!(
            id.starts_with("evt_"),
            "ID must start with evt_ prefix: {id}"
        );
        let uuid_part = id.strip_prefix("evt_").unwrap();
        let parsed = uuid::Uuid::parse_str(uuid_part).expect("ID must contain a valid UUID");
        assert_eq!(parsed.get_version_num(), 7, "UUID version must be 7");
    }

    #[test]
    fn test_consecutive_event_ids_are_monotonically_ordered() {
        let id1 = new_event_id();
        let id2 = new_event_id();
        assert!(id1 <= id2, "UUIDv7 IDs must be monotonically ordered");
    }

    #[test]
    fn test_timestamp_ends_with_z_suffix() {
        let ts = current_timestamp();
        assert!(
            ts.ends_with('Z'),
            "Timestamp must end with 'Z' for UTC: {ts}"
        );
    }

    #[test]
    fn test_verdict_response_serializes_optional_fields_omitted_when_none() {
        let resp = VerdictResponse::allow("evt-001".to_string(), 5.0);
        let json = serde_json::to_value(&resp).expect("serialization must succeed");

        assert!(json.get("reason").is_none());
        assert!(json.get("severity").is_none());
        assert!(json.get("threat_category").is_none());
        assert!(json.get("rule_id").is_none());
        assert!(json.get("details_url").is_none());

        assert_eq!(json["schema_version"], "1.0");
        assert_eq!(json["event_id"], "evt-001");
        assert_eq!(json["latency_ms"], 5.0);
    }

    #[test]
    fn test_verdict_allow_serializes_to_allow() {
        let json = serde_json::to_string(&Verdict::Allow).unwrap();
        assert_eq!(json, "\"allow\"");
    }

    #[test]
    fn test_verdict_approve_serializes_to_approve() {
        let json = serde_json::to_string(&Verdict::Approve).unwrap();
        assert_eq!(json, "\"approve\"");
    }

    #[test]
    fn test_verdict_deny_serializes_to_deny() {
        let json = serde_json::to_string(&Verdict::Deny).unwrap();
        assert_eq!(json, "\"deny\"");
    }

    // CloudEvents envelope smoke — validates the typify-generated
    // EventEnvelope round-trips the canonical CloudEvents shape. Uses
    // serde_json::Value for field assertions so the test survives minor
    // codegen changes to Rust field names (e.g. `type_` vs `r#type`).

    fn canonical_cloudevent() -> serde_json::Value {
        serde_json::json!({
            "specversion": "1.0",
            "id": "evt_019d8af1-f8da-73b3-92eb-79a99e59b10b",
            "source": "claude-code",
            "type": "pre_tool_use",
            "time": "2026-04-16T12:00:00Z",
            "datacontenttype": "application/json",
            "subject": "sess_abc123",
            "data": {"tool_name": "Bash", "tool_input": {"command": "ls -la"}},
            "os": "linux",
            "arch": "x86_64",
            "localipv4": "192.168.1.42",
            "publicipv4": "203.0.113.7",
            "clientversion": "0.2.0",
            "agentversion": "1.2.0"
        })
    }

    #[test]
    fn cloudevent_envelope_round_trip() {
        let wire = canonical_cloudevent();
        let envelope: EventEnvelope =
            serde_json::from_value(wire.clone()).expect("canonical CloudEvent must deserialise");
        let back = serde_json::to_value(&envelope).expect("must serialise back");

        // Required CloudEvents attrs preserved on the wire.
        assert_eq!(back["specversion"], "1.0");
        assert_eq!(back["id"], wire["id"]);
        assert_eq!(back["source"], "claude-code");
        assert_eq!(back["type"], "pre_tool_use");
        assert_eq!(back["time"], wire["time"]);
    }

    #[test]
    fn cloudevent_unknown_type_preserved_verbatim() {
        let mut wire = canonical_cloudevent();
        wire["type"] = serde_json::Value::String("session_replay".to_string());

        let envelope: EventEnvelope = serde_json::from_value(wire.clone())
            .expect("unknown type must deserialise (open string)");
        let back = serde_json::to_value(&envelope).expect("must serialise back");
        assert_eq!(back["type"], "session_replay");
    }

    #[test]
    fn cloudevent_unknown_source_preserved_verbatim() {
        let mut wire = canonical_cloudevent();
        wire["source"] = serde_json::Value::String("meta-llama".to_string());

        let envelope: EventEnvelope = serde_json::from_value(wire.clone())
            .expect("unknown source must deserialise (open string)");
        let back = serde_json::to_value(&envelope).expect("must serialise back");
        assert_eq!(back["source"], "meta-llama");
    }

    #[test]
    fn cloudevent_data_payload_opaque() {
        let mut wire = canonical_cloudevent();
        wire["data"] = serde_json::json!({"arbitrary": ["shape", 42, true, null]});

        let envelope: EventEnvelope = serde_json::from_value(wire.clone()).unwrap();
        let back = serde_json::to_value(&envelope).unwrap();
        assert_eq!(back["data"], wire["data"]);
    }
}