openlatch-client 0.0.1

The open-source security layer for AI agents — client forwarder
Documentation
// openlatch-hook: Minimal hook handler binary
// Reads event JSON from stdin, POSTs to daemon, writes verdict JSON to stdout.
// Fail-open: if daemon unreachable, returns "allow" and logs to fallback.jsonl.
//
// CRITICAL: This binary must stay under 1MB and start in under 1ms.
// 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 restricted to ~/.openlatch/logs/.

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

/// Maximum stdin payload size: 1MB (mirrors daemon's request body limit).
///
/// SECURITY (T-02-10): Prevents a rogue agent or malicious input from
/// exhausting memory on an oversized payload.
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);

/// Entry point for the openlatch-hook binary.
///
/// Uses a single-threaded tokio runtime (`current_thread` flavor) to keep
/// binary size and startup overhead minimal — no thread pool needed for a
/// single in-flight HTTP request.
#[tokio::main(flavor = "current_thread")]
async fn main() {
    // Read stdin — bounded at 1MB to prevent memory exhaustion (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);

    // Determine event type from input (best-effort parse)
    let event_type = detect_event_type(&input);
    let hook_event_name = event_type.unwrap_or("PreToolUse");

    // 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 url = format!(
        "http://localhost:{}/hooks/{}",
        port,
        hook_event_name_to_path(hook_event_name)
    );

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

    let output = match result {
        Ok(response) => response,
        Err(_) => {
            // Fail-open: log to fallback.jsonl, return allow
            let _ = append_fallback_log(&input);
            build_allow_response(hook_event_name)
        }
    };

    println!("{}", output);
}

/// Best-effort event type detection from raw JSON input.
///
/// Inspects the JSON structure for well-known field names to determine
/// the Claude Code hook event type. Returns `None` if the input is not
/// valid JSON or does not match any known pattern.
///
/// The caller falls back to `"PreToolUse"` when this returns `None`.
fn detect_event_type(input: &str) -> Option<&'static str> {
    let v: serde_json::Value = serde_json::from_str(input).ok()?;
    // Claude Code sends different structures per event type:
    // - PreToolUse: has "tool_name" or "toolName" field
    // - UserPromptSubmit: has "prompt" field
    // - Stop: has "stopReason" or "stop_reason" field
    if v.get("tool_name").is_some() || v.get("toolName").is_some() {
        Some("PreToolUse")
    } else if v.get("prompt").is_some() {
        Some("UserPromptSubmit")
    } else if v.get("stopReason").is_some() || v.get("stop_reason").is_some() {
        Some("Stop")
    } else {
        None
    }
}

/// Map a Claude Code hook event name to its daemon HTTP path segment.
fn hook_event_name_to_path(name: &str) -> &'static str {
    match name {
        "PreToolUse" => "pre-tool-use",
        "UserPromptSubmit" => "user-prompt-submit",
        "Stop" => "stop",
        _ => "pre-tool-use",
    }
}

/// POST the raw event body to the daemon and return the raw response text.
///
/// Uses a short connect timeout (100ms) and total timeout (500ms) per SHIM-03
/// so a slow or absent daemon does not block the agent for long.
///
/// # Errors
///
/// Returns an error if the daemon is unreachable, the request times out,
/// or the response body cannot be read. The caller handles all errors by
/// falling back to an allow verdict.
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", "application/json")
        .body(body.to_string())
        .send()
        .await?;

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

/// Build a JSON allow verdict in the format Claude Code expects.
///
/// The decision field differs by event type: "Stop" uses "approve" while
/// all other events use "allow" per the hook stdio contract.
fn build_allow_response(hook_event_name: &str) -> String {
    let decision = if hook_event_name == "Stop" {
        "approve"
    } else {
        "allow"
    };
    serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": hook_event_name,
            "permissionDecision": decision,
            "permissionDecisionReason": "OpenLatch M1: local logging only"
        }
    })
    .to_string()
}

/// Append the raw event JSON to the fallback log file.
///
/// The fallback log is written to `~/.openlatch/logs/fallback.jsonl` per D-15.
/// Directory creation is deferred until here — nothing touches the filesystem
/// at startup.
///
/// SECURITY (T-02-11): The path is constructed by joining the home directory
/// with a fixed relative path, preventing path traversal from environment
/// variable contents.
///
/// # Errors
///
/// Returns an `io::Error` if the directory cannot be created or the file
/// cannot be opened for appending. The caller silently ignores this error
/// (fail-open: losing a fallback log entry is preferable to crashing).
fn append_fallback_log(event_json: &str) -> std::io::Result<()> {
    let home = home_dir();
    // SECURITY (T-02-11): join() enforces that the path stays under home_dir
    let log_dir = home.join(".openlatch").join("logs");
    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(())
}

/// Resolve the current user's home directory without the `dirs` crate.
///
/// Uses standard environment variables to avoid pulling in the `dirs` crate
/// dependency, keeping the hook binary minimal.
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())
    }
}

/// Read daemon port from `~/.openlatch/daemon.port` (or %APPDATA%\openlatch\ on Windows).
///
/// Returns `None` if the file doesn't exist or can't be parsed. No file I/O at startup
/// unless OPENLATCH_PORT env var is not set (lazy discovery).
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()
}