Skip to main content

claude_agent/
hooks.rs

1// Layer 2: THE SKULL — Lifecycle Hooks
2// Priority-ordered, user-configurable hook handlers with granular events.
3
4use crate::types::*;
5
6/// A registered hook handler.
7#[derive(Debug, Clone)]
8pub struct HookHandler {
9    pub name: String,
10    pub event: HookEvent,
11    /// Lower priority runs first. Default: 100.
12    pub priority: i32,
13    /// Optional shell command to execute (future: subprocess dispatch).
14    pub command: Option<String>,
15    /// Optional tool name pattern for filtering (simple glob: "bash", "mcp__*").
16    pub matcher: Option<String>,
17    pub enabled: bool,
18}
19
20pub struct HookSystem {
21    handlers: Vec<HookHandler>,
22}
23
24impl Default for HookSystem {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl HookSystem {
31    pub fn new() -> Self {
32        Self {
33            handlers: Vec::new(),
34        }
35    }
36
37    /// Register built-in default hooks.
38    pub fn register_defaults(&mut self) {
39        // Pre-bash guard: block dangerous commands (priority 0 — always first)
40        self.register(HookHandler {
41            name: "pre-bash-guard".into(),
42            event: HookEvent::PreBash,
43            priority: 0,
44            command: None,
45            matcher: None,
46            enabled: true,
47        });
48
49        // Also fire on generic PreToolUse for backward compat
50        self.register(HookHandler {
51            name: "pre-tool-guard".into(),
52            event: HookEvent::PreToolUse,
53            priority: 0,
54            command: None,
55            matcher: Some("bash".into()),
56            enabled: true,
57        });
58    }
59
60    /// Register a hook handler, maintaining priority sort order.
61    pub fn register(&mut self, handler: HookHandler) {
62        self.handlers.push(handler);
63        self.handlers.sort_by_key(|h| h.priority);
64    }
65
66    /// Load user-defined hooks from a JSON config file.
67    ///
68    /// Format:
69    /// ```json
70    /// { "hooks": [
71    ///   { "name": "lint-on-write", "event": "PostFileWrite", "priority": 50,
72    ///     "command": "cargo clippy", "matcher": "*.rs" }
73    /// ] }
74    /// ```
75    pub async fn load_user_hooks(&mut self, config_path: &str) {
76        let path = std::path::Path::new(config_path);
77        if !path.exists() {
78            return;
79        }
80
81        let content = match tokio::fs::read_to_string(config_path).await {
82            Ok(c) => c,
83            Err(_) => return,
84        };
85
86        let config: serde_json::Value = match serde_json::from_str(&content) {
87            Ok(v) => v,
88            Err(_) => return,
89        };
90
91        let Some(hooks) = config.get("hooks").and_then(|h| h.as_array()) else {
92            return;
93        };
94
95        for hook in hooks {
96            let name = hook.get("name").and_then(|n| n.as_str()).unwrap_or("");
97            let event_str = hook.get("event").and_then(|e| e.as_str()).unwrap_or("");
98            if name.is_empty() || event_str.is_empty() {
99                continue;
100            }
101
102            let Some(event) = parse_hook_event(event_str) else {
103                continue;
104            };
105
106            self.register(HookHandler {
107                name: name.to_string(),
108                event,
109                priority: hook
110                    .get("priority")
111                    .and_then(|p| p.as_i64())
112                    .unwrap_or(100) as i32,
113                command: hook
114                    .get("command")
115                    .and_then(|c| c.as_str())
116                    .map(String::from),
117                matcher: hook
118                    .get("matcher")
119                    .and_then(|m| m.as_str())
120                    .map(String::from),
121                enabled: hook.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true),
122            });
123        }
124    }
125
126    /// Fire all handlers for an event, in priority order.
127    /// If any handler denies, execution stops and the deny result is returned.
128    pub fn fire(
129        &self,
130        event: HookEvent,
131        tool_name: Option<&str>,
132        input: Option<&serde_json::Value>,
133    ) -> HookResult {
134        for handler in &self.handlers {
135            if !handler.enabled || handler.event != event {
136                continue;
137            }
138
139            // Check matcher pattern if present
140            if let Some(ref matcher) = handler.matcher {
141                if let Some(name) = tool_name {
142                    if !simple_glob_match(matcher, name) {
143                        continue;
144                    }
145                }
146            }
147
148            let result = self.execute_handler(handler, tool_name, input);
149            if !result.allow {
150                return result;
151            }
152        }
153
154        HookResult {
155            allow: true,
156            message: None,
157        }
158    }
159
160    fn execute_handler(
161        &self,
162        handler: &HookHandler,
163        _tool_name: Option<&str>,
164        input: Option<&serde_json::Value>,
165    ) -> HookResult {
166        match handler.name.as_str() {
167            "pre-bash-guard" | "pre-tool-guard" => self.guard_dangerous_bash(input),
168            _ => {
169                // User-defined hooks pass through for now.
170                // Future: execute handler.command as subprocess.
171                HookResult {
172                    allow: true,
173                    message: None,
174                }
175            }
176        }
177    }
178
179    fn guard_dangerous_bash(&self, input: Option<&serde_json::Value>) -> HookResult {
180        let Some(input) = input else {
181            return HookResult {
182                allow: true,
183                message: None,
184            };
185        };
186
187        let command = input
188            .get("command")
189            .and_then(|v| v.as_str())
190            .unwrap_or("");
191
192        let dangerous = [
193            "rm -rf /",
194            "rm -rf ~",
195            "> /dev/sd",
196            "mkfs.",
197            "chmod 000",
198            "dd if=",
199            ":(){ :|:",
200        ];
201        for pattern in dangerous {
202            if command.contains(pattern) {
203                return HookResult {
204                    allow: false,
205                    message: Some(format!("Blocked dangerous command: {command}")),
206                };
207            }
208        }
209
210        // Block cloud metadata endpoint access
211        if command.contains("169.254.169.254") || command.contains("metadata.google") {
212            return HookResult {
213                allow: false,
214                message: Some("Cloud metadata endpoint access blocked".into()),
215            };
216        }
217
218        HookResult {
219            allow: true,
220            message: None,
221        }
222    }
223
224    pub fn count(&self) -> usize {
225        self.handlers.len()
226    }
227
228    pub fn list(&self) -> Vec<(&str, HookEvent, i32, bool)> {
229        self.handlers
230            .iter()
231            .map(|h| (h.name.as_str(), h.event, h.priority, h.enabled))
232            .collect()
233    }
234}
235
236fn parse_hook_event(s: &str) -> Option<HookEvent> {
237    match s {
238        "SessionStart" => Some(HookEvent::SessionStart),
239        "SessionStop" => Some(HookEvent::SessionStop),
240        "SessionResume" => Some(HookEvent::SessionResume),
241        "ContextClear" => Some(HookEvent::ContextClear),
242        "PreToolUse" => Some(HookEvent::PreToolUse),
243        "PostToolUse" => Some(HookEvent::PostToolUse),
244        "PostToolFailure" => Some(HookEvent::PostToolFailure),
245        "PreBash" => Some(HookEvent::PreBash),
246        "PostBash" => Some(HookEvent::PostBash),
247        "PreFileWrite" => Some(HookEvent::PreFileWrite),
248        "PostFileWrite" => Some(HookEvent::PostFileWrite),
249        "PreFileRead" => Some(HookEvent::PreFileRead),
250        "PreCompaction" => Some(HookEvent::PreCompaction),
251        "PostCompaction" => Some(HookEvent::PostCompaction),
252        "PreMcpCall" => Some(HookEvent::PreMcpCall),
253        "PostMcpCall" => Some(HookEvent::PostMcpCall),
254        "McpError" => Some(HookEvent::McpError),
255        "PreInference" => Some(HookEvent::PreInference),
256        "PostInference" => Some(HookEvent::PostInference),
257        _ => None,
258    }
259}
260
261fn simple_glob_match(pattern: &str, name: &str) -> bool {
262    if pattern == "*" {
263        return true;
264    }
265    if let Some(suffix) = pattern.strip_prefix('*') {
266        return name.ends_with(suffix);
267    }
268    if let Some(prefix) = pattern.strip_suffix('*') {
269        return name.starts_with(prefix);
270    }
271    pattern == name
272}