use std::sync::atomic::{AtomicU32, Ordering};
use futures_util::future::BoxFuture;
use hotl_engine::hooks::{cap_payload, Hooks, PreToolDecision};
use hotl_tools::{net, sandbox};
use serde::Deserialize;
use serde_json::{json, Value};
const MAX_STRIKES: u32 = 3;
const HOOK_TIMEOUT_SECS: u64 = 10;
const HOOK_MAX_OUTPUT: usize = 64 * 1024;
#[derive(Debug, Clone, Deserialize)]
struct HookSpec {
event: String,
command: String,
}
#[derive(Debug, Default, Deserialize)]
struct HooksFile {
#[serde(default, rename = "hook")]
hooks: Vec<HookSpec>,
}
struct ShellHook {
command: String,
strikes: AtomicU32,
}
pub struct ShellHooks {
pre: Vec<ShellHook>,
post: Vec<ShellHook>,
}
pub fn load_str(raw: &str) -> Option<ShellHooks> {
let parsed: HooksFile = toml::from_str(raw).ok()?;
let mut pre = Vec::new();
let mut post = Vec::new();
for spec in parsed.hooks {
let hook = ShellHook {
command: spec.command,
strikes: AtomicU32::new(0),
};
match spec.event.as_str() {
"pre_tool" => pre.push(hook),
"post_tool" => post.push(hook),
_ => {} }
}
if pre.is_empty() && post.is_empty() {
return None;
}
Some(ShellHooks { pre, post })
}
impl ShellHook {
async fn invoke(&self, payload: &Value) -> Option<Value> {
if self.strikes.load(Ordering::Relaxed) >= MAX_STRIKES {
return None; }
let egress = net::egress_state().await;
let mut cmd = sandbox::build_command(&self.command, &sandbox::probe(), &egress);
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
let child = cmd.spawn().ok();
let Some(mut child) = child else {
self.strike();
return None;
};
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(payload.to_string().as_bytes()).await;
let _ = stdin.shutdown().await;
}
let mut stdout = child.stdout.take();
let out = tokio::time::timeout(std::time::Duration::from_secs(HOOK_TIMEOUT_SECS), async {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
if let Some(so) = stdout.as_mut() {
let _ = so
.take((HOOK_MAX_OUTPUT + 1) as u64)
.read_to_end(&mut buf)
.await;
}
drop(stdout);
(child.wait().await, buf)
})
.await;
match out {
Ok((Ok(status), buf)) if status.success() && buf.len() <= HOOK_MAX_OUTPUT => {
serde_json::from_slice(&buf).ok().or_else(|| {
Some(json!({"decision": "continue"}))
})
}
_ => {
self.strike();
None
}
}
}
fn strike(&self) {
let n = self.strikes.fetch_add(1, Ordering::Relaxed) + 1;
if n == MAX_STRIKES {
eprintln!(
"hotl: hook `{}` failed {MAX_STRIKES}× — evicted for this session",
self.command
);
}
}
}
impl Hooks for ShellHooks {
fn pre_tool<'a>(&'a self, name: &'a str, input: &'a Value) -> BoxFuture<'a, PreToolDecision> {
Box::pin(async move {
let payload = json!({"event": "pre_tool", "tool": name, "input": input});
for hook in &self.pre {
let Some(decision) = hook.invoke(&payload).await else {
continue;
};
match decision.get("decision").and_then(Value::as_str) {
Some("deny") => {
let message = decision
.get("message")
.and_then(Value::as_str)
.unwrap_or("blocked by a hook")
.to_string();
return PreToolDecision::Deny { message };
}
Some("rewrite") => {
if let Some(new_input) = decision.get("input") {
return PreToolDecision::Rewrite {
input: new_input.clone(),
};
}
}
_ => {} }
}
PreToolDecision::Continue
})
}
fn post_tool<'a>(&'a self, name: &'a str, result: &'a str) -> BoxFuture<'a, Option<String>> {
Box::pin(async move {
let capped = cap_payload(result);
let mut current: Option<String> = None;
for hook in &self.post {
let view = current.as_deref().unwrap_or(capped);
let payload = json!({"event": "post_tool", "tool": name, "result": view});
if let Some(decision) = hook.invoke(&payload).await {
if let Some(replacement) = decision.get("result").and_then(Value::as_str) {
if !replacement.is_empty() {
current = Some(replacement.to_string());
}
}
}
}
current
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn pre_hook_denies_over_stdio() {
let hooks = load_str(
"[[hook]]\nevent = \"pre_tool\"\n\
command = \"cat >/dev/null; echo '{\\\"decision\\\":\\\"deny\\\",\\\"message\\\":\\\"shell says no\\\"}'\"\n",
).expect("hooks configured");
let decision = hooks.pre_tool("bash", &json!({"command": "ls"})).await;
assert_eq!(
decision,
PreToolDecision::Deny {
message: "shell says no".into()
}
);
}
#[tokio::test]
async fn post_hook_replaces_result_and_none_when_unconfigured() {
let hooks = load_str(
"[[hook]]\nevent = \"post_tool\"\n\
command = \"cat >/dev/null; echo '{\\\"result\\\":\\\"cleaned\\\"}'\"\n",
)
.unwrap();
assert_eq!(
hooks.post_tool("read", "raw output").await.as_deref(),
Some("cleaned")
);
assert!(load_str("# no hooks here\n").is_none());
}
#[tokio::test]
async fn failing_hook_is_evicted_after_three_strikes() {
let hooks = load_str("[[hook]]\nevent = \"pre_tool\"\ncommand = \"exit 1\"\n").unwrap();
for _ in 0..5 {
assert_eq!(
hooks.pre_tool("bash", &json!({})).await,
PreToolDecision::Continue
);
}
assert!(hooks.pre[0].strikes.load(Ordering::Relaxed) >= MAX_STRIKES);
}
}