use std::io::Read;
use std::time::Duration;
use openlatch_client::hook_output::{self, Verdict};
const MAX_INPUT_SIZE: usize = 1_048_576;
const CONNECT_TIMEOUT: Duration = Duration::from_millis(100);
const TOTAL_TIMEOUT: Duration = Duration::from_millis(500);
const CT_CLOUDEVENTS_SINGLE: &str = "application/cloudevents+json";
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut input = String::new();
let mut stdin = std::io::stdin().take(MAX_INPUT_SIZE as u64);
let _ = stdin.read_to_string(&mut input);
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());
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);
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;
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}");
}
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 })
}
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)
}
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
}
}
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"),
})
}
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()
}
fn new_event_id() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let ms: u64 = now.as_millis() as u64;
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;
}
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;
let b6 = 0x70 | (rand_bytes[0] & 0x0f);
let b7 = rand_bytes[1];
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}"
)
}
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")
}
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
}
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?)
}
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()
}