1use crate::types::*;
5
6#[derive(Debug, Clone)]
8pub struct HookHandler {
9 pub name: String,
10 pub event: HookEvent,
11 pub priority: i32,
13 pub command: Option<String>,
15 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 pub fn register_defaults(&mut self) {
39 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 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 pub fn register(&mut self, handler: HookHandler) {
62 self.handlers.push(handler);
63 self.handlers.sort_by_key(|h| h.priority);
64 }
65
66 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 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 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 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 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}