use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use futures_util::future::BoxFuture;
use serde_json::Value;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
pub const HOOK_PAYLOAD_CAP: usize = 2048;
pub const CAP_MARK: &str = "\n<capped — the tool receives the full value>";
pub const HOOK_CALL_TIMEOUT: Duration = Duration::from_secs(15);
pub async fn call_pre_tool(
hooks: &Arc<dyn Hooks>,
name: &str,
input: &Value,
cancel: &CancellationToken,
) -> PreToolDecision {
tokio::select! {
biased;
_ = cancel.cancelled() => PreToolDecision::Continue,
decision = tokio::time::timeout(HOOK_CALL_TIMEOUT, hooks.pre_tool(name, input)) => {
decision.unwrap_or(PreToolDecision::Continue)
}
}
}
pub async fn call_post_tool(
hooks: &Arc<dyn Hooks>,
name: &str,
result: &str,
cancel: &CancellationToken,
) -> Option<String> {
let capped = cap_payload(result);
tokio::select! {
biased;
_ = cancel.cancelled() => None,
replacement = tokio::time::timeout(HOOK_CALL_TIMEOUT, hooks.post_tool(name, capped)) => {
replacement.ok().flatten()
}
}
}
fn capped_view(s: &str) -> Option<String> {
(s.len() > HOOK_PAYLOAD_CAP).then(|| format!("{}{CAP_MARK}", cap_payload(s)))
}
pub fn cap_tool_input(input: &Value) -> Value {
let Some(fields) = input.as_object() else {
return input.clone();
};
let mut out = serde_json::Map::with_capacity(fields.len());
for (key, value) in fields {
let capped = value.as_str().and_then(capped_view);
out.insert(
key.clone(),
capped.map_or_else(|| value.clone(), Value::String),
);
}
Value::Object(out)
}
pub fn restore_capped(original: &Value, mut rewritten: Value) -> Value {
if let (Some(fields), Some(out)) = (original.as_object(), rewritten.as_object_mut()) {
for (key, value) in fields {
let Some(capped) = value.as_str().and_then(capped_view) else {
continue;
};
if out.get(key).and_then(Value::as_str) == Some(capped.as_str()) {
out.insert(key.clone(), value.clone());
}
}
}
rewritten
}
pub async fn call_user_prompt(hooks: &Arc<dyn Hooks>, prompt: &str) -> Option<String> {
tokio::time::timeout(HOOK_CALL_TIMEOUT, hooks.on_user_prompt(prompt))
.await
.ok()
.flatten()
}
pub const NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone, Default)]
pub struct NotificationDrain(Arc<Mutex<Vec<JoinHandle<()>>>>);
impl NotificationDrain {
pub fn new() -> Self {
Self::default()
}
fn track(&self, handle: JoinHandle<()>) {
let mut guard = self.0.lock().unwrap_or_else(PoisonError::into_inner);
guard.retain(|h| !h.is_finished());
guard.push(handle);
}
pub async fn drain(&self, grace: Duration) {
let handles: Vec<_> = {
let mut guard = self.0.lock().unwrap_or_else(PoisonError::into_inner);
std::mem::take(&mut *guard)
};
if handles.is_empty() {
return;
}
let _ = tokio::time::timeout(grace, futures_util::future::join_all(handles)).await;
}
}
pub fn notify(
hooks: &Arc<dyn Hooks>,
drain: &NotificationDrain,
kind: NotificationKind,
detail: impl Into<String>,
) {
let hooks = Arc::clone(hooks);
let detail = detail.into();
let handle = tokio::spawn(async move {
let _ =
tokio::time::timeout(NOTIFICATION_TIMEOUT, hooks.on_notification(kind, &detail)).await;
});
drain.track(handle);
}
pub async fn call_stop(hooks: &Arc<dyn Hooks>, outcome: &str) -> StopDecision {
tokio::time::timeout(HOOK_CALL_TIMEOUT, hooks.on_stop(outcome))
.await
.unwrap_or(StopDecision::Allow)
}
pub async fn call_session_end(hooks: &Arc<dyn Hooks>) {
let _ = tokio::time::timeout(NOTIFICATION_TIMEOUT, hooks.on_session_end()).await;
}
#[derive(Debug, Clone, PartialEq)]
pub enum PreToolDecision {
Continue,
Deny { message: String },
Rewrite { input: Value },
}
#[derive(Debug, Clone, PartialEq)]
pub enum Matcher {
All,
Names(Vec<String>),
}
impl Matcher {
pub fn matches(&self, tool: &str) -> bool {
match self {
Matcher::All => true,
Matcher::Names(names) => names.iter().any(|n| n == tool),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct EventMask(u8);
impl EventMask {
pub const PRE_TOOL: EventMask = EventMask(1 << 0);
pub const POST_TOOL: EventMask = EventMask(1 << 1);
pub const USER_PROMPT: EventMask = EventMask(1 << 2);
pub const NOTIFICATION: EventMask = EventMask(1 << 3);
pub const STOP: EventMask = EventMask(1 << 4);
pub const SESSION_END: EventMask = EventMask(1 << 5);
pub const NONE: EventMask = EventMask(0);
pub const ALL: EventMask = EventMask(
Self::PRE_TOOL.0
| Self::POST_TOOL.0
| Self::USER_PROMPT.0
| Self::NOTIFICATION.0
| Self::STOP.0
| Self::SESSION_END.0,
);
pub const fn contains(self, bit: EventMask) -> bool {
self.0 & bit.0 == bit.0
}
pub const fn union(self, other: EventMask) -> EventMask {
EventMask(self.0 | other.0)
}
pub const fn difference(self, other: EventMask) -> EventMask {
EventMask(self.0 & !other.0)
}
pub const fn bits(self) -> u8 {
self.0
}
pub const fn from_bits(bits: u8) -> EventMask {
EventMask(bits & Self::ALL.0)
}
}
pub trait Hooks: Send + Sync {
fn pre_tool<'a>(&'a self, name: &'a str, input: &'a Value) -> BoxFuture<'a, PreToolDecision>;
fn post_tool<'a>(&'a self, name: &'a str, result: &'a str) -> BoxFuture<'a, Option<String>>;
fn on_user_prompt<'a>(&'a self, _prompt: &'a str) -> BoxFuture<'a, Option<String>> {
Box::pin(std::future::ready(None))
}
fn on_notification<'a>(
&'a self,
_kind: NotificationKind,
_detail: &'a str,
) -> BoxFuture<'a, ()> {
Box::pin(std::future::ready(()))
}
fn on_stop<'a>(&'a self, _outcome: &'a str) -> BoxFuture<'a, StopDecision> {
Box::pin(std::future::ready(StopDecision::Allow))
}
fn on_session_end<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(std::future::ready(()))
}
fn event_mask(&self) -> EventMask {
EventMask::ALL
}
fn mask_handle(&self) -> Option<Arc<AtomicU8>> {
None
}
}
pub fn mask_of(handle: &AtomicU8) -> EventMask {
EventMask::from_bits(handle.load(Ordering::Relaxed))
}
macro_rules! hook_gate {
($hooks_opt:expr, $mask:expr, $bit:expr, |$hooks:ident| $body:expr, else $off:expr) => {
match &$hooks_opt {
Some($hooks) if $mask.contains($bit) => $body,
_ => $off,
}
};
}
pub(crate) use hook_gate;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationKind {
Blocked,
Idle,
Done,
}
#[derive(Debug, Clone, PartialEq)]
pub enum StopDecision {
Allow,
Block { reason: String },
}
pub fn cap_payload(s: &str) -> &str {
if s.len() <= HOOK_PAYLOAD_CAP {
return s;
}
let mut end = HOOK_PAYLOAD_CAP;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[derive(Default)]
pub struct InProcessHooks {
#[allow(clippy::type_complexity)]
pre: Vec<(
Matcher,
Box<dyn Fn(&str, &Value) -> PreToolDecision + Send + Sync>,
)>,
#[allow(clippy::type_complexity)]
post: Vec<(
Matcher,
Box<dyn Fn(&str, &str) -> Option<String> + Send + Sync>,
)>,
#[allow(clippy::type_complexity)]
prompt: Vec<Box<dyn Fn(&str) -> Option<String> + Send + Sync>>,
#[allow(clippy::type_complexity)]
notification: Vec<Box<dyn Fn(NotificationKind, &str) + Send + Sync>>,
#[allow(clippy::type_complexity)]
stop: Vec<Box<dyn Fn(&str) -> StopDecision + Send + Sync>>,
#[allow(clippy::type_complexity)]
session_end: Vec<Box<dyn Fn() + Send + Sync>>,
}
impl InProcessHooks {
pub fn new() -> Self {
Self::default()
}
pub fn on_pre_tool(
self,
f: impl Fn(&str, &Value) -> PreToolDecision + Send + Sync + 'static,
) -> Self {
self.on_pre_tool_matching(Matcher::All, f)
}
pub fn on_pre_tool_matching(
mut self,
matcher: Matcher,
f: impl Fn(&str, &Value) -> PreToolDecision + Send + Sync + 'static,
) -> Self {
self.pre.push((matcher, Box::new(f)));
self
}
pub fn on_post_tool(
self,
f: impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
) -> Self {
self.on_post_tool_matching(Matcher::All, f)
}
pub fn on_post_tool_matching(
mut self,
matcher: Matcher,
f: impl Fn(&str, &str) -> Option<String> + Send + Sync + 'static,
) -> Self {
self.post.push((matcher, Box::new(f)));
self
}
pub fn on_user_prompt(
mut self,
f: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
) -> Self {
self.prompt.push(Box::new(f));
self
}
pub fn on_notification(
mut self,
f: impl Fn(NotificationKind, &str) + Send + Sync + 'static,
) -> Self {
self.notification.push(Box::new(f));
self
}
pub fn on_stop(mut self, f: impl Fn(&str) -> StopDecision + Send + Sync + 'static) -> Self {
self.stop.push(Box::new(f));
self
}
pub fn on_session_end(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
self.session_end.push(Box::new(f));
self
}
pub fn is_empty(&self) -> bool {
self.pre.is_empty()
&& self.post.is_empty()
&& self.prompt.is_empty()
&& self.notification.is_empty()
&& self.stop.is_empty()
&& self.session_end.is_empty()
}
}
pub fn merge_pre_tool(results: Vec<PreToolDecision>) -> PreToolDecision {
if let Some(deny) = results
.iter()
.find(|d| matches!(d, PreToolDecision::Deny { .. }))
{
return deny.clone();
}
if let Some(rewrite) = results
.iter()
.find(|d| matches!(d, PreToolDecision::Rewrite { .. }))
{
return rewrite.clone();
}
PreToolDecision::Continue
}
pub fn merge_stop(results: Vec<StopDecision>) -> StopDecision {
results
.into_iter()
.find(|d| matches!(d, StopDecision::Block { .. }))
.unwrap_or(StopDecision::Allow)
}
impl Hooks for InProcessHooks {
fn pre_tool<'a>(&'a self, name: &'a str, input: &'a Value) -> BoxFuture<'a, PreToolDecision> {
Box::pin(async move {
let mut results = Vec::new();
for (matcher, hook) in &self.pre {
if matcher.matches(name) {
results.push(hook(name, input));
}
}
merge_pre_tool(results)
})
}
fn post_tool<'a>(&'a self, name: &'a str, result: &'a str) -> BoxFuture<'a, Option<String>> {
Box::pin(async move {
let capped = cap_payload(result);
let mut current: Option<String> = None;
for (matcher, hook) in &self.post {
if !matcher.matches(name) {
continue;
}
let view = current.as_deref().unwrap_or(capped);
if let Some(replacement) = hook(name, view) {
current = Some(replacement);
}
}
current
})
}
fn on_user_prompt<'a>(&'a self, prompt: &'a str) -> BoxFuture<'a, Option<String>> {
Box::pin(async move {
let results: Vec<_> = self.prompt.iter().filter_map(|hook| hook(prompt)).collect();
join_additional_context(results.into_iter())
})
}
fn on_notification<'a>(&'a self, kind: NotificationKind, detail: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
for hook in &self.notification {
hook(kind, detail);
}
})
}
fn on_stop<'a>(&'a self, outcome: &'a str) -> BoxFuture<'a, StopDecision> {
Box::pin(async move {
let results: Vec<_> = self.stop.iter().map(|hook| hook(outcome)).collect();
merge_stop(results)
})
}
fn on_session_end<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async move {
for hook in &self.session_end {
hook();
}
})
}
}
pub const ADDITIONAL_CONTEXT_CAP: usize = 10_000;
pub fn join_additional_context(parts: impl Iterator<Item = String>) -> Option<String> {
let mut combined = String::new();
for part in parts {
if part.is_empty() {
continue;
}
if !combined.is_empty() {
combined.push('\n');
}
combined.push_str(&part);
}
if combined.is_empty() {
None
} else {
Some(cap_str(&combined, ADDITIONAL_CONTEXT_CAP).to_string())
}
}
fn cap_str(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tokio_util::sync::CancellationToken;
#[test]
fn event_mask_bit_math() {
assert!(EventMask::ALL.contains(EventMask::PRE_TOOL));
assert!(EventMask::ALL.contains(EventMask::POST_TOOL));
assert!(EventMask::ALL.contains(EventMask::USER_PROMPT));
assert!(EventMask::ALL.contains(EventMask::NOTIFICATION));
assert!(EventMask::ALL.contains(EventMask::STOP));
assert!(EventMask::ALL.contains(EventMask::SESSION_END));
assert!(!EventMask::NONE.contains(EventMask::PRE_TOOL));
let union = EventMask::PRE_TOOL.union(EventMask::STOP);
assert!(union.contains(EventMask::PRE_TOOL));
assert!(union.contains(EventMask::STOP));
assert!(!union.contains(EventMask::POST_TOOL));
let all_bits = [
EventMask::PRE_TOOL,
EventMask::POST_TOOL,
EventMask::USER_PROMPT,
EventMask::NOTIFICATION,
EventMask::STOP,
EventMask::SESSION_END,
];
for (i, a) in all_bits.iter().enumerate() {
for (j, b) in all_bits.iter().enumerate() {
if i != j {
assert!(!a.contains(*b), "bit {i} must not contain bit {j}");
}
}
}
let cleared = union.difference(EventMask::PRE_TOOL);
assert!(!cleared.contains(EventMask::PRE_TOOL));
assert!(cleared.contains(EventMask::STOP));
}
#[test]
fn event_mask_default_hooks_impl_is_all() {
struct DefaultMaskHooks;
impl Hooks for DefaultMaskHooks {
fn pre_tool<'a>(
&'a self,
_n: &'a str,
_i: &'a Value,
) -> BoxFuture<'a, PreToolDecision> {
Box::pin(std::future::ready(PreToolDecision::Continue))
}
fn post_tool<'a>(&'a self, _n: &'a str, _r: &'a str) -> BoxFuture<'a, Option<String>> {
Box::pin(std::future::ready(None))
}
}
assert_eq!(DefaultMaskHooks.event_mask(), EventMask::ALL);
}
struct HangingHooks;
impl Hooks for HangingHooks {
fn pre_tool<'a>(&'a self, _n: &'a str, _i: &'a Value) -> BoxFuture<'a, PreToolDecision> {
Box::pin(std::future::pending())
}
fn post_tool<'a>(&'a self, _n: &'a str, _r: &'a str) -> BoxFuture<'a, Option<String>> {
Box::pin(std::future::pending())
}
}
#[tokio::test(start_paused = true)]
async fn a_hung_pre_tool_hook_times_out_instead_of_wedging_the_turn() {
let hooks: Arc<dyn Hooks> = Arc::new(HangingHooks);
let cancel = CancellationToken::new();
assert_eq!(
call_pre_tool(&hooks, "bash", &json!({}), &cancel).await,
PreToolDecision::Continue,
"a hung pre_tool degrades to a no-op — never a grant, never a block"
);
assert_eq!(
call_post_tool(&hooks, "bash", "the real output", &cancel).await,
None,
"a hung post_tool leaves the tool's real output in place"
);
}
#[tokio::test(start_paused = true)]
async fn a_cancelled_turn_does_not_wait_out_a_hook_timeout() {
let hooks: Arc<dyn Hooks> = Arc::new(HangingHooks);
let cancel = CancellationToken::new();
cancel.cancel();
let start = tokio::time::Instant::now();
assert_eq!(
call_pre_tool(&hooks, "bash", &json!({}), &cancel).await,
PreToolDecision::Continue
);
assert_eq!(call_post_tool(&hooks, "bash", "out", &cancel).await, None);
assert_eq!(
start.elapsed(),
Duration::ZERO,
"an interrupt must not wait out HOOK_CALL_TIMEOUT"
);
}
#[test]
fn capping_a_tool_input_is_reversible_when_the_hook_echoes_it_back() {
let body = "z".repeat(HOOK_PAYLOAD_CAP * 2);
let input = json!({"path": "notes.txt", "content": body});
let view = cap_tool_input(&input);
let seen = view["content"].as_str().expect("string field");
assert!(
seen.len() <= HOOK_PAYLOAD_CAP + CAP_MARK.len(),
"the hook saw {} bytes",
seen.len()
);
assert_eq!(
view["path"],
json!("notes.txt"),
"fields under the cap pass through untouched"
);
let restored = restore_capped(&input, view);
assert_eq!(restored, input, "capping must be observationally inert");
}
#[test]
fn a_deliberate_rewrite_of_a_capped_field_still_wins() {
let body = "z".repeat(HOOK_PAYLOAD_CAP * 2);
let input = json!({"path": "notes.txt", "content": body});
let mut view = cap_tool_input(&input);
view["content"] = json!("redacted by policy");
let restored = restore_capped(&input, view);
assert_eq!(
restored["content"],
json!("redacted by policy"),
"restoration must never undo a hook's real rewrite"
);
assert_eq!(restored["path"], json!("notes.txt"));
}
#[test]
fn capping_leaves_non_object_inputs_and_nested_values_alone() {
let nested = json!({"outer": {"inner": "z".repeat(HOOK_PAYLOAD_CAP * 2)}});
assert_eq!(
cap_tool_input(&nested),
nested,
"nested values are not capped"
);
let scalar = json!("just a string");
assert_eq!(cap_tool_input(&scalar), scalar);
assert_eq!(
restore_capped(&scalar, json!("rewritten")),
json!("rewritten")
);
}
#[tokio::test]
async fn pre_hook_denies_rewrites_or_continues() {
let hooks = InProcessHooks::new().on_pre_tool(|name, input| {
if name == "bash" && input.get("command").and_then(Value::as_str) == Some("rm -rf /") {
PreToolDecision::Deny {
message: "no destructive commands".into(),
}
} else if name == "write" {
PreToolDecision::Rewrite {
input: json!({"path": "safe.txt", "content": "x"}),
}
} else {
PreToolDecision::Continue
}
});
assert_eq!(
hooks
.pre_tool("bash", &json!({"command": "rm -rf /"}))
.await,
PreToolDecision::Deny {
message: "no destructive commands".into()
}
);
assert!(matches!(
hooks
.pre_tool("write", &json!({"path": "x", "content": "y"}))
.await,
PreToolDecision::Rewrite { .. }
));
assert_eq!(
hooks.pre_tool("read", &json!({})).await,
PreToolDecision::Continue
);
}
#[tokio::test]
async fn post_hook_caps_and_replaces() {
let hooks = InProcessHooks::new()
.on_post_tool(|_n, result| Some(format!("[annotated] {} chars", result.len())));
let big = "z".repeat(HOOK_PAYLOAD_CAP * 2);
let out = hooks.post_tool("read", &big).await.unwrap();
assert!(out.contains(&format!("{} chars", HOOK_PAYLOAD_CAP)));
}
#[tokio::test]
async fn matcher_scopes_pre_hook_to_named_tools() {
let hooks = InProcessHooks::new().on_pre_tool_matching(
Matcher::Names(vec!["bash".into()]),
|_n, _i| PreToolDecision::Deny {
message: "no bash".into(),
},
);
assert!(matches!(
hooks.pre_tool("bash", &json!({})).await,
PreToolDecision::Deny { .. }
));
assert_eq!(
hooks.pre_tool("read", &json!({})).await,
PreToolDecision::Continue
);
}
#[tokio::test]
async fn in_process_hooks_run_in_registration_order() {
let order: Arc<Mutex<Vec<&'static str>>> = Arc::default();
let push = |order: &Arc<Mutex<Vec<&'static str>>>, tag: &'static str| {
let order = Arc::clone(order);
move |_outcome: &str| {
order.lock().expect("lock").push(tag);
StopDecision::Allow
}
};
let hooks = InProcessHooks::new()
.on_stop(push(&order, "a"))
.on_stop(push(&order, "b"))
.on_stop(push(&order, "c"));
assert_eq!(Hooks::on_stop(&hooks, "done").await, StopDecision::Allow);
assert_eq!(*order.lock().expect("lock"), vec!["a", "b", "c"]);
}
#[test]
fn matcher_all_and_names() {
assert!(Matcher::All.matches("anything"));
assert!(Matcher::Names(vec!["bash".into(), "write".into()]).matches("write"));
assert!(!Matcher::Names(vec!["bash".into()]).matches("read"));
}
#[tokio::test]
async fn multiple_matching_pre_hooks_merge_most_restrictive_first() {
let hooks = InProcessHooks::new()
.on_pre_tool(|_n, _i| PreToolDecision::Continue)
.on_pre_tool(|_n, _i| PreToolDecision::Rewrite {
input: json!({"rewritten": true}),
})
.on_pre_tool(|_n, _i| PreToolDecision::Deny {
message: "blocked".into(),
});
assert_eq!(
hooks.pre_tool("bash", &json!({})).await,
PreToolDecision::Deny {
message: "blocked".into()
}
);
}
#[tokio::test]
async fn ties_among_same_severity_decisions_resolve_by_registration_order() {
let hooks = InProcessHooks::new()
.on_pre_tool(|_n, _i| PreToolDecision::Deny {
message: "first".into(),
})
.on_pre_tool(|_n, _i| PreToolDecision::Deny {
message: "second".into(),
});
assert_eq!(
hooks.pre_tool("bash", &json!({})).await,
PreToolDecision::Deny {
message: "first".into()
}
);
}
#[tokio::test]
async fn a_non_matching_hook_never_contributes_to_the_merge() {
let hooks = InProcessHooks::new()
.on_pre_tool_matching(Matcher::Names(vec!["write".into()]), |_n, _i| {
PreToolDecision::Deny {
message: "no write".into(),
}
})
.on_pre_tool(|_n, _i| PreToolDecision::Continue);
assert_eq!(
hooks.pre_tool("bash", &json!({})).await,
PreToolDecision::Continue
);
}
}