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 let preview: String = arguments.chars().take(120).collect();
87 tracing::debug!("→ tool:{} args:{}", tool_name, preview);
88 HookDecision::Allow
89 }
90
91 async fn after_tool_result(&self, tool_name: &str, _arguments: &str, result: &ToolResult) {
92 tracing::debug!(
93 "← tool:{} is_error:{} len:{}",
94 tool_name,
95 result.is_error,
96 result.output.len()
97 );
98 }
99}
100
101pub async fn run_pre_hooks(
104 hooks: &[Arc<dyn ToolHook>],
105 tool_name: &str,
106 arguments: &str,
107) -> HookDecision {
108 for hook in hooks {
109 match hook.before_tool_use(tool_name, arguments).await {
110 HookDecision::Block(reason) => return HookDecision::Block(reason),
111 HookDecision::Allow => {}
112 }
113 }
114 HookDecision::Allow
115}
116
117pub async fn run_post_hooks(
119 hooks: &[Arc<dyn ToolHook>],
120 tool_name: &str,
121 arguments: &str,
122 result: &ToolResult,
123) {
124 for hook in hooks {
125 hook.after_tool_result(tool_name, arguments, result).await;
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[tokio::test]
134 async fn permission_hook_deny() {
135 let hook = PermissionHook::new(vec!["exec".to_string()], vec![]);
136 assert!(matches!(
137 hook.before_tool_use("exec", "{}").await,
138 HookDecision::Block(_)
139 ));
140 assert!(matches!(
141 hook.before_tool_use("web_search", "{}").await,
142 HookDecision::Allow
143 ));
144 }
145
146 #[tokio::test]
147 async fn permission_hook_allow_list() {
148 let hook = PermissionHook::new(
149 vec![],
150 vec!["web_search".to_string(), "file_ops".to_string()],
151 );
152 assert!(matches!(
153 hook.before_tool_use("web_search", "{}").await,
154 HookDecision::Allow
155 ));
156 assert!(matches!(
157 hook.before_tool_use("exec", "{}").await,
158 HookDecision::Block(_)
159 ));
160 }
161
162 #[tokio::test]
163 async fn permission_hook_wildcard() {
164 let hook = PermissionHook::new(vec!["web_*".to_string()], vec![]);
165 assert!(matches!(
166 hook.before_tool_use("web_search", "{}").await,
167 HookDecision::Block(_)
168 ));
169 assert!(matches!(
170 hook.before_tool_use("web_fetch", "{}").await,
171 HookDecision::Block(_)
172 ));
173 assert!(matches!(
174 hook.before_tool_use("exec", "{}").await,
175 HookDecision::Allow
176 ));
177 }
178
179 #[tokio::test]
180 async fn run_pre_hooks_first_block_wins() {
181 let hooks: Vec<Arc<dyn ToolHook>> = vec![
182 Arc::new(PermissionHook::new(vec!["exec".to_string()], vec![])),
183 Arc::new(PermissionHook::new(vec!["web_search".to_string()], vec![])),
184 ];
185 assert!(matches!(
187 run_pre_hooks(&hooks, "exec", "{}").await,
188 HookDecision::Block(_)
189 ));
190 assert!(matches!(
192 run_pre_hooks(&hooks, "web_search", "{}").await,
193 HookDecision::Block(_)
194 ));
195 assert!(matches!(
197 run_pre_hooks(&hooks, "file_ops", "{}").await,
198 HookDecision::Allow
199 ));
200 }
201}