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 explicit_decision(text: &str) -> Option<HookDecision> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let env = serde_json::from_str::<DecisionEnvelope>(trimmed).ok()?;
let out = env.hook_specific_output?;
match out.permission_decision.as_deref() {
Some("deny") => Some(HookDecision::Deny(
out.permission_decision_reason
.unwrap_or_else(|| "denied by hook".into()),
)),
Some("ask") => Some(HookDecision::Deny(
out.permission_decision_reason
.unwrap_or_else(|| "ask path not yet wired".into()),
)),
Some(_) => Some(
out.updated_input
.map_or(HookDecision::Allow, HookDecision::UpdatedInput),
),
None => out.updated_input.map(HookDecision::UpdatedInput),
}
}
fn parse_decision_blob(text: &str) -> HookDecision {
explicit_decision(text).unwrap_or(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))
}
#[derive(Debug, Clone)]
pub struct HookGate {
pub event_name: String,
pub matcher: String,
pub if_pattern: Option<String>,
pub asynchronous: bool,
}
impl HookGate {
fn fires_for(&self, ctx: &ToolCtx<'_>) -> bool {
crate::permissions::matches_glob(&self.matcher, ctx.tool_name)
&& match &self.if_pattern {
None => true,
Some(p) => crate::permissions_matcher::matches(p, ctx),
}
}
}
#[async_trait]
pub trait ExternalTransport: Send + Sync + std::fmt::Debug {
async fn dispatch(&self, envelope: serde_json::Value) -> HookDecision;
fn spawn_detached(self: std::sync::Arc<Self>, envelope: serde_json::Value);
async fn capture(&self, envelope: serde_json::Value) -> Option<String>;
fn gate(&self) -> &HookGate;
}
#[derive(Debug, Clone)]
pub struct ExternalHook<T: ExternalTransport> {
pub transport: std::sync::Arc<T>,
}
#[async_trait]
impl<T: ExternalTransport + 'static> Hooks for ExternalHook<T> {
async fn before_tool(&self, ctx: &ToolCtx<'_>) -> Result<HookDecision> {
let gate = self.transport.gate();
if gate.event_name != "PreToolUse" || !gate.fires_for(ctx) {
return Ok(HookDecision::Allow);
}
let envelope = crate::hooks::pre_tool_envelope("PreToolUse", ctx);
if gate.asynchronous {
std::sync::Arc::clone(&self.transport).spawn_detached(envelope);
return Ok(HookDecision::Allow);
}
Ok(self.transport.dispatch(envelope).await)
}
async fn after_tool(
&self,
ctx: &ToolCtx<'_>,
_result: &std::result::Result<Vec<caliban_provider::ContentBlock>, crate::tool::ToolError>,
) -> Result<()> {
let gate = self.transport.gate();
if gate.event_name != "PostToolUse" || !gate.fires_for(ctx) {
return Ok(());
}
let envelope = crate::hooks::pre_tool_envelope("PostToolUse", ctx);
if gate.asynchronous {
std::sync::Arc::clone(&self.transport).spawn_detached(envelope);
return Ok(());
}
let _ = self.transport.dispatch(envelope).await;
Ok(())
}
async fn session_start(
&self,
ctx: &crate::hooks::SessionCtx<'_>,
) -> Result<crate::hooks::SessionStartOutcome> {
if self.transport.gate().event_name != "SessionStart" {
return Ok(crate::hooks::SessionStartOutcome::default());
}
let envelope = crate::hooks::session_start_envelope(ctx);
let additional_context: Vec<String> =
self.transport.capture(envelope).await.into_iter().collect();
Ok(crate::hooks::SessionStartOutcome { additional_context })
}
}
fn event_supported(event_name: &str) -> bool {
matches!(event_name, "PreToolUse" | "PostToolUse" | "SessionStart")
}
#[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 {
if matches!(h.kind, HookHandlerType::Command | HookHandlerType::Http)
&& !event_supported(event_name)
{
tracing::warn!(
event = %event_name,
kind = ?h.kind,
"hook is bound to an event this handler kind does not fire on \
(only PreToolUse/PostToolUse/SessionStart are supported); skipping"
);
continue;
}
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(ExternalHook {
transport: std::sync::Arc::new(ShellCommandHook {
command,
args: h.args.clone(),
timeout: h.timeout,
env: h.env.clone(),
gate: HookGate {
event_name: event_name.clone(),
matcher: h.matcher.clone(),
if_pattern: h.if_pattern.clone(),
asynchronous: h.asynchronous,
},
}),
}));
}
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(ExternalHook {
transport: std::sync::Arc::new(HttpHook {
url,
headers: h.headers.clone(),
timeout: h.timeout,
allowed_url_globs: cfg.allowed_http_hook_urls.clone(),
gate: HookGate {
event_name: event_name.clone(),
matcher: h.matcher.clone(),
if_pattern: h.if_pattern.clone(),
asynchronous: h.asynchronous,
},
allowed_env_vars: cfg.http_hook_allowed_env_vars.clone(),
allow_local_targets: cfg.allow_local_http_hook_targets,
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 gate: HookGate,
}
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;
};
if let Some(decision) = explicit_decision(&out.stdout) {
return decision;
}
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 {
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}\n[truncated to {kib} KiB]", &s[..end])
}
}
#[async_trait]
impl ExternalTransport for ShellCommandHook {
async fn dispatch(&self, envelope: serde_json::Value) -> HookDecision {
ShellCommandHook::dispatch(self, envelope).await
}
fn spawn_detached(self: std::sync::Arc<Self>, envelope: serde_json::Value) {
tokio::spawn(async move {
let _ = self.dispatch(envelope).await;
});
}
async fn capture(&self, envelope: serde_json::Value) -> Option<String> {
self.run_capture(envelope)
.await
.and_then(|o| parse_session_start_context(&o.stdout))
}
fn gate(&self) -> &HookGate {
&self.gate
}
}
#[derive(Debug, Clone)]
pub struct HttpHook {
pub url: String,
pub headers: BTreeMap<String, String>,
pub timeout: Duration,
pub allowed_url_globs: Vec<String>,
pub gate: HookGate,
pub allowed_env_vars: Vec<String>,
pub allow_local_targets: bool,
pub client: reqwest::Client,
}
fn expand_env_vars(s: &str, allowed: &[String]) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
out.push_str("${");
rest = after;
continue;
};
let var = &after[..end];
if allowed.iter().any(|a| a == var) {
match std::env::var(var) {
Ok(val) => out.push_str(&val),
Err(_) => {
tracing::warn!(var = %var, "http hook: ${{VAR}} is unset; expanding to empty");
}
}
} else {
tracing::warn!(
var = %var,
"http hook: ${{VAR}} not in http_hook_allowed_env_vars; expanding to empty"
);
}
rest = &after[end + 1..];
}
out.push_str(rest);
out
}
fn url_glob_matches(pattern: &str, target: &reqwest::Url) -> bool {
use crate::permissions::matches_glob;
let (scheme_pat, rest) = match pattern.split_once("://") {
Some((s, r)) => (Some(s), r),
None => (None, pattern),
};
if let Some(sp) = scheme_pat
&& !matches_glob(sp, target.scheme())
{
return false;
}
let (authority, path_pat) = match rest.split_once('/') {
Some((a, p)) => (a, Some(format!("/{p}"))),
None => (rest, None),
};
let host_pat = authority.rsplit_once(':').map_or(authority, |(h, _)| h);
let Some(target_host) = target.host_str() else {
return false;
};
if !matches_glob(host_pat, target_host) {
return false;
}
match path_pat {
None => true,
Some(p) if p == "/" => true,
Some(p) => matches_glob(&p, target.path()),
}
}
fn ssrf_blocked_reason(target: &reqwest::Url, allow_local: bool) -> Option<&'static str> {
if !matches!(target.scheme(), "http" | "https") {
return Some("scheme is not http/https");
}
let host = target.host_str()?;
let bare = host.trim_start_matches('[').trim_end_matches(']');
let lower = bare.to_ascii_lowercase();
if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
if ip_is_link_local(ip) {
return Some("link-local / cloud-metadata address");
}
if !allow_local && ip_is_internal(ip) {
return Some("loopback / private address");
}
} else if !allow_local && (lower == "localhost" || lower.ends_with(".localhost")) {
return Some("loopback host (localhost)");
}
None
}
fn ip_is_link_local(ip: std::net::IpAddr) -> bool {
use std::net::IpAddr;
match ip {
IpAddr::V4(v4) => v4.is_link_local(),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return v4.is_link_local();
}
(v6.segments()[0] & 0xffc0) == 0xfe80
}
}
}
fn ip_is_internal(ip: std::net::IpAddr) -> bool {
use std::net::IpAddr;
if ip_is_link_local(ip) {
return true;
}
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_documentation()
|| o[0] == 0
|| (o[0] == 100 && (o[1] & 0xc0) == 64)
}
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return ip_is_internal(IpAddr::V4(v4));
}
let s0 = v6.segments()[0];
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (s0 & 0xfe00) == 0xfc00
}
}
}
impl HttpHook {
fn is_url_allowed(&self, target: &reqwest::Url) -> bool {
self.allowed_url_globs
.iter()
.any(|g| url_glob_matches(g, target))
}
async fn fetch_body(&self, envelope: serde_json::Value) -> Option<String> {
let url = expand_env_vars(&self.url, &self.allowed_env_vars);
let parsed = match reqwest::Url::parse(&url) {
Ok(u) => u,
Err(e) => {
tracing::warn!(url = %url, error = %e, "http hook: unparseable URL; skipping (Allow)");
return None;
}
};
if !self.is_url_allowed(&parsed) {
tracing::warn!(
url = %url,
"http hook: URL not in allowed_http_hook_urls; skipping (Allow)"
);
return None;
}
if let Some(reason) = ssrf_blocked_reason(&parsed, self.allow_local_targets) {
tracing::warn!(
url = %url,
reason,
"http hook: SSRF-blocked target; skipping (Allow)"
);
return None;
}
let mut req = self.client.post(parsed).json(&envelope);
for (k, v) in &self.headers {
req = req.header(k, expand_env_vars(v, &self.allowed_env_vars));
}
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 ExternalTransport for HttpHook {
async fn dispatch(&self, envelope: serde_json::Value) -> HookDecision {
HttpHook::dispatch(self, envelope).await
}
fn spawn_detached(self: std::sync::Arc<Self>, envelope: serde_json::Value) {
tokio::spawn(async move {
let _ = self.dispatch(envelope).await;
});
}
async fn capture(&self, envelope: serde_json::Value) -> Option<String> {
self.fetch_body(envelope)
.await
.and_then(|b| parse_session_start_context(&b))
}
fn gate(&self) -> &HookGate {
&self.gate
}
}
#[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()
}
fn u(s: &str) -> reqwest::Url {
reqwest::Url::parse(s).unwrap()
}
#[test]
fn url_glob_matches_host_component_not_substring() {
let allow = "*.example.com/*";
assert!(
url_glob_matches(allow, &u("https://api.example.com/hook")),
"legit subdomain must match"
);
assert!(
!url_glob_matches(allow, &u("http://attacker.com/?x=.example.com/evil")),
"attacker.com host must not match *.example.com (#217)"
);
assert!(
!url_glob_matches(allow, &u("http://example.com.attacker.net/x")),
"suffix-style spoof must not match"
);
}
#[test]
fn url_glob_matches_scheme_and_path_components() {
let allow = "https://hooks.example.com/cb";
assert!(url_glob_matches(allow, &u("https://hooks.example.com/cb")));
assert!(
!url_glob_matches(allow, &u("http://hooks.example.com/cb")),
"scheme mismatch must fail"
);
assert!(
!url_glob_matches(allow, &u("https://evil.com/cb")),
"host mismatch must fail"
);
assert!(url_glob_matches(
"https://hooks.example.com/*",
&u("https://hooks.example.com/anything/here")
));
}
#[test]
fn ssrf_blocks_loopback_link_local_and_bad_scheme() {
for blocked in [
"http://127.0.0.1/x",
"http://169.254.169.254/latest/meta-data/",
"http://localhost/x",
"http://sub.localhost/x",
"http://[::1]/x",
"http://10.0.0.5/x",
"http://192.168.1.1/x",
"http://172.16.5.4/x",
"http://100.64.1.2/x",
"http://0.0.0.0/x",
"file:///etc/passwd",
"gopher://127.0.0.1/x",
] {
assert!(
ssrf_blocked_reason(&u(blocked), false).is_some(),
"{blocked} must be SSRF-blocked"
);
}
assert!(ssrf_blocked_reason(&u("https://api.example.com/x"), false).is_none());
assert!(ssrf_blocked_reason(&u("http://93.184.216.34/x"), false).is_none());
assert!(ssrf_blocked_reason(&u("http://127.0.0.1/x"), true).is_none());
assert!(ssrf_blocked_reason(&u("http://localhost/x"), true).is_none());
assert!(ssrf_blocked_reason(&u("http://10.0.0.5/x"), true).is_none());
assert!(
ssrf_blocked_reason(&u("http://169.254.169.254/latest/"), true).is_some(),
"link-local / metadata must stay blocked even with the local opt-in"
);
assert!(ssrf_blocked_reason(&u("file:///etc/passwd"), true).is_some());
}
#[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 truncate_kb_handles_multibyte_at_boundary() {
let mut s = "a".repeat(8 * 1024 - 1);
s.push('😀'); let out = truncate_kb(&s, 8);
assert!(out.contains("[truncated to 8 KiB]"));
assert!(!out.contains('😀'));
}
#[test]
fn event_supported_only_for_implemented_events() {
assert!(event_supported("PreToolUse"));
assert!(event_supported("PostToolUse"));
assert!(event_supported("SessionStart"));
assert!(!event_supported("UserPromptSubmit"));
assert!(!event_supported("PreCompact"));
assert!(!event_supported("Stop"));
}
#[test]
fn expand_env_vars_only_substitutes_allowlisted() {
assert_eq!(expand_env_vars("x=${HOME}", &[]), "x=");
assert_eq!(
expand_env_vars(
"x=${DEFINITELY_UNSET_VAR_H5}",
&["DEFINITELY_UNSET_VAR_H5".to_string()]
),
"x="
);
assert_eq!(expand_env_vars("a${b", &["b".to_string()]), "a${b");
if let Some((name, value)) = std::env::vars().next() {
let tmpl = format!("v=${{{name}}}");
assert_eq!(expand_env_vars(&tmpl, &[name]), format!("v={value}"));
}
}
#[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));
}
}