use std::collections::BTreeMap;
use std::time::Duration;
use caliban_agent_core::{HookDecision, Hooks, HttpHook, ToolCtx};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn ctx<'a>(name: &'a str, input: &'a serde_json::Value) -> ToolCtx<'a> {
ToolCtx {
session_id: "test-session",
turn_index: 0,
tool_use_id: "t1",
tool_name: name,
input,
is_read_only: false,
}
}
#[tokio::test]
async fn http_200_with_deny_body_denies() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/preflight"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"http says no"}}"#,
))
.mount(&server)
.await;
let url = format!("{}/preflight", server.uri());
let hook = caliban_agent_core::ExternalHook {
transport: std::sync::Arc::new(HttpHook {
allowed_env_vars: vec![],
url: url.clone(),
headers: BTreeMap::new(),
timeout: Duration::from_secs(5),
allowed_url_globs: vec!["*".into()],
allow_local_targets: true,
client: reqwest::Client::new(),
gate: caliban_agent_core::HookGate {
event_name: "PreToolUse".into(),
matcher: "*".into(),
if_pattern: None,
asynchronous: false,
},
}),
};
let input = serde_json::json!({});
let d = hook.before_tool(&ctx("Bash", &input)).await.unwrap();
match d {
HookDecision::Deny(msg) => assert!(msg.contains("http says no")),
d => panic!("unexpected: {d:?}"),
}
}
#[tokio::test]
async fn http_url_not_allowlisted_skips() {
let server = MockServer::start().await;
let url = format!("{}/preflight", server.uri());
let hook = caliban_agent_core::ExternalHook {
transport: std::sync::Arc::new(HttpHook {
allowed_env_vars: vec![],
url: url.clone(),
headers: BTreeMap::new(),
timeout: Duration::from_secs(5),
allowed_url_globs: vec!["*.example.com/*".into()],
allow_local_targets: true,
client: reqwest::Client::new(),
gate: caliban_agent_core::HookGate {
event_name: "PreToolUse".into(),
matcher: "*".into(),
if_pattern: None,
asynchronous: false,
},
}),
};
let input = serde_json::json!({});
let d = hook.before_tool(&ctx("Bash", &input)).await.unwrap();
assert!(matches!(d, HookDecision::Allow));
}
#[tokio::test]
async fn http_non_2xx_is_allow() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/preflight"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let url = format!("{}/preflight", server.uri());
let hook = caliban_agent_core::ExternalHook {
transport: std::sync::Arc::new(HttpHook {
allowed_env_vars: vec![],
url: url.clone(),
headers: BTreeMap::new(),
timeout: Duration::from_secs(5),
allowed_url_globs: vec!["*".into()],
allow_local_targets: true,
client: reqwest::Client::new(),
gate: caliban_agent_core::HookGate {
event_name: "PreToolUse".into(),
matcher: "*".into(),
if_pattern: None,
asynchronous: false,
},
}),
};
let input = serde_json::json!({});
let d = hook.before_tool(&ctx("Bash", &input)).await.unwrap();
assert!(matches!(d, HookDecision::Allow));
}
#[tokio::test]
async fn http_updated_input_parses() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rewrite"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"hookSpecificOutput":{"updatedInput":{"command":"echo redacted"}}}"#,
))
.mount(&server)
.await;
let url = format!("{}/rewrite", server.uri());
let hook = caliban_agent_core::ExternalHook {
transport: std::sync::Arc::new(HttpHook {
allowed_env_vars: vec![],
url: url.clone(),
headers: BTreeMap::new(),
timeout: Duration::from_secs(5),
allowed_url_globs: vec!["*".into()],
allow_local_targets: true,
client: reqwest::Client::new(),
gate: caliban_agent_core::HookGate {
event_name: "PreToolUse".into(),
matcher: "*".into(),
if_pattern: None,
asynchronous: false,
},
}),
};
let input = serde_json::json!({"command": "rm -rf /"});
let d = hook.before_tool(&ctx("Bash", &input)).await.unwrap();
match d {
HookDecision::UpdatedInput(v) => assert_eq!(v["command"], "echo redacted"),
d => panic!("unexpected: {d:?}"),
}
}
#[tokio::test]
async fn http_matcher_skips_non_matching_tools() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/wf"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(r#"{"hookSpecificOutput":{"permissionDecision":"deny"}}"#),
)
.mount(&server)
.await;
let url = format!("{}/wf", server.uri());
let hook = caliban_agent_core::ExternalHook {
transport: std::sync::Arc::new(HttpHook {
allowed_env_vars: vec![],
url: url.clone(),
headers: BTreeMap::new(),
timeout: Duration::from_secs(5),
allowed_url_globs: vec!["*".into()],
allow_local_targets: true,
client: reqwest::Client::new(),
gate: caliban_agent_core::HookGate {
event_name: "PreToolUse".into(),
matcher: "WebFetch".into(),
if_pattern: None,
asynchronous: false,
},
}),
};
let input = serde_json::json!({});
let d = hook.before_tool(&ctx("Bash", &input)).await.unwrap();
assert!(matches!(d, HookDecision::Allow));
}