use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use crate::error::{Error, Result};
use crate::event::AgentEvent;
use crate::hooks::config::{matches_hook, HookCommand, HookCommandType, HooksConfig};
use crate::llm::LlmProvider;
use crate::message::Message;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_util::sync::CancellationToken;
pub use crate::hooks::HookAction;
const HOOK_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum HookEvent {
PreToolCall,
PostToolCall,
PermissionRequest,
PostToolCallFailure,
PermissionDenied,
SessionStart,
SessionEnd,
UserPromptSubmit,
Stop,
SubagentStart,
SubagentStop,
Notification,
Setup,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HookInput {
pub event: HookEvent,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<serde_json::Value>,
pub mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub depth: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
enum JsonAction {
Continue,
Skip,
Error,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct HookOutput {
#[serde(default)]
action: Option<JsonAction>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
additional_context: Option<String>,
#[serde(default)]
updated_input: Option<serde_json::Value>,
#[serde(default)]
system_message: Option<String>,
#[serde(default)]
suppress_output: bool,
#[serde(default)]
permission_decision: Option<String>,
#[serde(default)]
permission_decision_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PermissionDecision {
Allow,
Deny,
Passthrough,
}
impl PermissionDecision {
fn from_str(s: &str) -> Option<Self> {
match s {
"allow" => Some(Self::Allow),
"deny" => Some(Self::Deny),
"passthrough" => Some(Self::Passthrough),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct HookResult {
pub action: HookAction,
pub additional_context: Option<String>,
pub updated_input: Option<serde_json::Value>,
pub system_message: Option<String>,
pub suppress_output: bool,
pub permission_decision: Option<PermissionDecision>,
pub permission_decision_reason: Option<String>,
}
impl Default for HookResult {
fn default() -> Self {
Self {
action: HookAction::Continue,
additional_context: None,
updated_input: None,
system_message: None,
suppress_output: false,
permission_decision: None,
permission_decision_reason: None,
}
}
}
impl HookResult {
pub fn continue_default() -> Self {
Self::default()
}
}
impl HookOutput {
fn into_hook_result(self) -> HookResult {
let action = match self.action.unwrap_or(JsonAction::Continue) {
JsonAction::Continue => HookAction::Continue,
JsonAction::Skip => HookAction::Skip,
JsonAction::Error => HookAction::Error(
self.message
.clone()
.unwrap_or_else(|| "external hook blocked".to_string()),
),
};
let permission_decision = self
.permission_decision
.as_deref()
.and_then(PermissionDecision::from_str);
HookResult {
action,
additional_context: self.additional_context,
updated_input: self.updated_input,
system_message: self.system_message,
suppress_output: self.suppress_output,
permission_decision,
permission_decision_reason: self.permission_decision_reason,
}
}
}
#[derive(Clone, Debug)]
enum ResolvedHookKind {
Command(PathBuf),
Http {
url: String,
headers: Option<std::collections::HashMap<String, String>>,
allowed_env_vars: Option<Vec<String>>,
},
Prompt {
prompt: String,
},
}
#[derive(Clone, Debug)]
struct ResolvedHook {
kind: ResolvedHookKind,
timeout_secs: Option<u64>,
event_name: Option<String>,
matcher: Option<String>,
once: bool,
r#async: bool,
async_rewake: bool,
}
#[derive(Clone)]
pub struct ExternalHookRunner {
hooks: Vec<ResolvedHook>,
llm: Option<Arc<dyn LlmProvider>>,
executed_once: Arc<Mutex<HashSet<usize>>>,
pub cancel_token: Option<CancellationToken>,
event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
}
impl ExternalHookRunner {
pub fn discover(dirs: &[PathBuf]) -> Self {
let mut hooks = Vec::new();
for dir in dirs {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if is_executable(&path) {
hooks.push(ResolvedHook {
kind: ResolvedHookKind::Command(path),
timeout_secs: None,
event_name: None,
matcher: None,
once: false,
r#async: false,
async_rewake: false,
});
}
}
}
}
Self {
hooks,
llm: None,
executed_once: Arc::new(Mutex::new(HashSet::new())),
cancel_token: None,
event_tx: None,
}
}
pub fn from_config(config: HooksConfig) -> Self {
Self::from_config_with_llm(config, None)
}
pub fn from_config_with_llm(config: HooksConfig, llm: Option<Arc<dyn LlmProvider>>) -> Self {
let mut hooks = Vec::new();
for (event_name, matchers) in config.events {
for matcher_entry in matchers {
for cmd in &matcher_entry.hooks {
if let Some(resolved) = Self::resolve_command(
cmd,
Some(event_name.clone()),
matcher_entry.matcher.clone(),
) {
hooks.push(resolved);
}
}
}
}
Self {
hooks,
llm,
executed_once: Arc::new(Mutex::new(HashSet::new())),
cancel_token: None,
event_tx: None,
}
}
pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
self.cancel_token = Some(token);
self
}
pub fn with_event_tx(mut self, tx: mpsc::UnboundedSender<AgentEvent>) -> Self {
self.event_tx = Some(tx);
self
}
fn emit(&self, event: AgentEvent) {
if let Some(tx) = &self.event_tx {
let _ = tx.send(event);
}
}
fn resolve_command(
cmd: &HookCommand,
event_name: Option<String>,
matcher: Option<String>,
) -> Option<ResolvedHook> {
let base = |kind: ResolvedHookKind| ResolvedHook {
kind,
timeout_secs: Some(cmd.timeout),
event_name,
matcher,
once: cmd.once,
r#async: cmd.r#async,
async_rewake: cmd.async_rewake,
};
match cmd.r#type {
HookCommandType::Command => {
let command_str = cmd.command.as_deref()?;
let path = expand_tilde(command_str);
Some(base(ResolvedHookKind::Command(path)))
}
HookCommandType::Http => {
let url = cmd.url.clone()?;
Some(base(ResolvedHookKind::Http {
url,
headers: None,
allowed_env_vars: None,
}))
}
HookCommandType::Prompt | HookCommandType::Agent => {
let prompt = cmd.prompt.clone()?;
Some(base(ResolvedHookKind::Prompt { prompt }))
}
}
}
pub fn len(&self) -> usize {
self.hooks.len()
}
pub fn is_empty(&self) -> bool {
self.hooks.is_empty()
}
pub async fn dispatch(&self, input: &HookInput) -> HookResult {
let event_str = serde_json::to_string(&input.event)
.unwrap_or_default()
.trim_matches('"')
.to_string();
let tool_name = input.tool_name.as_deref().unwrap_or("");
let empty_args = serde_json::Value::Object(Default::default());
let args = input.args.as_ref().unwrap_or(&empty_args);
for (idx, hook) in self.hooks.iter().enumerate() {
if let Some(ref ev) = hook.event_name {
if !event_names_match(ev, &event_str) {
continue;
}
}
if !matches_hook(&hook.matcher, tool_name, args) {
continue;
}
if hook.once {
let already_ran = {
let guard = self.executed_once.lock().unwrap();
guard.contains(&idx)
};
if already_ran {
continue;
}
}
if hook.r#async && !hook.async_rewake {
let hook_clone = hook.clone();
let input_clone = input.clone();
let self_clone = self.clone();
tokio::spawn(async move {
if let Err(e) = self_clone.run_hook(&hook_clone, &input_clone).await {
tracing::warn!("async hook error: {e}");
}
});
if hook.once {
self.executed_once.lock().unwrap().insert(idx);
}
continue;
}
if hook.async_rewake {
let hook_clone = hook.clone();
let input_clone = input.clone();
let self_clone = self.clone();
let cancel = self.cancel_token.clone();
tokio::spawn(async move {
let exit_code = self_clone
.run_command_exit_code(&hook_clone, &input_clone)
.await
.unwrap_or(None);
if exit_code == Some(2) {
tracing::warn!("asyncRewake hook exited with code 2 — cancelling agent");
if let Some(token) = cancel {
token.cancel();
}
}
});
if hook.once {
self.executed_once.lock().unwrap().insert(idx);
}
continue;
}
let hook_display_name = match &hook.kind {
ResolvedHookKind::Command(path) => path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "hook".to_string()),
ResolvedHookKind::Http { url, .. } => url.clone(),
ResolvedHookKind::Prompt { .. } => "prompt-hook".to_string(),
};
self.emit(AgentEvent::HookStarted {
hook_event: event_str.clone(),
hook_name: hook_display_name.clone(),
status_message: None,
});
let start = std::time::Instant::now();
if let Ok(result) = self.run_hook(hook, input).await {
if hook.once {
self.executed_once.lock().unwrap().insert(idx);
}
let duration_ms = start.elapsed().as_millis() as u64;
let outcome = match &result.action {
HookAction::Continue => "continue".to_string(),
HookAction::Skip => "skip".to_string(),
HookAction::Error(reason) => format!("error: {reason}"),
};
if let Some(msg) = &result.system_message {
self.emit(AgentEvent::HookSystemMessage { text: msg.clone() });
}
self.emit(AgentEvent::HookFinished {
hook_event: event_str.clone(),
hook_name: hook_display_name.clone(),
outcome,
duration_ms,
});
if !matches!(result.action, HookAction::Continue) {
return result;
}
}
}
HookResult::continue_default()
}
async fn run_hook(&self, hook: &ResolvedHook, input: &HookInput) -> Result<HookResult> {
let hook_timeout = hook
.timeout_secs
.map(Duration::from_secs)
.unwrap_or(HOOK_TIMEOUT);
match &hook.kind {
ResolvedHookKind::Http {
url,
headers,
allowed_env_vars,
} => {
let result = run_http_hook(
url,
input,
hook_timeout.as_secs(),
headers.as_ref(),
allowed_env_vars.as_deref(),
)
.await;
Ok(result)
}
ResolvedHookKind::Prompt { prompt } => {
if let Some(llm) = &self.llm {
let result = run_prompt_hook(llm.as_ref(), prompt, input, hook_timeout).await;
Ok(result)
} else {
tracing::warn!(
"prompt hook configured but no LLM provider available; skipping"
);
Ok(HookResult::continue_default())
}
}
ResolvedHookKind::Command(path) => {
self.run_command_hook(path, input, hook_timeout).await
}
}
}
async fn run_command_exit_code(
&self,
hook: &ResolvedHook,
input: &HookInput,
) -> Result<Option<i32>> {
let ResolvedHookKind::Command(path) = &hook.kind else {
return Ok(None);
};
let hook_timeout = hook
.timeout_secs
.map(Duration::from_secs)
.unwrap_or(HOOK_TIMEOUT);
let input_json = serde_json::to_string(input).map_err(|e| Error::Config {
message: format!("hook input serialize: {e}"),
})?;
let mut child = Command::new(path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| Error::Config {
message: format!("hook spawn {}: {e}", path.display()),
})?;
let status = timeout(hook_timeout, async {
use tokio::io::AsyncWriteExt;
if let Some(stdin) = child.stdin.as_mut() {
let _ = stdin.write_all(input_json.as_bytes()).await;
let _ = stdin.shutdown().await;
}
child.wait().await
})
.await;
match status {
Ok(Ok(s)) => Ok(s.code()),
_ => Ok(None),
}
}
async fn run_command_hook(
&self,
path: &PathBuf,
input: &HookInput,
hook_timeout: Duration,
) -> Result<HookResult> {
let input_json = serde_json::to_string(input).map_err(|e| Error::Config {
message: format!("hook input serialize: {e}"),
})?;
let mut child = Command::new(path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| Error::Config {
message: format!("hook spawn {}: {e}", path.display()),
})?;
let output = timeout(hook_timeout, async {
use tokio::io::AsyncWriteExt;
if let Some(stdin) = child.stdin.as_mut() {
let _ = stdin.write_all(input_json.as_bytes()).await;
let _ = stdin.shutdown().await;
}
child.wait_with_output().await
})
.await
.map_err(|_| Error::Config {
message: format!("hook timeout: {}", path.display()),
})?
.map_err(|e| Error::Config {
message: format!("hook wait {}: {e}", path.display()),
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: HookOutput =
serde_json::from_str(stdout.trim()).map_err(|e| Error::Config {
message: format!("hook output parse {}: {e}", path.display()),
})?;
Ok(parsed.into_hook_result())
}
}
fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(path)
}
fn event_names_match(config_name: &str, wire_name: &str) -> bool {
config_name.to_lowercase() == wire_name.to_lowercase()
}
async fn run_prompt_hook(
llm: &dyn LlmProvider,
prompt_template: &str,
input: &HookInput,
hook_timeout: Duration,
) -> HookResult {
let args_json = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
let prompt = prompt_template.replace("$ARGUMENTS", &args_json);
let messages = [Message::user(prompt)];
let completion_future = llm.complete(&messages, &[]);
let completion = match timeout(hook_timeout, completion_future).await {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
tracing::warn!("prompt hook LLM error: {e}");
return HookResult::continue_default();
}
Err(_) => {
tracing::warn!("prompt hook timeout");
return HookResult::continue_default();
}
};
match serde_json::from_str::<HookOutput>(completion.content.trim()) {
Ok(output) => output.into_hook_result(),
Err(_) => {
HookResult::continue_default()
}
}
}
pub(crate) async fn run_http_hook(
url: &str,
input: &HookInput,
timeout_secs: u64,
headers: Option<&std::collections::HashMap<String, String>>,
allowed_env_vars: Option<&[String]>,
) -> HookResult {
let client_result = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.connect_timeout(Duration::from_secs(timeout_secs))
.build();
let client = match client_result {
Ok(c) => c,
Err(_) => return HookResult::continue_default(),
};
let mut builder = client.post(url).json(input);
if let Some(headers_map) = headers {
for (k, v) in headers_map {
let interpolated = interpolate_env_vars(v, allowed_env_vars);
builder = builder.header(k.as_str(), interpolated);
}
}
let response = match builder.send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!("http hook request failed: {e}");
return HookResult::continue_default();
}
};
let body = match response.text().await {
Ok(b) => b,
Err(e) => {
tracing::warn!("http hook response read failed: {e}");
return HookResult::continue_default();
}
};
match serde_json::from_str::<HookOutput>(body.trim()) {
Ok(output) => output.into_hook_result(),
Err(e) => {
tracing::warn!("http hook response parse failed: {e}");
HookResult::continue_default()
}
}
}
fn interpolate_env_vars(value: &str, allowed: Option<&[String]>) -> String {
let mut result = value.to_string();
for (pattern, var_name) in find_env_var_refs(&result) {
let replacement = if is_allowed(&var_name, allowed) {
std::env::var(&var_name).unwrap_or_default()
} else {
String::new()
};
result = result.replacen(&pattern, &replacement, 1);
}
result
}
fn find_env_var_refs(s: &str) -> Vec<(String, String)> {
let mut refs = Vec::new();
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' {
if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
if let Some(end) = s[i + 2..].find('}') {
let var_name = &s[i + 2..i + 2 + end];
refs.push((format!("${{{var_name}}}"), var_name.to_string()));
i += 3 + end;
continue;
}
} else {
let start = i + 1;
let mut end = start;
while end < bytes.len()
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
{
end += 1;
}
if end > start {
let var_name = &s[start..end];
refs.push((format!("${var_name}"), var_name.to_string()));
i = end;
continue;
}
}
}
i += 1;
}
refs
}
fn is_allowed(var: &str, allowed: Option<&[String]>) -> bool {
let Some(list) = allowed else { return true };
list.iter().any(|a| a == var)
}
fn is_executable(path: &std::path::Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
path.is_file()
&& std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
let _ = path;
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool_input(event: HookEvent, tool: &str, args: serde_json::Value) -> HookInput {
HookInput {
event,
tool_name: Some(tool.to_string()),
args: Some(args),
mode: "ask".to_string(),
content: None,
message: None,
depth: None,
reason: None,
error: None,
}
}
#[test]
fn hook_output_parse_continue() {
let json = r#"{"action":"continue"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
assert!(matches!(out.action, Some(JsonAction::Continue)));
assert!(out.message.is_none());
}
#[test]
fn hook_output_parse_skip() {
let json = r#"{"action":"skip","message":"blocked"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
assert!(matches!(out.action, Some(JsonAction::Skip)));
assert_eq!(out.message.as_deref(), Some("blocked"));
}
#[test]
fn hook_output_parse_error() {
let json = r#"{"action":"error","message":"not allowed"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
assert!(matches!(out.action, Some(JsonAction::Error)));
assert_eq!(out.message.as_deref(), Some("not allowed"));
}
#[test]
fn hook_output_parse_camel_case() {
let json = r#"{"action":"continue"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
assert!(matches!(out.action, Some(JsonAction::Continue)));
let json = r#"{"action":"skip","message":"nope"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
assert!(matches!(out.action, Some(JsonAction::Skip)));
}
#[test]
fn hook_output_parse_missing_message() {
let json = r#"{"action":"error"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
assert!(matches!(out.action, Some(JsonAction::Error)));
assert!(out.message.is_none());
}
#[test]
fn hook_output_into_hook_action_continue() {
let json = r#"{"action":"continue"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
let result = out.into_hook_result();
assert!(matches!(result.action, HookAction::Continue));
}
#[test]
fn hook_output_into_hook_action_skip() {
let json = r#"{"action":"skip","message":"blocked"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
let result = out.into_hook_result();
assert!(matches!(result.action, HookAction::Skip));
}
#[test]
fn hook_output_into_hook_action_error_with_message() {
let json = r#"{"action":"error","message":"not allowed"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
let result = out.into_hook_result();
assert!(matches!(result.action, HookAction::Error(ref msg) if msg == "not allowed"));
}
#[test]
fn hook_output_into_hook_action_error_without_message() {
let json = r#"{"action":"error"}"#;
let out: HookOutput = serde_json::from_str(json).unwrap();
let result = out.into_hook_result();
assert!(
matches!(result.action, HookAction::Error(ref msg) if msg == "external hook blocked")
);
}
#[test]
fn empty_runner_returns_continue() {
let runner = ExternalHookRunner::discover(&[]);
assert!(runner.is_empty());
assert_eq!(runner.len(), 0);
}
#[test]
fn discover_skips_non_executable() {
let tmp = tempfile::tempdir().unwrap();
let non_exec = tmp.path().join("script.sh");
std::fs::write(&non_exec, "echo hello").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&non_exec).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&non_exec, perms).unwrap();
}
let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
assert!(runner.is_empty());
}
#[test]
#[cfg(unix)]
fn discover_collects_executable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let exec = tmp.path().join("hook.sh");
std::fs::write(&exec, "#!/bin/sh\necho '{\"action\":\"continue\"}'").unwrap();
let mut perms = std::fs::metadata(&exec).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&exec, perms).unwrap();
let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
assert_eq!(runner.len(), 1);
}
#[test]
fn is_executable_rejects_directory() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("subdir");
std::fs::create_dir(&dir).unwrap();
assert!(!is_executable(&dir));
}
#[test]
fn is_executable_rejects_nonexistent() {
assert!(!is_executable(std::path::Path::new(
"/nonexistent/path/script"
)));
}
#[tokio::test]
#[cfg(unix)]
async fn dispatch_runs_executable_hook_and_returns_decision() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let hook_path = tmp.path().join("my-hook.sh");
let script = r#"#!/bin/sh
read -r line
echo '{"action":"skip","message":"blocked by test hook"}'
"#;
std::fs::write(&hook_path, script).unwrap();
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
assert_eq!(runner.len(), 1);
let input = make_tool_input(
HookEvent::PreToolCall,
"run_shell",
serde_json::json!({"command": "ls"}),
);
let result = runner.dispatch(&input).await;
assert!(matches!(result.action, HookAction::Skip));
}
#[tokio::test]
async fn dispatch_returns_continue_when_no_hooks() {
let runner = ExternalHookRunner::discover(&[]);
let input = make_tool_input(
HookEvent::PreToolCall,
"read_file",
serde_json::json!({"path": "foo.txt"}),
);
let result = runner.dispatch(&input).await;
assert!(matches!(result.action, HookAction::Continue));
}
#[tokio::test]
#[cfg(unix)]
async fn dispatch_treats_timeout_as_continue() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let hook_path = tmp.path().join("hang.sh");
let script = "#!/bin/sh\nsleep 30\n";
std::fs::write(&hook_path, script).unwrap();
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
assert_eq!(runner.len(), 1);
let input = make_tool_input(
HookEvent::PreToolCall,
"run_shell",
serde_json::json!({"command": "ls"}),
);
let result = runner.dispatch(&input).await;
assert!(matches!(result.action, HookAction::Continue));
}
#[tokio::test]
#[cfg(unix)]
async fn dispatch_treats_bad_output_as_continue() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let hook_path = tmp.path().join("bad.sh");
let script = "#!/bin/sh\necho 'not json'\n";
std::fs::write(&hook_path, script).unwrap();
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
assert_eq!(runner.len(), 1);
let input = make_tool_input(
HookEvent::PreToolCall,
"run_shell",
serde_json::json!({"command": "ls"}),
);
let result = runner.dispatch(&input).await;
assert!(matches!(result.action, HookAction::Continue));
}
#[tokio::test]
#[cfg(unix)]
async fn dispatch_short_circuits_on_first_non_continue() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let h1 = tmp.path().join("h1.sh");
let s1 = "#!/bin/sh\nread -r line\necho '{\"action\":\"skip\",\"message\":\"first\"}'\n";
std::fs::write(&h1, s1).unwrap();
let h2 = tmp.path().join("h2.sh");
let s2 = "#!/bin/sh\nread -r line\necho '{\"action\":\"continue\"}'\n";
std::fs::write(&h2, s2).unwrap();
for p in [&h1, &h2] {
let mut perms = std::fs::metadata(p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(p, perms).unwrap();
}
let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
assert_eq!(runner.len(), 2);
let input = make_tool_input(
HookEvent::PreToolCall,
"write_file",
serde_json::json!({"path": "test.txt"}),
);
let result = runner.dispatch(&input).await;
assert!(matches!(result.action, HookAction::Skip));
}
#[test]
fn new_hook_events_serialize_camel_case() {
let cases: &[(&str, HookEvent)] = &[
("postToolCallFailure", HookEvent::PostToolCallFailure),
("permissionDenied", HookEvent::PermissionDenied),
("sessionStart", HookEvent::SessionStart),
("sessionEnd", HookEvent::SessionEnd),
("userPromptSubmit", HookEvent::UserPromptSubmit),
("stop", HookEvent::Stop),
("subagentStart", HookEvent::SubagentStart),
("subagentStop", HookEvent::SubagentStop),
("notification", HookEvent::Notification),
("setup", HookEvent::Setup),
];
for (expected, event) in cases {
let json = serde_json::to_string(event).unwrap();
assert_eq!(
json,
format!("\"{expected}\""),
"wrong camelCase for {expected}"
);
}
}
#[test]
fn hook_input_optional_fields_absent_when_none() {
let input = HookInput {
event: HookEvent::UserPromptSubmit,
tool_name: None,
args: None,
mode: "ask".to_string(),
content: Some("hello".to_string()),
message: None,
depth: None,
reason: None,
error: None,
};
let json = serde_json::to_string(&input).unwrap();
assert!(
!json.contains("tool_name") && !json.contains("toolName"),
"toolName should be absent"
);
assert!(!json.contains("\"args\""), "args should be absent");
assert!(json.contains("\"hello\""), "content should be present");
}
#[test]
fn hook_input_tool_name_present_for_tool_events() {
let input = make_tool_input(
HookEvent::PreToolCall,
"run_shell",
serde_json::json!({"command": "ls"}),
);
let json = serde_json::to_string(&input).unwrap();
assert!(json.contains("run_shell"));
assert!(json.contains("command"));
}
#[test]
fn hook_output_parses_additional_context() {
let json = r#"{"action":"continue","additionalContext":"extra info"}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
let result = output.into_hook_result();
assert!(matches!(result.action, HookAction::Continue));
assert_eq!(result.additional_context.as_deref(), Some("extra info"));
}
#[test]
fn hook_output_parses_updated_input() {
let json = r#"{"action":"continue","updatedInput":{"command":"ls -la"}}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
let result = output.into_hook_result();
assert_eq!(
result.updated_input,
Some(serde_json::json!({"command": "ls -la"}))
);
}
#[test]
fn hook_output_parses_permission_decision_allow() {
let json = r#"{"action":"continue","permissionDecision":"allow","permissionDecisionReason":"safe"}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
let result = output.into_hook_result();
assert_eq!(result.permission_decision, Some(PermissionDecision::Allow));
assert_eq!(result.permission_decision_reason.as_deref(), Some("safe"));
}
#[test]
fn hook_output_parses_permission_decision_deny() {
let json = r#"{"action":"skip","permissionDecision":"deny"}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
let result = output.into_hook_result();
assert_eq!(result.permission_decision, Some(PermissionDecision::Deny));
assert!(matches!(result.action, HookAction::Skip));
}
#[test]
fn hook_output_parses_permission_decision_passthrough() {
let json = r#"{"action":"continue","permissionDecision":"passthrough"}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
let result = output.into_hook_result();
assert_eq!(
result.permission_decision,
Some(PermissionDecision::Passthrough)
);
}
#[test]
fn hook_result_default_is_continue_with_no_extras() {
let result = HookResult::default();
assert!(matches!(result.action, HookAction::Continue));
assert!(result.additional_context.is_none());
assert!(result.updated_input.is_none());
assert!(result.system_message.is_none());
assert!(!result.suppress_output);
assert!(result.permission_decision.is_none());
assert!(result.permission_decision_reason.is_none());
}
#[test]
fn hook_output_system_message_included() {
let json = r#"{"action":"continue","systemMessage":"Please review before proceeding"}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
let result = output.into_hook_result();
assert_eq!(
result.system_message.as_deref(),
Some("Please review before proceeding")
);
}
#[test]
fn hook_output_suppress_output_default_false() {
let json = r#"{"action":"continue"}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
let result = output.into_hook_result();
assert!(!result.suppress_output);
}
#[test]
fn hook_output_suppress_output_true() {
let json = r#"{"action":"continue","suppressOutput":true}"#;
let output: HookOutput = serde_json::from_str(json).unwrap();
let result = output.into_hook_result();
assert!(result.suppress_output);
}
fn make_non_tool_input() -> HookInput {
HookInput {
event: HookEvent::UserPromptSubmit,
tool_name: None,
args: None,
mode: "ask".to_string(),
content: Some("hello".to_string()),
message: None,
depth: None,
reason: None,
error: None,
}
}
#[tokio::test]
async fn http_hook_posts_json_input_and_parses_response() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/hook")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"action":"skip","message":"blocked by http hook"}"#)
.create_async()
.await;
let input = make_non_tool_input();
let url = format!("{}/hook", server.url());
let result = run_http_hook(&url, &input, 10, None, None).await;
assert!(matches!(result.action, HookAction::Skip));
mock.assert_async().await;
}
#[tokio::test]
async fn http_hook_parses_continue_response() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/hook")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"action":"continue"}"#)
.create_async()
.await;
let input = make_non_tool_input();
let url = format!("{}/hook", server.url());
let result = run_http_hook(&url, &input, 10, None, None).await;
assert!(matches!(result.action, HookAction::Continue));
}
#[tokio::test]
async fn http_hook_connection_error_returns_continue() {
let input = make_non_tool_input();
let result = run_http_hook("http://127.0.0.1:19999/hook", &input, 2, None, None).await;
assert!(matches!(result.action, HookAction::Continue));
}
#[tokio::test]
async fn http_hook_bad_json_response_returns_continue() {
let mut server = mockito::Server::new_async().await;
server
.mock("POST", "/hook")
.with_status(200)
.with_body("not json at all")
.create_async()
.await;
let input = make_non_tool_input();
let url = format!("{}/hook", server.url());
let result = run_http_hook(&url, &input, 10, None, None).await;
assert!(matches!(result.action, HookAction::Continue));
}
#[test]
fn env_var_interpolation_respects_allowlist() {
std::env::set_var("MY_TOKEN", "secret123");
let allowed = vec!["MY_TOKEN".to_string()];
let result = interpolate_env_vars("Bearer $MY_TOKEN", Some(&allowed));
assert_eq!(result, "Bearer secret123");
}
#[test]
fn env_var_interpolation_empty_for_non_allowed() {
std::env::set_var("BLOCKED_VAR", "should-not-appear");
let allowed = vec!["OTHER_VAR".to_string()];
let result = interpolate_env_vars("Bearer $BLOCKED_VAR", Some(&allowed));
assert_eq!(result, "Bearer ");
}
#[test]
fn env_var_interpolation_curly_brace_form() {
std::env::set_var("API_KEY", "mykey");
let allowed = vec!["API_KEY".to_string()];
let result = interpolate_env_vars("key=${API_KEY}", Some(&allowed));
assert_eq!(result, "key=mykey");
}
#[test]
fn env_var_interpolation_no_allowlist_replaces_all() {
std::env::set_var("UNGUARDED", "value");
let result = interpolate_env_vars("$UNGUARDED", None);
assert_eq!(result, "value");
}
#[tokio::test]
async fn prompt_hook_replaces_arguments_placeholder() {
use crate::llm::{Completion, MockProvider};
let captured = Arc::new(std::sync::Mutex::new(String::new()));
let c = captured.clone();
let provider = MockProvider::new(vec![Completion {
content: r#"{"action":"skip","message":"prompt blocked"}"#.to_string(),
..Default::default()
}]);
drop(c);
let input = make_non_tool_input();
let result = run_prompt_hook(
&provider,
"Is this safe? $ARGUMENTS",
&input,
Duration::from_secs(10),
)
.await;
assert!(matches!(result.action, HookAction::Skip));
}
#[tokio::test]
async fn prompt_hook_uses_llm_response_continue() {
use crate::llm::{Completion, MockProvider};
let provider = MockProvider::new(vec![Completion {
content: r#"{"action":"continue"}"#.to_string(),
..Default::default()
}]);
let input = make_non_tool_input();
let result = run_prompt_hook(
&provider,
"Check: $ARGUMENTS",
&input,
Duration::from_secs(10),
)
.await;
assert!(matches!(result.action, HookAction::Continue));
}
#[tokio::test]
async fn prompt_hook_falls_back_on_non_json_response() {
use crate::llm::{Completion, MockProvider};
let provider = MockProvider::new(vec![Completion {
content: "Sorry, I cannot evaluate this.".to_string(),
..Default::default()
}]);
let input = make_non_tool_input();
let result = run_prompt_hook(
&provider,
"Is this safe? $ARGUMENTS",
&input,
Duration::from_secs(10),
)
.await;
assert!(matches!(result.action, HookAction::Continue));
}
#[tokio::test]
async fn no_llm_prompt_hook_returns_continue() {
use crate::hooks::config::HooksConfig;
let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt","prompt":"Is this safe? $ARGUMENTS"}]}]}"#;
let cfg: HooksConfig = serde_json::from_str(json).unwrap();
let runner = ExternalHookRunner::from_config_with_llm(cfg, None);
assert_eq!(runner.len(), 1);
let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
let result = runner.dispatch(&input).await;
assert!(matches!(result.action, HookAction::Continue));
}
#[test]
fn from_config_creates_hooks_from_json() {
use crate::hooks::config::HooksConfig;
let json = r#"{"PreToolCall":[{"matcher":"run_shell","hooks":[{"type":"command","command":"/usr/bin/true"}]}]}"#;
let cfg: HooksConfig = serde_json::from_str(json).unwrap();
let runner = ExternalHookRunner::from_config(cfg);
assert_eq!(runner.len(), 1);
}
#[test]
fn from_config_http_type_is_registered() {
use crate::hooks::config::HooksConfig;
let json = r#"{"PreToolCall":[{"hooks":[{"type":"http","url":"https://example.com"}]}]}"#;
let cfg: HooksConfig = serde_json::from_str(json).unwrap();
let runner = ExternalHookRunner::from_config(cfg);
assert_eq!(runner.len(), 1);
}
#[test]
fn from_config_prompt_without_prompt_field_is_skipped() {
use crate::hooks::config::HooksConfig;
let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt"}]}]}"#;
let cfg: HooksConfig = serde_json::from_str(json).unwrap();
let runner = ExternalHookRunner::from_config(cfg);
assert_eq!(runner.len(), 0);
}
#[test]
fn from_config_http_without_url_is_skipped() {
use crate::hooks::config::HooksConfig;
let json = r#"{"PreToolCall":[{"hooks":[{"type":"http"}]}]}"#;
let cfg: HooksConfig = serde_json::from_str(json).unwrap();
let runner = ExternalHookRunner::from_config(cfg);
assert_eq!(runner.len(), 0);
}
#[test]
fn from_config_empty_config_gives_empty_runner() {
use crate::hooks::config::HooksConfig;
let runner = ExternalHookRunner::from_config(HooksConfig::default());
assert!(runner.is_empty());
}
#[tokio::test]
#[cfg(unix)]
async fn from_config_respects_matcher_event_filter() {
use crate::hooks::config::HooksConfig;
let tmp = tempfile::tempdir().unwrap();
let hook_path = tmp.path().join("hook.sh");
std::fs::write(
&hook_path,
"#!/bin/sh\nread -r line\necho '{\"action\":\"skip\"}'\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
}
let json = format!(
r#"{{"PostToolCall":[{{"matcher":"run_shell","hooks":[{{"type":"command","command":"{}"}}]}}]}}"#,
hook_path.display()
);
let cfg: HooksConfig = serde_json::from_str(&json).unwrap();
let runner = ExternalHookRunner::from_config(cfg);
let pre_input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
let result = runner.dispatch(&pre_input).await;
assert!(
matches!(result.action, HookAction::Continue),
"PreToolCall should not trigger PostToolCall hook"
);
let post_input =
make_tool_input(HookEvent::PostToolCall, "run_shell", serde_json::json!({}));
let result = runner.dispatch(&post_input).await;
assert!(
matches!(result.action, HookAction::Skip),
"PostToolCall should trigger hook"
);
}
#[tokio::test]
#[cfg(unix)]
async fn once_hook_runs_only_first_time() {
use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
let tmp = tempfile::tempdir().unwrap();
let counter_file = tmp.path().join("counter");
let hook_path = tmp.path().join("once_hook.sh");
std::fs::write(&counter_file, "0").unwrap();
let script = format!(
"#!/bin/sh\nread -r _\necho $(( $(cat {0}) + 1 )) > {0}\necho '{{\"action\":\"continue\"}}'\n",
counter_file.display()
);
std::fs::write(&hook_path, &script).unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
}
let cfg = HooksConfig {
events: std::collections::HashMap::from([(
"PreToolCall".to_string(),
vec![HookMatcher {
matcher: None,
hooks: vec![HookCommand {
r#type: HookCommandType::Command,
command: Some(hook_path.to_string_lossy().to_string()),
once: true,
timeout: 5,
..Default::default()
}],
}],
)]),
};
let runner = ExternalHookRunner::from_config(cfg);
let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
runner.dispatch(&input).await;
runner.dispatch(&input).await;
let count = std::fs::read_to_string(&counter_file)
.unwrap()
.trim()
.to_string();
assert_eq!(count, "1", "once hook should have run exactly once");
}
#[tokio::test]
#[cfg(unix)]
async fn async_hook_returns_continue_immediately() {
use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
let tmp = tempfile::tempdir().unwrap();
let hook_path = tmp.path().join("slow_hook.sh");
std::fs::write(
&hook_path,
"#!/bin/sh\nread -r _\nsleep 10\necho '{\"action\":\"skip\"}'\n",
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
}
let cfg = HooksConfig {
events: std::collections::HashMap::from([(
"PreToolCall".to_string(),
vec![HookMatcher {
matcher: None,
hooks: vec![HookCommand {
r#type: HookCommandType::Command,
command: Some(hook_path.to_string_lossy().to_string()),
r#async: true,
timeout: 5,
..Default::default()
}],
}],
)]),
};
let runner = ExternalHookRunner::from_config(cfg);
let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
let start = std::time::Instant::now();
let result = runner.dispatch(&input).await;
let elapsed = start.elapsed();
assert!(
elapsed.as_secs() < 1,
"async hook blocked dispatch: {elapsed:?}"
);
assert!(matches!(result.action, HookAction::Continue));
}
#[tokio::test]
#[cfg(unix)]
async fn async_rewake_exit2_triggers_cancel() {
use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
let tmp = tempfile::tempdir().unwrap();
let hook_path = tmp.path().join("exit2.sh");
std::fs::write(&hook_path, "#!/bin/sh\nread -r _\nexit 2\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
}
let token = CancellationToken::new();
let child_token = token.clone();
let cfg = HooksConfig {
events: std::collections::HashMap::from([(
"PreToolCall".to_string(),
vec![HookMatcher {
matcher: None,
hooks: vec![HookCommand {
r#type: HookCommandType::Command,
command: Some(hook_path.to_string_lossy().to_string()),
async_rewake: true,
timeout: 5,
..Default::default()
}],
}],
)]),
};
let runner = ExternalHookRunner::from_config(cfg).with_cancel_token(child_token);
let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
runner.dispatch(&input).await;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !token.is_cancelled() {
if std::time::Instant::now() >= deadline {
panic!("timed out waiting for asyncRewake cancellation (exit 2 hook)");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
token.is_cancelled(),
"exit code 2 should have triggered cancellation"
);
}
#[tokio::test]
#[cfg(unix)]
async fn async_rewake_exit0_no_cancel() {
use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
let tmp = tempfile::tempdir().unwrap();
let hook_path = tmp.path().join("exit0.sh");
std::fs::write(&hook_path, "#!/bin/sh\nread -r _\nexit 0\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
}
let token = CancellationToken::new();
let child_token = token.clone();
let cfg = HooksConfig {
events: std::collections::HashMap::from([(
"PreToolCall".to_string(),
vec![HookMatcher {
matcher: None,
hooks: vec![HookCommand {
r#type: HookCommandType::Command,
command: Some(hook_path.to_string_lossy().to_string()),
async_rewake: true,
timeout: 5,
..Default::default()
}],
}],
)]),
};
let runner = ExternalHookRunner::from_config(cfg).with_cancel_token(child_token);
let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
runner.dispatch(&input).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
!token.is_cancelled(),
"exit code 0 should NOT trigger cancellation"
);
}
}