openlatch-client 0.1.5

The open-source security layer for AI agents — client forwarder
// openlatch-hook: Minimal hook handler binary (CloudEvents Mode A).
//
// Reads raw agent event JSON from stdin, wraps it in a CloudEvents v1.0.2
// structured-mode envelope, POSTs to the local daemon at /hooks, and writes
// the agent-specific hook-output JSON to stdout. The daemon speaks
// OpenLatch's agent-neutral `VerdictResponse`; this binary translates into
// whatever the caller agent expects (see `openlatch_client::hook_output`).
//
// Fail-open: if the daemon is unreachable OR its response fails to parse,
// emit the agent's silent "continue normally" shape (typically `{}`) and
// append the envelope to `fallback.jsonl` for later replay. Fail-open is
// silent on stdout — any operator-facing diagnostic goes to the log, never
// to the user's transcript.
//
// CRITICAL: binary must stay <20MB and cold-start in <3ms.
// No file I/O at startup. No DNS. No full tokio runtime.
//
// SECURITY (T-02-10): stdin read is capped at 1MB to prevent memory exhaustion.
// SECURITY (T-02-11): fallback log path is pinned under `~/.openlatch/logs/`.

use std::io::Read;
use std::time::Duration;

use openlatch_client::hook_output::{self, Verdict};

/// Maximum stdin payload size: 1MB (mirrors daemon's request body limit).
const MAX_INPUT_SIZE: usize = 1_048_576;

/// Connect timeout for daemon POST: fail fast if daemon is not listening.
const CONNECT_TIMEOUT: Duration = Duration::from_millis(100);

/// Total request timeout: allows daemon to process and respond.
const TOTAL_TIMEOUT: Duration = Duration::from_millis(500);

/// CloudEvents structured-mode Content-Type (single event).
const CT_CLOUDEVENTS_SINGLE: &str = "application/cloudevents+json";

/// Entry point for the openlatch-hook binary.
///
/// Single-threaded tokio runtime to keep binary size + startup overhead
/// minimal — one in-flight HTTP request is enough.
#[tokio::main(flavor = "current_thread")]
async fn main() {
    // Stdin is capped at MAX_INPUT_SIZE per T-02-10.
    let mut input = String::new();
    let mut stdin = std::io::stdin().take(MAX_INPUT_SIZE as u64);
    let _ = stdin.read_to_string(&mut input);

    // Agent identity comes from either `--agent`/`--event` CLI flags (preferred,
    // cross-platform) or OPENLATCH_AGENT_TYPE / OPENLATCH_EVENT_TYPE env vars
    // (POSIX-only fallback). Hook-config generators emit CLI flags so the
    // command string works identically on Windows cmd.exe, PowerShell, and
    // POSIX shells. Unknown/missing values fall through to best-effort
    // inference — a misconfigured hook still produces a valid CloudEvent.
    let (cli_agent, cli_event) = parse_agent_event_args();
    let agent_type = cli_agent
        .or_else(|| std::env::var("OPENLATCH_AGENT_TYPE").ok())
        .unwrap_or_else(|| "unknown".into());
    let event_type = cli_event
        .or_else(|| std::env::var("OPENLATCH_EVENT_TYPE").ok())
        .or_else(|| detect_event_type(&input).map(str::to_string))
        .unwrap_or_else(|| "unknown".into());

    // Port resolution: OPENLATCH_PORT env > daemon.port file > default 7443.
    let port = std::env::var("OPENLATCH_PORT")
        .ok()
        .and_then(|p| p.parse::<u16>().ok())
        .or_else(read_port_file)
        .unwrap_or(7443);
    let token = std::env::var("OPENLATCH_TOKEN").unwrap_or_default();

    let envelope = build_cloudevent(&agent_type, &event_type, &input);

    // SECURITY: daemon binds 127.0.0.1 only. We hit the literal IPv4 loopback
    // rather than `localhost` because on Windows `localhost` often resolves
    // `::1` first — the daemon isn't listening there, and the 100ms connect
    // timeout would elapse before the IPv4 fallback kicks in.
    let url = format!("http://127.0.0.1:{port}/hooks");
    let body = serde_json::to_string(&envelope).unwrap_or_else(|_| "{}".to_string());

    let result = forward_to_daemon(&url, &token, &body).await;

    // Success path: parse the daemon's `VerdictResponse` and translate to
    // the caller agent's stdout shape. Parse failure degrades to the
    // silent fail-open default so a malformed daemon response never
    // surfaces a schema-validation error in the agent.
    // Failure path: log the envelope for later replay, emit the agent's
    // silent "continue normally" shape. No user-visible diagnostic.
    let output = match result {
        Ok(response) if !response.is_empty() => {
            translate_daemon_response(&agent_type, &event_type, &response)
        }
        _ => {
            let _ = append_fallback_log(&body);
            hook_output::translate(&agent_type, &event_type, &Verdict::allow())
        }
    };

    println!("{output}");
}

