openlatch-client 0.1.13

The open-source security layer for AI agents — client forwarder
//! Super-properties builder — attached to every outbound event by the client.
//!
//! See `.brainstorms/2026-04-13-posthog-client-telemetry.md §5.1`.
//!
//! Explicitly excluded from super-properties: IP address (PostHog project
//! configured to drop it), hostname, username, working directory, file paths,
//! command arguments, environment variable names beyond the telemetry toggles.

use serde_json::{json, Map, Value};

/// Current v1 event schema version. Bumped when any event shape changes so
/// PostHog consumers can filter by shape.
pub const EVENT_SCHEMA_VERSION: u32 = 1;

/// Immutable super-properties snapshot for this process.
#[derive(Debug, Clone)]
pub struct SuperProps {
    pub client_version: &'static str,
    pub os: &'static str,
    pub agent_id: String,
    pub session_id: String,
    pub authenticated: bool,
}

impl SuperProps {
    /// Construct from the resolved `agent_id` (from `config::ensure_agent_id`)
    /// and the current auth state. `session_id` is generated fresh per process.
    pub fn new(agent_id: String, authenticated: bool) -> Self {
        Self {
            client_version: env!("CARGO_PKG_VERSION"),
            os: os_tag(),
            agent_id,
            session_id: format!("sess_{}", uuid::Uuid::now_v7().simple()),
            authenticated,
        }
    }

    /// Merge the super-properties into an event's property map, plus a fresh
    /// `$insert_id` for PostHog /batch deduplication.
    pub fn merge_into(&self, props: &mut Map<String, Value>) {
        props.insert("client_version".into(), json!(self.client_version));
        props.insert("os".into(), json!(self.os));
        props.insert("agent_id".into(), json!(self.agent_id));
        props.insert("session_id".into(), json!(self.session_id));
        props.insert("authenticated".into(), json!(self.authenticated));
        props.insert("event_schema_version".into(), json!(EVENT_SCHEMA_VERSION));
        props.insert(
            "$insert_id".into(),
            json!(format!("ins_{}", uuid::Uuid::now_v7().simple())),
        );
    }
}

/// Compile-time OS tag in the same `{os}-{arch}` form used by the envelope.
const fn os_tag() -> &'static str {
    // target_os + target_arch combinations we explicitly support (CLAUDE.md).
    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
    {
        "darwin-arm64"
    }
    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
    {
        "darwin-x64"
    }
    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
    {
        "linux-x64"
    }
    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
    {
        "linux-arm64"
    }
    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
    {
        "win32-x64"
    }
    #[cfg(not(any(
        all(target_os = "macos", target_arch = "aarch64"),
        all(target_os = "macos", target_arch = "x86_64"),
        all(target_os = "linux", target_arch = "x86_64"),
        all(target_os = "linux", target_arch = "aarch64"),
        all(target_os = "windows", target_arch = "x86_64"),
    )))]
    {
        "unknown"
    }
}

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

    #[test]
    fn test_merge_into_attaches_all_fields() {
        let sp = SuperProps::new("agt_deadbeef".into(), false);
        let mut props = Map::new();
        sp.merge_into(&mut props);

        assert!(props.contains_key("client_version"));
        assert!(props.contains_key("os"));
        assert_eq!(props["agent_id"], "agt_deadbeef");
        assert!(props.contains_key("session_id"));
        assert_eq!(props["authenticated"], false);
        assert_eq!(props["event_schema_version"], 1);
        assert!(props.contains_key("$insert_id"));
    }

    #[test]
    fn test_session_id_is_unique_per_instance() {
        let a = SuperProps::new("agt_x".into(), true);
        let b = SuperProps::new("agt_x".into(), true);
        assert_ne!(a.session_id, b.session_id);
    }

    #[test]
    fn test_os_tag_is_known_value() {
        // Every supported build target produces a non-"unknown" tag.
        let tag = os_tag();
        let supported = [
            "darwin-arm64",
            "darwin-x64",
            "linux-x64",
            "linux-arm64",
            "win32-x64",
        ];
        if tag == "unknown" {
            // Unsupported build target in CI — just check the constant is wired.
            assert_eq!(tag, "unknown");
        } else {
            assert!(supported.contains(&tag), "unexpected os tag: {tag}");
        }
    }
}