use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;
use openlatch_client::hook_output::{self, Verdict, VerdictContext};
const MAX_INPUT_SIZE: usize = 1_048_576;
const FALLBACK_MAX_BYTES: u64 = 52_428_800;
const CONNECT_TIMEOUT: Duration = Duration::from_millis(100);
const TOTAL_TIMEOUT: Duration = Duration::from_millis(500);
const CT_CLOUDEVENTS_SINGLE: &str = "application/cloudevents+json";
fn main() {
if std::env::args()
.skip(1)
.any(|a| a == "--version" || a == "-V")
{
println!("openlatch-hook {}", env!("OPENLATCH_VERSION"));
return;
}
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 args = parse_hook_args(&std::env::args().skip(1).collect::<Vec<_>>());
let agent_type = args
.agent
.or_else(|| std::env::var("OPENLATCH_AGENT_TYPE").ok())
.unwrap_or_else(|| "unknown".into());
let event_type = args
.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, token) = resolve_secrets(args.openlatch_dir.as_deref());
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);
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);
let ctx_obj = parsed.get("context").and_then(serde_json::Value::as_object);
let context_owned: Option<VerdictContext<'_>> = ctx_obj.and_then(|o| {
let headline = o.get("headline").and_then(serde_json::Value::as_str)?;
let body = o.get("body").and_then(serde_json::Value::as_str)?;
Some(VerdictContext { headline, body })
});
let context = context_owned.as_ref();
hook_output::translate(
agent,
event,
&Verdict {
decision,
reason,
context,
},
)
}
#[derive(Debug, Default, PartialEq)]
struct HookArgs {
agent: Option<String>,
event: Option<String>,
openlatch_dir: Option<PathBuf>,
}
fn parse_hook_args(args: &[String]) -> HookArgs {
let mut parsed = HookArgs::default();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if let Some(v) = arg.strip_prefix("--agent=") {
parsed.agent = Some(v.to_string());
i += 1;
} else if arg == "--agent" {
if let Some(v) = args.get(i + 1) {
parsed.agent = Some(v.clone());
i += 2;
} else {
i += 1;
}
} else if let Some(v) = arg.strip_prefix("--event=") {
parsed.event = Some(v.to_string());
i += 1;
} else if arg == "--event" {
if let Some(v) = args.get(i + 1) {
parsed.event = Some(v.clone());
i += 2;
} else {
i += 1;
}
} else if let Some(v) = arg.strip_prefix("--openlatch-dir=") {
parsed.openlatch_dir = Some(PathBuf::from(v));
i += 1;
} else if arg == "--openlatch-dir" {
if let Some(v) = args.get(i + 1) {
parsed.openlatch_dir = Some(PathBuf::from(v));
i += 2;
} else {
i += 1;
}
} else {
i += 1;
}
}
parsed
}
fn resolve_secrets(dir: Option<&Path>) -> (u16, String) {
match dir {
Some(dir) => {
let port = std::fs::read_to_string(dir.join("daemon.port"))
.ok()
.and_then(|p| p.trim().parse::<u16>().ok())
.unwrap_or(7443);
let token = std::fs::read_to_string(dir.join("daemon.token"))
.map(|t| t.trim().to_string())
.unwrap_or_default();
(port, token)
}
None => {
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();
(port, token)
}
}
}
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": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"clientversion": env!("OPENLATCH_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 forward_to_daemon(
url: &str,
token: &str,
body: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let agent = ureq::Agent::new_with_config(
ureq::Agent::config_builder()
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_global(Some(TOTAL_TIMEOUT))
.http_status_as_error(false)
.proxy(None)
.max_redirects(0)
.build(),
);
let mut response = agent
.post(url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", CT_CLOUDEVENTS_SINGLE)
.send(body)?;
Ok(response.body_mut().read_to_string()?)
}
fn append_fallback_log(event_json: &str) -> std::io::Result<()> {
let log_dir = openlatch_log_dir();
std::fs::create_dir_all(&log_dir)?;
let path = log_dir.join("fallback.jsonl");
let line_bytes = event_json.len() as u64 + 1;
enforce_fallback_cap(&path, &log_dir.join("fallback.jsonl.offset"), line_bytes);
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{event_json}")?;
Ok(())
}
fn enforce_fallback_cap(path: &std::path::Path, offset_path: &std::path::Path, line_bytes: u64) {
let Ok(metadata) = std::fs::metadata(path) else {
return;
};
let total_len = metadata.len();
if total_len + line_bytes <= FALLBACK_MAX_BYTES {
return;
}
let cursor = read_offset_local(offset_path);
let unread = total_len.saturating_sub(cursor);
if unread + line_bytes <= FALLBACK_MAX_BYTES {
return;
}
let excess = (unread + line_bytes) - FALLBACK_MAX_BYTES;
use std::io::{BufRead, BufReader, Seek, SeekFrom};
let Ok(file) = std::fs::File::open(path) else {
return;
};
let mut reader = BufReader::new(file);
if reader.seek(SeekFrom::Start(cursor)).is_err() {
return;
}
let mut advanced: u64 = 0;
let mut new_offset = cursor;
for line in reader.lines() {
let Ok(raw) = line else {
return;
};
let len = raw.len() as u64 + 1;
new_offset += len;
advanced += len;
if advanced >= excess {
break;
}
}
if new_offset == cursor {
return;
}
write_offset_local(offset_path, new_offset);
}
fn read_offset_local(path: &std::path::Path) -> u64 {
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(0)
}
fn write_offset_local(path: &std::path::Path, value: u64) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = path.with_extension("offset.tmp");
if let Ok(mut f) = std::fs::File::create(&tmp) {
use std::io::Write;
if writeln!(f, "{value}").is_ok() && f.sync_all().is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Mutex;
use std::time::Instant;
static HTTP_TEST_LOCK: Mutex<()> = Mutex::new(());
fn read_request(sock: &TcpStream) -> String {
let mut reader = BufReader::new(sock);
let mut head = String::new();
let mut content_length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).expect("read header line") == 0 {
break;
}
if let Some(v) = line
.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(str::trim)
.and_then(|v| v.parse::<usize>().ok())
{
content_length = v;
}
let done = line == "\r\n" || line == "\n";
head.push_str(&line);
if done {
break;
}
}
let mut body = vec![0u8; content_length];
if content_length > 0 {
std::io::Read::read_exact(&mut reader, &mut body).expect("read body");
}
head + &String::from_utf8_lossy(&body)
}
fn one_shot(listener: TcpListener, response: &'static str) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("accept");
let request = read_request(&sock);
sock.write_all(response.as_bytes()).expect("write response");
sock.flush().expect("flush");
request
})
}
fn loopback_listener() -> (TcpListener, String) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("local_addr").port();
(listener, format!("http://127.0.0.1:{port}/hooks"))
}
#[test]
fn posts_the_envelope_verbatim_to_the_daemon() {
let _guard = HTTP_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (listener, url) = loopback_listener();
let server = one_shot(
listener,
"HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"decision\":1}\n",
);
let body = r#"{"specversion":"1.0","type":"pre_tool_use"}"#;
let response = forward_to_daemon(&url, "tok-123", body).expect("forward");
let request = server.join().expect("server thread");
let lower = request.to_ascii_lowercase();
assert!(
request.starts_with("POST /hooks HTTP/1.1\r\n"),
"unexpected request line in:\n{request}"
);
assert!(
lower.contains("authorization: bearer tok-123\r\n"),
"missing bearer token in:\n{request}"
);
assert!(
lower.contains(&format!(
"content-type: {}\r\n",
CT_CLOUDEVENTS_SINGLE.to_ascii_lowercase()
)),
"missing CloudEvents content-type in:\n{request}"
);
assert!(
request.ends_with(body),
"body not forwarded verbatim:\n{request}"
);
assert_eq!(response, "{\"decision\":1}\n");
}
#[test]
fn non_2xx_is_returned_as_a_body_not_an_error() {
let _guard = HTTP_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (listener, url) = loopback_listener();
let server = one_shot(
listener,
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 4\r\n\r\noops",
);
let response =
forward_to_daemon(&url, "t", "{}").expect("5xx must not be a transport error");
assert_eq!(response, "oops");
server.join().expect("server thread");
}
#[test]
fn a_silent_daemon_times_out_within_the_budget() {
let _guard = HTTP_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (listener, url) = loopback_listener();
let server = std::thread::spawn(move || {
let (sock, _) = listener.accept().expect("accept");
std::thread::sleep(Duration::from_secs(3));
drop(sock);
});
let started = Instant::now();
let result = forward_to_daemon(&url, "t", "{}");
let elapsed = started.elapsed();
assert!(result.is_err(), "a silent daemon must not resolve");
assert!(
elapsed < TOTAL_TIMEOUT + Duration::from_millis(200),
"took {elapsed:?}, budget is {TOTAL_TIMEOUT:?}"
);
server.join().expect("server thread");
}
#[test]
fn proxy_environment_never_diverts_the_loopback_post() {
let _guard = HTTP_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let (listener, url) = loopback_listener();
let trap = TcpListener::bind("127.0.0.1:0").expect("bind trap");
let trap_url = format!("http://127.0.0.1:{}", trap.local_addr().unwrap().port());
let mut previous: Vec<(&str, Option<String>)> = ["NO_PROXY", "no_proxy"]
.iter()
.map(|k| {
let old = std::env::var(k).ok();
std::env::remove_var(k);
(*k, old)
})
.collect();
previous.extend(["ALL_PROXY", "HTTP_PROXY", "http_proxy"].iter().map(|k| {
let old = std::env::var(k).ok();
std::env::set_var(k, &trap_url);
(*k, old)
}));
let server = one_shot(listener, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}");
let response = forward_to_daemon(&url, "t", "{}");
for (k, v) in previous {
match v {
Some(v) => std::env::set_var(k, v),
None => std::env::remove_var(k),
}
}
assert_eq!(response.expect("forward"), "{}");
server.join().expect("server thread");
trap.set_nonblocking(true).expect("set_nonblocking");
assert!(
matches!(trap.accept(), Err(e) if e.kind() == std::io::ErrorKind::WouldBlock),
"the hook's loopback POST reached the proxy trap"
);
}
#[test]
fn hook_resolves_secrets_from_openlatch_dir_arg() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("daemon.port"), "7551\n").expect("seed daemon.port");
std::fs::write(dir.path().join("daemon.token"), " a-real-token\n")
.expect("seed daemon.token");
let (port, token) = resolve_secrets(Some(dir.path()));
assert_eq!(port, 7551, "the port comes from <dir>/daemon.port");
assert_eq!(
token, "a-real-token",
"the token comes from <dir>/daemon.token, trimmed"
);
}
#[test]
fn parse_hook_args_reads_the_openlatch_dir_flag() {
let spaced: Vec<String> = ["--openlatch-dir", "/tmp/ol"]
.iter()
.map(|s| (*s).to_string())
.collect();
assert_eq!(
parse_hook_args(&spaced).openlatch_dir,
Some(PathBuf::from("/tmp/ol"))
);
let equals: Vec<String> = vec!["--openlatch-dir=/tmp/ol".to_string()];
assert_eq!(
parse_hook_args(&equals).openlatch_dir,
Some(PathBuf::from("/tmp/ol"))
);
let with_noise: Vec<String> = [
"--surprise",
"--openlatch-dir",
"/tmp/ol",
"--agent",
"cursor",
"--future-flag=1",
]
.iter()
.map(|s| (*s).to_string())
.collect();
assert_eq!(
parse_hook_args(&with_noise),
HookArgs {
agent: Some("cursor".to_string()),
event: None,
openlatch_dir: Some(PathBuf::from("/tmp/ol")),
},
"an unknown flag is ignored, and the known ones still parse around it"
);
assert_eq!(
parse_hook_args(&[]).openlatch_dir,
None,
"absent means today's resolution, byte for byte"
);
}
}