/// Parse the daemon's `VerdictResponse` body and translate it into the
/// caller agent's stdout JSON. Returns the silent allow shape if the body
/// isn't valid JSON — a defensive posture so daemon-side issues never
/// trip the agent's output validator.
fn translate_daemon_response(agent: &str, event: &str, body: &str) -> serde_json::Value {
    let parsed: serde_json::Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(_) => return hook_output::translate(agent, event, &Verdict::allow()),
    };
    let decision = parsed
        .get("verdict")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("allow");
    let reason = parsed.get("reason").and_then(serde_json::Value::as_str);
    hook_output::translate(agent, event, &Verdict { decision, reason })
}

/// Parse `--agent <slug>` and `--event <name>` from argv without pulling in
/// the `clap` crate (binary stays <20MB). Accepts `--agent=X` / `--event=X`
/// too. Unknown flags are silently ignored — the hook binary must never
/// fail-closed because the agent handed it something unexpected.
fn parse_agent_event_args() -> (Option<String>, Option<String>) {
    let mut agent: Option<String> = None;
    let mut event: Option<String> = None;
    let args: Vec<String> = std::env::args().skip(1).collect();
    let mut i = 0;
    while i < args.len() {
        let arg = &args[i];
        if let Some(v) = arg.strip_prefix("--agent=") {
            agent = Some(v.to_string());
            i += 1;
        } else if arg == "--agent" {
            if let Some(v) = args.get(i + 1) {
                agent = Some(v.clone());
                i += 2;
            } else {
                i += 1;
            }
        } else if let Some(v) = arg.strip_prefix("--event=") {
            event = Some(v.to_string());
            i += 1;
        } else if arg == "--event" {
            if let Some(v) = args.get(i + 1) {
                event = Some(v.clone());
                i += 2;
            } else {
                i += 1;
            }
        } else {
            i += 1;
        }
    }
    (agent, event)
}

/// Best-effort event type detection from raw JSON input.
///
/// Inspects the JSON structure for well-known field names to infer the
/// Claude Code hook event name. Only used as a fallback when
/// `OPENLATCH_EVENT_TYPE` is not set in the hook config.
fn detect_event_type(input: &str) -> Option<&'static str> {
    let v: serde_json::Value = serde_json::from_str(input).ok()?;
    if v.get("tool_name").is_some() || v.get("toolName").is_some() {
        Some("pre_tool_use")
    } else if v.get("prompt").is_some() {
        Some("user_prompt_submit")
    } else if v.get("stopReason").is_some() || v.get("stop_reason").is_some() {
        Some("stop")
    } else {
        None
    }
}

/// Build a CloudEvents v1.0.2 structured-mode envelope around the raw agent
/// payload. All OpenLatch metadata lives as lowercase-alphanumeric extension
/// attributes per the spec. Daemon stamps any missing extension values
/// (os/arch/localipv4/…) after receipt — the hook binary sets only what it
/// knows without touching disk.
fn build_cloudevent(agent_type: &str, event_type: &str, raw_input: &str) -> serde_json::Value {
    let data: serde_json::Value =
        serde_json::from_str(raw_input).unwrap_or(serde_json::Value::Null);
    let subject = extract_session_id(&data);

    serde_json::json!({
        "specversion": "1.0",
        "id": new_event_id(),
        "source": agent_type,
        "type": event_type,
        "time": now_rfc3339_z(),
        "datacontenttype": "application/json",
        "subject": subject,
        "data": data,
        "os": os_str(),
        "arch": arch_str(),
        "clientversion": env!("CARGO_PKG_VERSION"),
    })
}

/// Extract `session_id` (Claude-Code-style) or `sessionId` from the raw event
/// body for use as the CloudEvents `subject` attribute.
fn extract_session_id(data: &serde_json::Value) -> String {
    data.get("session_id")
        .or_else(|| data.get("sessionId"))
        .and_then(|v| v.as_str())
        .unwrap_or("unknown")
        .to_string()
}

