use std::io::Read;
use std::time::Duration;
const MAX_INPUT_SIZE: usize = 1_048_576;
const CONNECT_TIMEOUT: Duration = Duration::from_millis(100);
const TOTAL_TIMEOUT: Duration = Duration::from_millis(500);
#[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 event_type = detect_event_type(&input);
let hook_event_name = event_type.unwrap_or("PreToolUse");
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(_) => {
let _ = append_fallback_log(&input);
build_allow_response(hook_event_name)
}
};
println!("{}", output);
}
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("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
}
}
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",
}
}
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?)
}
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()
}
fn append_fallback_log(event_json: &str) -> std::io::Result<()> {
let home = 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(())
}
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()
}