use serde_json::Value;
use super::{
OnFailure,
contract::{HookOutcome, HookRequest},
};
pub(super) struct Participant<'a> {
kind: &'static str,
name: &'a str,
on_failure: OnFailure,
}
impl<'a> Participant<'a> {
pub(super) fn hook(name: &'a str, on_failure: OnFailure) -> Self {
Self {
kind: "hook",
name,
on_failure,
}
}
pub(super) fn interceptor(name: &'a str) -> Self {
Self {
kind: "interceptor",
name,
on_failure: OnFailure::Deny,
}
}
}
impl std::fmt::Display for Participant<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} '{}'", self.kind, self.name)
}
}
pub(super) enum Answer {
Allow,
Deny(Option<String>),
Modify {
input: Value,
reason: Option<String>,
},
Broken(String),
}
#[derive(Debug)]
pub(super) struct Chain {
request: HookRequest,
modifiers: Vec<String>,
}
impl Chain {
pub(super) fn new(request: HookRequest) -> Self {
Self {
request,
modifiers: Vec::new(),
}
}
pub(super) fn request(&self) -> &HookRequest {
&self.request
}
pub(super) fn advance(
self,
who: Participant<'_>,
answer: Answer,
report: &dyn Fn(&str),
) -> Result<Self, HookOutcome> {
match answer {
Answer::Allow => Ok(self),
Answer::Deny(reason) => Err(HookOutcome::Deny(match reason {
Some(reason) => format!("denied by {who}: {reason}"),
None => format!("denied by {who}"),
})),
Answer::Modify { input, reason } => {
if !input.is_object() {
return self.broken(
who,
"asked to replace the tool input with something that is not a JSON object"
.to_string(),
report,
);
}
Ok(Self {
request: self.request.with_input(input),
modifiers: {
let mut modifiers = self.modifiers;
modifiers.push(match reason {
Some(reason) => format!("{who}: {reason}"),
None => who.to_string(),
});
modifiers
},
})
}
Answer::Broken(failure) => self.broken(who, failure, report),
}
}
pub(super) fn outcome(self) -> HookOutcome {
if self.modifiers.is_empty() {
return HookOutcome::Allow;
}
HookOutcome::Modify {
input: self.request.input,
reason: Some(self.modifiers.join("; ")),
}
}
fn broken(
self,
who: Participant<'_>,
failure: String,
report: &dyn Fn(&str),
) -> Result<Self, HookOutcome> {
report(&format!("{who} {failure}"));
match who.on_failure {
OnFailure::Deny => Err(HookOutcome::Deny(format!(
"{who} could not answer and denies on failure: {failure}"
))),
OnFailure::Allow => Ok(self),
}
}
}
#[cfg(test)]
mod tests {
use std::{path::Path, sync::Mutex};
use serde_json::json;
use super::*;
use crate::hooks::{HookCall, HookEvent};
fn chain() -> Chain {
Chain::new(HookRequest::from_call(
HookEvent::PreToolUse,
Path::new("/repo"),
&HookCall::new("agent-1", "shell", "call-1", r#"{"command":"ls"}"#),
))
}
#[derive(Default)]
struct Reports(Mutex<Vec<String>>);
impl Reports {
fn sink(&self) -> impl Fn(&str) + '_ {
|message: &str| {
self.0
.lock()
.expect("not poisoned")
.push(message.to_string())
}
}
fn all(&self) -> Vec<String> {
self.0.lock().expect("not poisoned").clone()
}
}
fn nowhere(_message: &str) {}
#[test]
fn a_chain_nobody_touched_allows() {
assert_eq!(chain().outcome(), HookOutcome::Allow);
}
#[test]
fn a_refusal_names_the_binding_that_refused() {
let hook = chain()
.advance(
Participant::hook("guard", OnFailure::Deny),
Answer::Deny(Some("not here".to_string())),
&nowhere,
)
.expect_err("denied");
let interceptor = chain()
.advance(
Participant::interceptor("guard"),
Answer::Deny(None),
&nowhere,
)
.expect_err("denied");
assert_eq!(
hook,
HookOutcome::Deny("denied by hook 'guard': not here".to_string())
);
assert_eq!(
interceptor,
HookOutcome::Deny("denied by interceptor 'guard'".to_string())
);
}
#[test]
fn a_modification_is_what_the_next_participant_sees() {
let chain = chain()
.advance(
Participant::interceptor("redact"),
Answer::Modify {
input: json!({"command": "deploy --token REDACTED"}),
reason: Some("stripped a credential".to_string()),
},
&nowhere,
)
.expect("allowed on");
assert_eq!(chain.request().input["command"], "deploy --token REDACTED");
assert_eq!(
chain.outcome(),
HookOutcome::Modify {
input: json!({"command": "deploy --token REDACTED"}),
reason: Some("interceptor 'redact': stripped a credential".to_string()),
}
);
}
#[test]
fn every_hand_that_touched_the_call_is_named_in_order() {
let outcome = chain()
.advance(
Participant::interceptor("first"),
Answer::Modify {
input: json!({"command": "once"}),
reason: None,
},
&nowhere,
)
.expect("allowed on")
.advance(
Participant::hook("second", OnFailure::Deny),
Answer::Modify {
input: json!({"command": "twice"}),
reason: Some("narrowed".to_string()),
},
&nowhere,
)
.expect("allowed on")
.outcome();
assert_eq!(
outcome,
HookOutcome::Modify {
input: json!({"command": "twice"}),
reason: Some("interceptor 'first'; hook 'second': narrowed".to_string()),
}
);
}
#[test]
fn a_modification_cannot_smuggle_a_call_past_a_later_guard() {
let outcome = chain()
.advance(
Participant::interceptor("rewriter"),
Answer::Modify {
input: json!({"command": "sneaky"}),
reason: None,
},
&nowhere,
)
.expect("allowed on")
.advance(
Participant::hook("guard", OnFailure::Deny),
Answer::Deny(Some("still no".to_string())),
&nowhere,
)
.expect_err("denied");
assert_eq!(
outcome,
HookOutcome::Deny("denied by hook 'guard': still no".to_string())
);
}
#[test]
fn a_replacement_that_is_not_an_object_is_refused_whichever_binding_sent_it() {
for who in [
Participant::hook("confused", OnFailure::Deny),
Participant::interceptor("confused"),
] {
let reports = Reports::default();
let outcome = chain()
.advance(
who,
Answer::Modify {
input: json!("ls -l"),
reason: None,
},
&reports.sink(),
)
.expect_err("denied");
let HookOutcome::Deny(reason) = outcome else {
panic!("expected a denial");
};
assert!(reason.contains("not a JSON object"), "got {reason}");
assert_eq!(reports.all().len(), 1);
}
}
#[test]
fn an_interceptor_that_breaks_denies_and_says_so() {
let reports = Reports::default();
let outcome = chain()
.advance(
Participant::interceptor("vault"),
Answer::Broken("answered with an error: unreachable".to_string()),
&reports.sink(),
)
.expect_err("denied");
assert_eq!(
outcome,
HookOutcome::Deny(
"interceptor 'vault' could not answer and denies on failure: answered with an \
error: unreachable"
.to_string()
)
);
assert_eq!(
reports.all(),
vec!["interceptor 'vault' answered with an error: unreachable"],
"a broken guard must be announced, not only reported to the model"
);
}
#[test]
fn a_participant_that_chose_to_fail_open_is_still_announced() {
let reports = Reports::default();
let chain = chain()
.advance(
Participant::hook("logger", OnFailure::Allow),
Answer::Broken("exited with code 1".to_string()),
&reports.sink(),
)
.expect("carries on");
assert_eq!(chain.outcome(), HookOutcome::Allow);
assert_eq!(
reports.all().len(),
1,
"carrying on is not the same as staying quiet"
);
}
}