/// Generate an `evt_<UUIDv7>` identifier. Inlined here instead of depending
/// on the `uuid` crate so the hook binary stays minimal — UUIDv7 is 128 bits
/// of (unix_ts_ms << 80) | (rand | version | variant).
fn new_event_id() -> String {
    // Pull current time in milliseconds since the Unix epoch.
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default();
    let ms: u64 = now.as_millis() as u64;

    // Fill the low 10 bytes with process-derived randomness. We use the
    // nanosecond fraction + process id, which is adequate within the hook
    // binary's purpose — dedup and audit correlation do not need crypto-grade
    // randomness.
    let nanos = now.subsec_nanos() as u64;
    let pid = std::process::id() as u64;
    let mut rand_bytes = [0u8; 10];
    let mix = nanos.wrapping_mul(0x9e37_79b9_7f4a_7c15).wrapping_add(pid);
    for (i, b) in rand_bytes.iter_mut().enumerate() {
        *b = ((mix >> ((i % 8) * 8)) & 0xff) as u8;
    }

    // 48-bit ms timestamp
    let b0 = ((ms >> 40) & 0xff) as u8;
    let b1 = ((ms >> 32) & 0xff) as u8;
    let b2 = ((ms >> 24) & 0xff) as u8;
    let b3 = ((ms >> 16) & 0xff) as u8;
    let b4 = ((ms >> 8) & 0xff) as u8;
    let b5 = (ms & 0xff) as u8;

    // Byte 6: 0x7X (version 7) + high nibble of rand
    let b6 = 0x70 | (rand_bytes[0] & 0x0f);
    let b7 = rand_bytes[1];
    // Byte 8: 0b10xxxxxx (RFC 4122 variant)
    let b8 = 0x80 | (rand_bytes[2] & 0x3f);
    let b9 = rand_bytes[3];
    let b10 = rand_bytes[4];
    let b11 = rand_bytes[5];
    let b12 = rand_bytes[6];
    let b13 = rand_bytes[7];
    let b14 = rand_bytes[8];
    let b15 = rand_bytes[9];

    format!(
        "evt_{b0:02x}{b1:02x}{b2:02x}{b3:02x}-{b4:02x}{b5:02x}-{b6:02x}{b7:02x}-{b8:02x}{b9:02x}-{b10:02x}{b11:02x}{b12:02x}{b13:02x}{b14:02x}{b15:02x}"
    )
}

/// Current UTC time as RFC 3339 with the `Z` suffix.
///
/// Hand-rolled to avoid the chrono dependency on the hook-binary code path —
/// chrono is a full-cli-only dep and the hook binary must stay minimal.
fn now_rfc3339_z() -> String {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default();
    let secs = now.as_secs();
    let s = secs % 60;
    let m = (secs / 60) % 60;
    let h = (secs / 3600) % 24;
    let days = secs / 86400;
    let (year, month, day) = days_to_ymd(days);
    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
}

/// Proleptic Gregorian calendar: days since 1970-01-01 → (year, month, day).
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
    let z = days + 719468;
    let era = z / 146097;
    let doe = z % 146097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}

fn os_str() -> &'static str {
    std::env::consts::OS
}

fn arch_str() -> &'static str {
    std::env::consts::ARCH
}

/// POST the serialised CloudEvent to the daemon and return the raw response.
async fn forward_to_daemon(
    url: &str,
    token: &str,
    body: &str,
) -> Result<String, Box<dyn std::error::Error>> {
    let client = reqwest::Client::builder()
        .connect_timeout(CONNECT_TIMEOUT)
        .timeout(TOTAL_TIMEOUT)
        .build()?;

    let response = client
        .post(url)
        .header("Authorization", format!("Bearer {token}"))
        .header("Content-Type", CT_CLOUDEVENTS_SINGLE)
        .body(body.to_string())
        .send()
        .await?;

    Ok(response.text().await?)
}

/// Append the serialised CloudEvent to the fallback log file.
///
/// Path resolution mirrors `config::openlatch_dir()` so the `openlatch logs`
/// command sees these events alongside the daemon's `events-*.jsonl`:
/// `OPENLATCH_DIR` wins, else `%APPDATA%\openlatch` on Windows / `~/.openlatch`
/// elsewhere. We resolve it inline rather than pulling in the `dirs` crate —
/// the hook binary must stay <20MB.
fn append_fallback_log(event_json: &str) -> std::io::Result<()> {
    let log_dir = openlatch_log_dir();
    std::fs::create_dir_all(&log_dir)?;

    use std::io::Write;
    let mut file = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(log_dir.join("fallback.jsonl"))?;
    writeln!(file, "{event_json}")?;
    Ok(())
}

fn openlatch_log_dir() -> std::path::PathBuf {
    if let Ok(dir) = std::env::var("OPENLATCH_DIR") {
        if !dir.is_empty() {
            return std::path::PathBuf::from(dir).join("logs");
        }
    }
    #[cfg(windows)]
    {
        std::env::var("APPDATA")
            .map(std::path::PathBuf::from)
            .unwrap_or_else(|_| home_dir())
            .join("openlatch")
            .join("logs")
    }
    #[cfg(not(windows))]
    {
        home_dir().join(".openlatch").join("logs")
    }
}

fn home_dir() -> std::path::PathBuf {
    #[cfg(unix)]
    {
        std::env::var("HOME")
            .map(Into::into)
            .unwrap_or_else(|_| "/tmp".into())
    }
    #[cfg(windows)]
    {
        std::env::var("USERPROFILE")
            .map(Into::into)
            .unwrap_or_else(|_| "C:\\Temp".into())
    }
}

fn read_port_file() -> Option<u16> {
    #[cfg(windows)]
    let path = std::env::var("APPDATA")
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|_| home_dir())
        .join("openlatch")
        .join("daemon.port");
    #[cfg(not(windows))]
    let path = home_dir().join(".openlatch").join("daemon.port");
    std::fs::read_to_string(path)
        .ok()?
        .trim()
        .parse::<u16>()
        .ok()
}