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, 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,
}
impl ShellCommandHook {
async fn dispatch(&self, envelope: serde_json::Value) -> HookDecision {
let payload = match serde_json::to_string(&envelope) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "shell hook: failed to serialize envelope");
return HookDecision::Allow;
}
};
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 cmd.spawn() {
Ok(c) => c,
Err(e) => {
tracing::warn!(command = %self.command, error = %e, "shell hook: spawn failed");
return HookDecision::Allow;
}
};
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 HookDecision::Allow;
}
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 HookDecision::Allow;
}
};
let stdout_text = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr_text = String::from_utf8_lossy(&output.stderr).into_owned();
let truncated_stderr = truncate_kb(&stderr_text, 8);
if !truncated_stderr.is_empty() {
tracing::debug!(
command = %self.command,
hook_stderr = %truncated_stderr,
"shell hook: stderr captured",
);
}
let from_json = parse_decision_blob(&stdout_text);
if !matches!(from_json, HookDecision::Allow) || stdout_text.trim().starts_with('{') {
return from_json;
}
let code = output.status.code().unwrap_or(0);
match code {
0 => HookDecision::Allow,
2 => HookDecision::Deny(if truncated_stderr.is_empty() {
format!("hook `{}` exited 2", self.command)
} else {
truncated_stderr
}),
other => {
tracing::warn!(
command = %self.command,
exit_code = other,
"shell hook: non-zero exit treated as Allow"
);
HookDecision::Allow
}
}
}
}
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(())
}
}
#[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 dispatch(&self, envelope: serde_json::Value) -> HookDecision {
if !self.is_url_allowed() {
tracing::warn!(
url = %self.url,
"http hook: URL not in allowed_http_hook_urls; skipping (Allow)"
);
return HookDecision::Allow;
}
let mut req = self.client.post(&self.url).json(&envelope);
for (k, v) in &self.headers {
req = req.header(k, v);
}
let send = tokio::time::timeout(self.timeout, req.send()).await;
let resp = match send {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::warn!(url = %self.url, error = %e, "http hook: request failed");
return HookDecision::Allow;
}
Err(_) => {
tracing::warn!(url = %self.url, "http hook: timeout exceeded; Allow");
return HookDecision::Allow;
}
};
if !resp.status().is_success() {
tracing::warn!(
url = %self.url,
status = resp.status().as_u16(),
"http hook: non-2xx; Allow"
);
return HookDecision::Allow;
}
let body_result = tokio::time::timeout(self.timeout, resp.text()).await;
let body = match body_result {
Ok(Ok(b)) => b,
Ok(Err(e)) => {
tracing::warn!(error = %e, "http hook: body read failed; Allow");
return HookDecision::Allow;
}
Err(_) => {
tracing::warn!("http hook: body read timeout; Allow");
return HookDecision::Allow;
}
};
parse_decision_blob(&body)
}
}
#[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)
}
}
#[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));
}
#[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");
}
}