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        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
100/// Helper: run all registered hooks before a tool call.
101/// Returns `HookDecision::Block` on first blocking hook; otherwise `Allow`.
102pub 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
116/// Helper: run all registered hooks after a tool call.
117pub 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        // exec is blocked by first hook
185        assert!(matches!(
186            run_pre_hooks(&hooks, "exec", "{}").await,
187            HookDecision::Block(_)
188        ));
189        // web_search passes first hook, blocked by second
190        assert!(matches!(
191            run_pre_hooks(&hooks, "web_search", "{}").await,
192            HookDecision::Block(_)
193        ));
194        // file_ops passes both
195        assert!(matches!(
196            run_pre_hooks(&hooks, "file_ops", "{}").await,
197            HookDecision::Allow
198        ));
199    }
200}