use std::sync::Arc;
use super::contract::{HookOutcome, HookRequest};
pub type InterceptorError = Box<dyn std::error::Error + Send + Sync>;
#[async_trait::async_trait]
pub trait Interceptor: Send + Sync {
fn name(&self) -> &str;
async fn intercept(&self, call: &HookRequest) -> Result<HookOutcome, InterceptorError>;
async fn review(&self, result: &HookRequest) -> Result<HookOutcome, InterceptorError> {
let _ = result;
Ok(HookOutcome::Allow)
}
}
#[async_trait::async_trait]
impl<T: Interceptor + ?Sized> Interceptor for Box<T> {
fn name(&self) -> &str {
(**self).name()
}
async fn intercept(&self, call: &HookRequest) -> Result<HookOutcome, InterceptorError> {
(**self).intercept(call).await
}
async fn review(&self, result: &HookRequest) -> Result<HookOutcome, InterceptorError> {
(**self).review(result).await
}
}
#[async_trait::async_trait]
impl<T: Interceptor + ?Sized> Interceptor for Arc<T> {
fn name(&self) -> &str {
(**self).name()
}
async fn intercept(&self, call: &HookRequest) -> Result<HookOutcome, InterceptorError> {
(**self).intercept(call).await
}
async fn review(&self, result: &HookRequest) -> Result<HookOutcome, InterceptorError> {
(**self).review(result).await
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::hooks::{HookCall, HookEvent};
struct Named(&'static str);
#[async_trait::async_trait]
impl Interceptor for Named {
fn name(&self) -> &str {
self.0
}
async fn intercept(&self, call: &HookRequest) -> Result<HookOutcome, InterceptorError> {
Ok(HookOutcome::Deny(call.tool_name.clone()))
}
}
fn request() -> HookRequest {
HookRequest::from_call(
HookEvent::PreToolUse,
Path::new("/repo"),
&HookCall::new("agent-1", "shell", "call-1", r#"{"command":"ls"}"#),
)
}
fn result() -> HookRequest {
HookRequest::from_result(
Path::new("/repo"),
&HookCall::new("agent-1", "shell", "call-1", r#"{"command":"cat .env"}"#),
serde_json::json!("TOKEN=hunter2"),
false,
)
}
#[tokio::test]
async fn an_indirected_interceptor_answers_exactly_as_the_one_inside() {
let boxed: Box<dyn Interceptor> = Box::new(Named("boxed"));
let shared: Arc<dyn Interceptor> = Arc::new(Named("shared"));
assert_eq!(boxed.name(), "boxed");
assert_eq!(shared.name(), "shared");
assert_eq!(
boxed.intercept(&request()).await.expect("answers"),
HookOutcome::Deny("shell".to_string())
);
assert_eq!(
shared.intercept(&request()).await.expect("answers"),
HookOutcome::Deny("shell".to_string())
);
}
#[tokio::test]
async fn an_interceptor_that_only_guards_calls_keeps_every_result() {
assert_eq!(
Named("guard").review(&result()).await.expect("answers"),
HookOutcome::Allow
);
}
#[tokio::test]
async fn an_indirection_forwards_a_review_too() {
struct Redacts;
#[async_trait::async_trait]
impl Interceptor for Redacts {
fn name(&self) -> &str {
"redacts"
}
async fn intercept(
&self,
_call: &HookRequest,
) -> Result<HookOutcome, InterceptorError> {
Ok(HookOutcome::Allow)
}
async fn review(&self, result: &HookRequest) -> Result<HookOutcome, InterceptorError> {
Ok(HookOutcome::Replace {
output: serde_json::json!("[redacted]"),
is_error: result.is_error.unwrap_or(false),
reason: Some("a credential".to_string()),
})
}
}
let boxed: Box<dyn Interceptor> = Box::new(Redacts);
let shared: Arc<dyn Interceptor> = Arc::new(Redacts);
for indirected in [&boxed as &dyn Interceptor, &shared as &dyn Interceptor] {
assert_eq!(
indirected.review(&result()).await.expect("answers"),
HookOutcome::Replace {
output: serde_json::json!("[redacted]"),
is_error: false,
reason: Some("a credential".to_string()),
}
);
}
}
#[tokio::test]
async fn a_review_is_shown_the_output_as_well_as_the_input() {
struct SeesOutput;
#[async_trait::async_trait]
impl Interceptor for SeesOutput {
fn name(&self) -> &str {
"sees-output"
}
async fn intercept(
&self,
_call: &HookRequest,
) -> Result<HookOutcome, InterceptorError> {
Ok(HookOutcome::Allow)
}
async fn review(&self, result: &HookRequest) -> Result<HookOutcome, InterceptorError> {
Ok(HookOutcome::Deny(format!(
"{} -> {}",
result.input["command"],
result.output.clone().unwrap_or_default()
)))
}
}
assert_eq!(
SeesOutput.review(&result()).await.expect("answers"),
HookOutcome::Deny("\"cat .env\" -> \"TOKEN=hunter2\"".to_string())
);
}
#[tokio::test]
async fn an_interceptor_is_asked_about_the_call_as_it_now_stands() {
struct SeesInput;
#[async_trait::async_trait]
impl Interceptor for SeesInput {
fn name(&self) -> &str {
"sees-input"
}
async fn intercept(&self, call: &HookRequest) -> Result<HookOutcome, InterceptorError> {
Ok(HookOutcome::Deny(call.input["command"].to_string()))
}
}
assert_eq!(
SeesInput.intercept(&request()).await.expect("answers"),
HookOutcome::Deny("\"ls\"".to_string())
);
}
#[tokio::test]
async fn any_error_a_host_has_can_be_carried_out() {
struct Fails;
#[async_trait::async_trait]
impl Interceptor for Fails {
fn name(&self) -> &str {
"fails"
}
async fn intercept(
&self,
_call: &HookRequest,
) -> Result<HookOutcome, InterceptorError> {
Err(std::io::Error::other("the vault is unreachable"))?
}
}
assert_eq!(
Fails
.intercept(&request())
.await
.expect_err("fails")
.to_string(),
"the vault is unreachable"
);
}
}