1use std::sync::Arc;
8
9use async_trait::async_trait;
10
11use crate::tools::ToolResult;
12
13#[derive(Debug)]
15pub enum HookDecision {
16 Allow,
17 Block(String), }
19
20#[async_trait]
22pub trait ToolHook: Send + Sync {
23 async fn before_tool_use(&self, tool_name: &str, arguments: &str) -> HookDecision;
25
26 async fn after_tool_result(&self, _tool_name: &str, _arguments: &str, _result: &ToolResult) {}
28}
29
30pub struct PermissionHook {
34 deny: Vec<String>,
35 allow: Vec<String>,
36}
37
38impl PermissionHook {
39 pub fn new(deny: Vec<String>, allow: Vec<String>) -> Self {
40 Self { deny, allow }
41 }
42
43 fn matches(pattern: &str, tool_name: &str) -> bool {
44 if let Some(prefix) = pattern.strip_suffix('*') {
45 tool_name.starts_with(prefix)
46 } else {
47 pattern == tool_name
48 }
49 }
50}
51
52#[async_trait]
53impl ToolHook for PermissionHook {
54 async fn before_tool_use(&self, tool_name: &str, _arguments: &str) -> HookDecision {
55 for pattern in &self.deny {
57 if Self::matches(pattern, tool_name) {
58 return HookDecision::Block(format!(
59 "Tool '{}' is blocked by permission rules (deny list).",
60 tool_name
61 ));
62 }
63 }
64
65 if !self.allow.is_empty() {
67 let allowed = self.allow.iter().any(|p| Self::matches(p, tool_name));
68 if !allowed {
69 return HookDecision::Block(format!(
70 "Tool '{}' is not in the allow list.",
71 tool_name
72 ));
73 }
74 }
75
76 HookDecision::Allow
77 }
78}
79
80pub struct LoggingHook;
82
83#[async_trait]
84impl ToolHook for LoggingHook {
85 async fn before_tool_use(&self, tool_name: &str, arguments: &str) -> HookDecision {
86 tracing::debug!("→ tool:{} args_len:{}", tool_name, arguments.len());
87 HookDecision::Allow
88 }
89
90 async fn after_tool_result(&self, tool_name: &str, _arguments: &str, result: &ToolResult) {
91 tracing::debug!(
92 "← tool:{} is_error:{} len:{}",
93 tool_name,
94 result.is_error,
95 result.output.len()
96 );
97 }
98}
99
100pub async fn run_pre_hooks(
103 hooks: &[Arc<dyn ToolHook>],
104 tool_name: &str,
105 arguments: &str,
106) -> HookDecision {
107 for hook in hooks {
108 match hook.before_tool_use(tool_name, arguments).await {
109 HookDecision::Block(reason) => return HookDecision::Block(reason),
110 HookDecision::Allow => {}
111 }
112 }
113 HookDecision::Allow
114}
115
116pub async fn run_post_hooks(
118 hooks: &[Arc<dyn ToolHook>],
119 tool_name: &str,
120 arguments: &str,
121 result: &ToolResult,
122) {
123 for hook in hooks {
124 hook.after_tool_result(tool_name, arguments, result).await;
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[tokio::test]
133 async fn permission_hook_deny() {
134 let hook = PermissionHook::new(vec!["exec".to_string()], vec![]);
135 assert!(matches!(
136 hook.before_tool_use("exec", "{}").await,
137 HookDecision::Block(_)
138 ));
139 assert!(matches!(
140 hook.before_tool_use("web_search", "{}").await,
141 HookDecision::Allow
142 ));
143 }
144
145 #[tokio::test]
146 async fn permission_hook_allow_list() {
147 let hook = PermissionHook::new(
148 vec![],
149 vec!["web_search".to_string(), "file_ops".to_string()],
150 );
151 assert!(matches!(
152 hook.before_tool_use("web_search", "{}").await,
153 HookDecision::Allow
154 ));
155 assert!(matches!(
156 hook.before_tool_use("exec", "{}").await,
157 HookDecision::Block(_)
158 ));
159 }
160
161 #[tokio::test]
162 async fn permission_hook_wildcard() {
163 let hook = PermissionHook::new(vec!["web_*".to_string()], vec![]);
164 assert!(matches!(
165 hook.before_tool_use("web_search", "{}").await,
166 HookDecision::Block(_)
167 ));
168 assert!(matches!(
169 hook.before_tool_use("web_fetch", "{}").await,
170 HookDecision::Block(_)
171 ));
172 assert!(matches!(
173 hook.before_tool_use("exec", "{}").await,
174 HookDecision::Allow
175 ));
176 }
177
178 #[tokio::test]
179 async fn run_pre_hooks_first_block_wins() {
180 let hooks: Vec<Arc<dyn ToolHook>> = vec![
181 Arc::new(PermissionHook::new(vec!["exec".to_string()], vec![])),
182 Arc::new(PermissionHook::new(vec!["web_search".to_string()], vec![])),
183 ];
184 assert!(matches!(
186 run_pre_hooks(&hooks, "exec", "{}").await,
187 HookDecision::Block(_)
188 ));
189 assert!(matches!(
191 run_pre_hooks(&hooks, "web_search", "{}").await,
192 HookDecision::Block(_)
193 ));
194 assert!(matches!(
196 run_pre_hooks(&hooks, "file_ops", "{}").await,
197 HookDecision::Allow
198 ));
199 }
200}