use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use tokio::io::AsyncWriteExt as _;
use crate::error::Result;
use crate::hooks::{HookDecision, Hooks, ToolCtx};
#[derive(Debug, Deserialize, Default)]
struct HookSpecificOutput {
#[serde(rename = "permissionDecision")]
permission_decision: Option<String>,
#[serde(rename = "permissionDecisionReason")]
permission_decision_reason: Option<String>,
#[serde(rename = "updatedInput")]
updated_input: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, Default)]
struct DecisionEnvelope {
#[serde(rename = "hookSpecificOutput", default)]
hook_specific_output: Option<HookSpecificOutput>,
}
fn parse_decision_blob(text: &str) -> HookDecision {
let trimmed = text.trim();
if trimmed.is_empty() {
return HookDecision::Allow;
}
let Ok(env) = serde_json::from_str::<DecisionEnvelope>(trimmed) else {
return HookDecision::Allow;
};
let Some(out) = env.hook_specific_output else {
return HookDecision::Allow;
};
match out.permission_decision.as_deref() {
Some("deny") => HookDecision::Deny(
out.permission_decision_reason
.unwrap_or_else(|| "denied by hook".into()),
),
Some("ask") => HookDecision::Deny(
out.permission_decision_reason
.unwrap_or_else(|| "ask path not yet wired".into()),
),
_ => match out.updated_input {
Some(v) => HookDecision::UpdatedInput(v),
None => HookDecision::Allow,
},
}
}
#[derive(Debug, Deserialize, Default)]
struct SessionStartBlob {
#[serde(rename = "additionalContext")]
additional_context: Option<String>,
#[serde(rename = "hookSpecificOutput", default)]
hook_specific_output: Option<SessionStartNested>,
}
#[derive(Debug, Deserialize, Default)]
struct SessionStartNested {
#[serde(rename = "additionalContext")]
additional_context: Option<String>,
}
pub(crate) fn parse_session_start_context(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let blob = serde_json::from_str::<SessionStartBlob>(trimmed).ok()?;
blob.additional_context
.or_else(|| blob.hook_specific_output.and_then(|n| n.additional_context))
}
#[must_use]
pub fn build_config_hooks(
cfg: &crate::hooks_config::HooksConfig,
http_client: &reqwest::Client,
) -> Vec<std::sync::Arc<dyn crate::hooks::Hooks + Send + Sync>> {
use crate::hooks_config::HookHandlerType;
if cfg.disable_all_hooks {
return Vec::new();
}
if cfg.allow_managed_hooks_only {
tracing::warn!(
"allow_managed_hooks_only is set but handler scope is not tracked; \
firing no config hooks (see #124)"
);
return Vec::new();
}
let mut out: Vec<std::sync::Arc<dyn crate::hooks::Hooks + Send + Sync>> = Vec::new();
for (event_name, handlers) in &cfg.events {
for h in handlers {
match h.kind {
HookHandlerType::Command => {
let Some(command) = h.command.clone() else {
tracing::warn!(event = %event_name, "command hook missing `command`; skipping");
continue;
};
out.push(std::sync::Arc::new(ShellCommandHook {
command,
args: h.args.clone(),
timeout: h.timeout,
env: h.env.clone(),
matcher: h.matcher.clone(),
event_name: event_name.clone(),
}));
}
HookHandlerType::Http => {
let Some(url) = h.url.clone() else {
tracing::warn!(event = %event_name, "http hook missing `url`; skipping");
continue;
};
out.push(std::sync::Arc::new(HttpHook {
url,
headers: h.headers.clone(),
timeout: h.timeout,
allowed_url_globs: cfg.allowed_http_hook_urls.clone(),
event_name: event_name.clone(),
matcher: h.matcher.clone(),
client: http_client.clone(),
}));
}
HookHandlerType::Mcp | HookHandlerType::Prompt | HookHandlerType::Agent => {
tracing::warn!(
event = %event_name,
kind = ?h.kind,
"config hook kind not yet executable at runtime; skipping"
);
}
}
}
}
out
}
#[derive(Debug, Clone)]
pub struct ShellCommandHook {
pub command: String,
pub args: Vec<String>,
pub timeout: Duration,
pub env: BTreeMap<String, String>,
pub matcher: String,
pub event_name: String,
}
struct CaptureOutput {
stdout: String,
stderr: String,
exit_code: i32,
}
impl ShellCommandHook {
async fn run_capture(&self, envelope: serde_json::Value) -> Option<CaptureOutput> {
let payload = match serde_json::to_string(&envelope) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "shell hook: failed to serialize envelope");
return None;
}
};
let mut cmd = tokio::process::Command::new(&self.command);
cmd.args(&self.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in &self.env {
cmd.env(k, v);
}
let mut child = match spawn_with_retry(&mut cmd, &self.command).await {
Ok(c) => c,
Err(e) => {
tracing::warn!(command = %self.command, error = %e, "shell hook: spawn failed");
return None;
}
};
if let Some(mut stdin) = child.stdin.take()
&& let Err(e) = stdin.write_all(payload.as_bytes()).await
{
tracing::warn!(error = %e, "shell hook: stdin write failed");
}
let wait_output = tokio::time::timeout(self.timeout, child.wait_with_output()).await;
let output = match wait_output {
Ok(Ok(o)) => o,
Ok(Err(e)) => {
tracing::warn!(error = %e, "shell hook: wait failed");
return None;
}
Err(_) => {
tracing::warn!(
command = %self.command,
timeout_ms = u64::try_from(self.timeout.as_millis()).unwrap_or(u64::MAX),
"shell hook: timeout exceeded; treating as Allow"
);
return None;
}
};
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = truncate_kb(&String::from_utf8_lossy(&output.stderr), 8);
if !stderr.is_empty() {
tracing::debug!(
command = %self.command,
hook_stderr = %stderr,
"shell hook: stderr captured",
);
}
Some(CaptureOutput {
stdout,
stderr,
exit_code: output.status.code().unwrap_or(0),
})
}
async fn dispatch(&self, envelope: serde_json::Value) -> HookDecision {
let Some(out) = self.run_capture(envelope).await else {
return HookDecision::Allow;
};
let from_json = parse_decision_blob(&out.stdout);
if !matches!(from_json, HookDecision::Allow) || out.stdout.trim().starts_with('{') {
return from_json;
}
match out.exit_code {
0 => HookDecision::Allow,
2 => HookDecision::Deny(if out.stderr.is_empty() {
format!("hook `{}` exited 2", self.command)
} else {
out.stderr
}),
other => {
tracing::warn!(
command = %self.command,
exit_code = other,
"shell hook: non-zero exit treated as Allow"
);
HookDecision::Allow
}
}
}
}
async fn spawn_with_retry(
cmd: &mut tokio::process::Command,
command: &str,
) -> std::io::Result<tokio::process::Child> {
const MAX_ATTEMPTS: u32 = 4;
let mut attempt = 1;
loop {
match cmd.spawn() {
Ok(child) => return Ok(child),
Err(e) if attempt < MAX_ATTEMPTS && is_transient_spawn_error(&e) => {
let backoff = Duration::from_millis(5 * (1 << (attempt - 1)));
tracing::debug!(
command = %command,
error = %e,
attempt,
backoff_ms = u64::try_from(backoff.as_millis()).unwrap_or(u64::MAX),
"shell hook: transient spawn failure; retrying",
);
tokio::time::sleep(backoff).await;
attempt += 1;
}
Err(e) => return Err(e),
}
}
}
fn is_transient_spawn_error(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::ExecutableFileBusy
)
}
fn truncate_kb(s: &str, kib: usize) -> String {
let max = kib * 1024;
if s.len() <= max {
s.to_string()
} else {
format!(
"{}\n[truncated to {kib} KiB]",
&s[..max.min(s.len() - (s.len() - max))]
)
}
}
#[async_trait]
impl Hooks for ShellCommandHook {
async fn before_tool(&self, ctx: &ToolCtx<'_>) -> Result<HookDecision> {
if self.event_name != "PreToolUse" {
return Ok(HookDecision::Allow);
}
if !crate::permissions::matches_glob(&self.matcher, ctx.tool_name) {
return Ok(HookDecision::Allow);
}
let envelope = crate::hooks::build_envelope(
"PreToolUse",
serde_json::json!({
"session_id": "",
"turn_index": ctx.turn_index,
"tool": {
"name": ctx.tool_name,
"useId": ctx.tool_use_id,
"input": ctx.input,
}
}),
);
Ok(self.dispatch(envelope).await)
}
async fn after_tool(
&self,
ctx: &ToolCtx<'_>,
_result: &std::result::Result<Vec<caliban_provider::ContentBlock>, crate::tool::ToolError>,
) -> Result<()> {
if self.event_name != "PostToolUse" {
return Ok(());
}
if !crate::permissions::matches_glob(&self.matcher, ctx.tool_name) {
return Ok(());
}
let envelope = crate::hooks::build_envelope(
"PostToolUse",
serde_json::json!({
"session_id": "",
"turn_index": ctx.turn_index,
"tool": {
"name": ctx.tool_name,
"useId": ctx.tool_use_id,
"input": ctx.input,
}
}),
);
let _ = self.dispatch(envelope).await; Ok(())
}
async fn session_start(
&self,
ctx: &crate::hooks::SessionCtx<'_>,
) -> Result<crate::hooks::SessionStartOutcome> {
if self.event_name != "SessionStart" {
return Ok(crate::hooks::SessionStartOutcome::default());
}
let envelope = crate::hooks::build_envelope(
"SessionStart",
serde_json::json!({
"session_id": ctx.session_id,
"cwd": ctx.cwd.display().to_string(),
"provider": ctx.provider,
"model": ctx.model,
}),
);
let additional_context: Vec<String> = self
.run_capture(envelope)
.await
.and_then(|o| parse_session_start_context(&o.stdout))
.into_iter()
.collect();
Ok(crate::hooks::SessionStartOutcome { additional_context })
}
}
#[derive(Debug, Clone)]
pub struct HttpHook {
pub url: String,
pub headers: BTreeMap<String, String>,
pub timeout: Duration,
pub allowed_url_globs: Vec<String>,
pub event_name: String,
pub matcher: String,
pub client: reqwest::Client,
}
impl HttpHook {
fn is_url_allowed(&self) -> bool {
self.allowed_url_globs
.iter()
.any(|g| crate::permissions::matches_glob(g, &self.url))
}
async fn fetch_body(&self, envelope: serde_json::Value) -> Option<String> {
if !self.is_url_allowed() {
tracing::warn!(
url = %self.url,
"http hook: URL not in allowed_http_hook_urls; skipping (Allow)"
);
return None;
}
let mut req = self.client.post(&self.url).json(&envelope);
for (k, v) in &self.headers {
req = req.header(k, v);
}
let resp = match tokio::time::timeout(self.timeout, req.send()).await {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::warn!(url = %self.url, error = %e, "http hook: request failed");
return None;
}
Err(_) => {
tracing::warn!(url = %self.url, "http hook: timeout exceeded; Allow");
return None;
}
};
if !resp.status().is_success() {
tracing::warn!(
url = %self.url,
status = resp.status().as_u16(),
"http hook: non-2xx; Allow"
);
return None;
}
match tokio::time::timeout(self.timeout, resp.text()).await {
Ok(Ok(b)) => Some(b),
Ok(Err(e)) => {
tracing::warn!(error = %e, "http hook: body read failed; Allow");
None
}
Err(_) => {
tracing::warn!("http hook: body read timeout; Allow");
None
}
}
}
async fn dispatch(&self, envelope: serde_json::Value) -> HookDecision {
match self.fetch_body(envelope).await {
Some(body) => parse_decision_blob(&body),
None => HookDecision::Allow,
}
}
}
#[async_trait]
impl Hooks for HttpHook {
async fn before_tool(&self, ctx: &ToolCtx<'_>) -> Result<HookDecision> {
if self.event_name != "PreToolUse" {
return Ok(HookDecision::Allow);
}
if !crate::permissions::matches_glob(&self.matcher, ctx.tool_name) {
return Ok(HookDecision::Allow);
}
let envelope = crate::hooks::build_envelope(
"PreToolUse",
serde_json::json!({
"session_id": "",
"turn_index": ctx.turn_index,
"tool": {
"name": ctx.tool_name,
"useId": ctx.tool_use_id,
"input": ctx.input,
}
}),
);
Ok(self.dispatch(envelope).await)
}
async fn session_start(
&self,
ctx: &crate::hooks::SessionCtx<'_>,
) -> Result<crate::hooks::SessionStartOutcome> {
if self.event_name != "SessionStart" {
return Ok(crate::hooks::SessionStartOutcome::default());
}
let envelope = crate::hooks::build_envelope(
"SessionStart",
serde_json::json!({
"session_id": ctx.session_id,
"cwd": ctx.cwd.display().to_string(),
"provider": ctx.provider,
"model": ctx.model,
}),
);
let additional_context: Vec<String> = self
.fetch_body(envelope)
.await
.and_then(|b| parse_session_start_context(&b))
.into_iter()
.collect();
Ok(crate::hooks::SessionStartOutcome { additional_context })
}
}
#[derive(Debug, Clone)]
pub struct PromptHook {
pub prompt: String,
pub schema: Option<String>,
pub model: Option<String>,
pub event_name: String,
}
#[async_trait]
impl Hooks for PromptHook {
async fn before_tool(&self, _ctx: &ToolCtx<'_>) -> Result<HookDecision> {
tracing::warn!(
event = %self.event_name,
"PromptHook is a v1 stub; returning Allow (real wiring lands with ADR 0023)"
);
Ok(HookDecision::Allow)
}
}
#[derive(Debug, Clone)]
pub struct AgentHook {
pub agent: String,
pub event_name: String,
}
#[async_trait]
impl Hooks for AgentHook {
async fn before_tool(&self, _ctx: &ToolCtx<'_>) -> Result<HookDecision> {
tracing::warn!(
agent = %self.agent,
event = %self.event_name,
"AgentHook is a v1 stub; returning Allow (real wiring lands with ADR 0037)"
);
Ok(HookDecision::Allow)
}
}
#[derive(Debug, Clone)]
pub struct McpHook {
pub server: String,
pub tool: String,
pub event_name: String,
}
#[async_trait]
impl Hooks for McpHook {
async fn before_tool(&self, _ctx: &ToolCtx<'_>) -> Result<HookDecision> {
tracing::warn!(
server = %self.server,
tool = %self.tool,
event = %self.event_name,
"McpHook is a v1 stub; returning Allow (real wiring lands with ADR 0023)"
);
Ok(HookDecision::Allow)
}
}
#[doc(hidden)]
pub fn __noop_pathbuf(p: PathBuf) -> PathBuf {
p
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_blob_is_allow() {
assert!(matches!(parse_decision_blob(""), HookDecision::Allow));
assert!(matches!(parse_decision_blob(" "), HookDecision::Allow));
}
fn test_client() -> reqwest::Client {
reqwest::Client::builder().build().unwrap()
}
#[test]
fn bridge_builds_command_and_skips_stub_kinds() {
let toml = r#"
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.handlers]]
type = "command"
command = "/bin/true"
[[hooks.SessionStart]]
[[hooks.SessionStart.handlers]]
type = "mcp"
mcp = "srv"
tool = "t"
"#;
let cfg =
crate::hooks_config::HooksConfig::from_str(toml, std::path::Path::new("test")).unwrap();
let hooks = build_config_hooks(&cfg, &test_client());
assert_eq!(hooks.len(), 1);
}
#[test]
fn bridge_disable_all_hooks_is_empty() {
let toml = r#"
disable_all_hooks = true
[[hooks.PreToolUse]]
[[hooks.PreToolUse.handlers]]
type = "command"
command = "/bin/true"
"#;
let cfg =
crate::hooks_config::HooksConfig::from_str(toml, std::path::Path::new("test")).unwrap();
assert!(build_config_hooks(&cfg, &test_client()).is_empty());
}
#[test]
fn bridge_managed_only_is_empty() {
let toml = r#"
allow_managed_hooks_only = true
[[hooks.PreToolUse]]
[[hooks.PreToolUse.handlers]]
type = "command"
command = "/bin/true"
"#;
let cfg =
crate::hooks_config::HooksConfig::from_str(toml, std::path::Path::new("test")).unwrap();
assert!(build_config_hooks(&cfg, &test_client()).is_empty());
}
#[test]
fn session_start_context_flat_shape() {
let blob = r#"{ "additionalContext": "hello from hook" }"#;
assert_eq!(
parse_session_start_context(blob),
Some("hello from hook".to_string())
);
}
#[test]
fn session_start_context_nested_shape() {
let blob = r#"{ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "nested ctx" } }"#;
assert_eq!(
parse_session_start_context(blob),
Some("nested ctx".to_string())
);
}
#[test]
fn session_start_context_absent_or_nonjson() {
assert_eq!(parse_session_start_context(""), None);
assert_eq!(parse_session_start_context("not json"), None);
assert_eq!(parse_session_start_context(r#"{ "other": 1 }"#), None);
}
#[test]
fn non_json_blob_is_allow() {
assert!(matches!(parse_decision_blob("nope"), HookDecision::Allow));
}
#[test]
fn deny_blob_parses() {
let blob = r#"{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "no rm allowed"
}
}"#;
match parse_decision_blob(blob) {
HookDecision::Deny(msg) => assert!(msg.contains("no rm")),
d => panic!("unexpected: {d:?}"),
}
}
#[test]
fn updated_input_blob_parses() {
let blob = r#"{
"hookSpecificOutput": {
"updatedInput": { "command": "echo safe" }
}
}"#;
match parse_decision_blob(blob) {
HookDecision::UpdatedInput(v) => {
assert_eq!(v["command"], "echo safe");
}
d => panic!("unexpected: {d:?}"),
}
}
#[test]
fn allow_blob_with_no_updated_input() {
let blob = r#"{ "hookSpecificOutput": { "permissionDecision": "allow" } }"#;
assert!(matches!(parse_decision_blob(blob), HookDecision::Allow));
}
#[test]
fn truncate_kb_short_string_untouched() {
assert_eq!(truncate_kb("hi", 8), "hi");
}
#[test]
fn transient_spawn_error_flags_eagain() {
let e = std::io::Error::from(std::io::ErrorKind::WouldBlock);
assert!(is_transient_spawn_error(&e));
}
#[test]
fn transient_spawn_error_flags_etxtbsy() {
let e = std::io::Error::from(std::io::ErrorKind::ExecutableFileBusy);
assert!(is_transient_spawn_error(&e));
}
#[test]
fn transient_spawn_error_rejects_not_found() {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
assert!(!is_transient_spawn_error(&e));
}
#[test]
fn transient_spawn_error_rejects_permission_denied() {
let e = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
assert!(!is_transient_spawn_error(&e));
}
}