Skip to main content

apollo/agent/
hooks.rs

1//! Tool hook system — PreToolUse / PostToolUse event interception.
2//!
3//! Hooks run synchronously in the tool execution path. A hook can allow,
4//! block, or (future) modify a tool call. Multiple hooks are checked in
5//! registration order; the first Block wins.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10
11use crate::tools::ToolResult;
12
13/// Decision returned by a hook's `before_tool_use` method.
14#[derive(Debug)]
15pub enum HookDecision {
16    Allow,
17    Block(String), // reason shown to the LLM as a tool error
18}
19
20/// Hook that runs before and after every tool execution.
21#[async_trait]
22pub trait ToolHook: Send + Sync {
23    /// Called before the tool executes. Return `Block` to skip execution.
24    async fn before_tool_use(&self, tool_name: &str, arguments: &str) -> HookDecision;
25
26    /// Called after the tool completes (whether success or error).
27    async fn after_tool_result(&self, _tool_name: &str, _arguments: &str, _result: &ToolResult) {}
28}
29
30/// Enforces `allow` / `deny` rules from `PermissionRulesConfig`.
31///
32/// Deny is checked before allow. Patterns support a single trailing `*` wildcard.
33pub 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        // Deny list checked first
56        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        // Allow list: if non-empty, tool must match at least one pattern
66        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
80/// Logging hook — emits a tracing event for every tool call and result.
81pub 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
101/// Helper: run all registered hooks before a tool call.
102/// Returns `HookDecision::Block` on first blocking hook; otherwise `Allow`.
103pub 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
117/// Helper: run all registered hooks after a tool call.
118pub 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        // exec is blocked by first hook
186        assert!(matches!(
187            run_pre_hooks(&hooks, "exec", "{}").await,
188            HookDecision::Block(_)
189        ));
190        // web_search passes first hook, blocked by second
191        assert!(matches!(
192            run_pre_hooks(&hooks, "web_search", "{}").await,
193            HookDecision::Block(_)
194        ));
195        // file_ops passes both
196        assert!(matches!(
197            run_pre_hooks(&hooks, "file_ops", "{}").await,
198            HookDecision::Allow
199        ));
200    }
201}