zeroclaw_runtime/hooks/builtin/
webhook_audit.rs1use async_trait::async_trait;
2use serde_json::Value;
3use std::collections::HashMap;
4use std::net::IpAddr;
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use crate::hooks::traits::{HookHandler, HookResult};
9use zeroclaw_api::tool::ToolResult;
10use zeroclaw_config::schema::WebhookAuditConfig;
11
12fn validate_webhook_url(url: &str) -> Result<(), String> {
20 let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid webhook URL: {e}"))?;
21
22 let scheme = parsed.scheme();
23 let host_str = parsed.host_str().unwrap_or("");
24
25 let is_localhost = host_str == "localhost" || host_str == "127.0.0.1" || host_str == "::1";
27
28 if scheme != "https" {
29 if scheme == "http" && is_localhost && cfg!(debug_assertions) {
30 } else {
32 return Err(format!(
33 "webhook URL must use https:// scheme (got {scheme}://)"
34 ));
35 }
36 }
37
38 if let Some(host) = parsed.host_str() {
40 let bare = host.trim_start_matches('[').trim_end_matches(']');
42 if let Ok(ip) = bare.parse::<IpAddr>() {
43 reject_private_ip(ip)?;
44 } else {
45 if bare == "localhost" && !(cfg!(debug_assertions) && scheme == "http") {
47 return Err("webhook URL must not target localhost".to_string());
48 }
49 }
50 }
51
52 Ok(())
53}
54
55fn reject_private_ip(addr: IpAddr) -> Result<(), String> {
56 match addr {
57 IpAddr::V4(ip) => {
58 if ip.is_loopback() {
59 return Err(format!(
60 "webhook URL must not target loopback address ({ip})"
61 ));
62 }
63 let octets = ip.octets();
64 if octets[0] == 10 {
66 return Err(format!(
67 "webhook URL must not target private address ({ip})"
68 ));
69 }
70 if octets[0] == 172 && (octets[1] & 0xf0) == 16 {
72 return Err(format!(
73 "webhook URL must not target private address ({ip})"
74 ));
75 }
76 if octets[0] == 192 && octets[1] == 168 {
78 return Err(format!(
79 "webhook URL must not target private address ({ip})"
80 ));
81 }
82 if octets[0] == 169 && octets[1] == 254 {
84 return Err(format!(
85 "webhook URL must not target link-local address ({ip})"
86 ));
87 }
88 }
89 IpAddr::V6(ip) => {
90 if ip.is_loopback() {
91 return Err(format!(
92 "webhook URL must not target loopback address ({ip})"
93 ));
94 }
95 let segments = ip.segments();
96 if (segments[0] & 0xffc0) == 0xfe80 {
98 return Err(format!(
99 "webhook URL must not target link-local address ({ip})"
100 ));
101 }
102 }
103 }
104 Ok(())
105}
106
107pub struct WebhookAuditHook {
109 config: WebhookAuditConfig,
110 client: reqwest::Client,
111 pending_args: Arc<Mutex<HashMap<String, Vec<Value>>>>,
112}
113
114impl WebhookAuditHook {
115 pub fn new(config: WebhookAuditConfig) -> Self {
116 if config.enabled && config.url.is_empty() {
118 ::zeroclaw_log::record!(
119 WARN,
120 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
121 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
122 .with_attrs(::serde_json::json!({"hook": "webhook-audit"})),
123 "webhook-audit hook is enabled but no URL is configured — audit events will be dropped"
124 );
125 }
126
127 if !config.url.is_empty()
129 && let Err(e) = validate_webhook_url(&config.url)
130 {
131 ::zeroclaw_log::record!(
132 ERROR,
133 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
134 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
135 .with_attrs(
136 ::serde_json::json!({"hook": "webhook-audit", "error": format!("{}", e)})
137 ),
138 "webhook URL validation failed"
139 );
140 panic!("webhook-audit: {e}");
141 }
142
143 let client = reqwest::Client::builder()
144 .timeout(Duration::from_secs(5))
145 .build()
146 .expect("failed to build webhook HTTP client");
147 Self {
148 config,
149 client,
150 pending_args: Arc::new(Mutex::new(HashMap::new())),
151 }
152 }
153}
154
155fn glob_matches(pattern: &str, text: &str) -> bool {
157 if pattern == "*" {
158 return true;
159 }
160 if !pattern.contains('*') {
161 return pattern == text;
162 }
163
164 let parts: Vec<&str> = pattern.split('*').collect();
165
166 let mut pos = 0usize;
168
169 if !pattern.starts_with('*') {
171 let first = parts[0];
172 if !text.starts_with(first) {
173 return false;
174 }
175 pos = first.len();
176 }
177
178 if !pattern.ends_with('*') {
180 let last = parts[parts.len() - 1];
181 if !text.ends_with(last) {
182 return false;
183 }
184 if text.len() < pos + last.len() {
186 return false;
189 }
190 }
191
192 let end_boundary = if pattern.ends_with('*') {
195 text.len()
196 } else {
197 text.len() - parts[parts.len() - 1].len()
198 };
199
200 let start_idx = if pattern.starts_with('*') { 0 } else { 1 };
201 let end_idx = if pattern.ends_with('*') {
202 parts.len()
203 } else {
204 parts.len() - 1
205 };
206
207 for part in &parts[start_idx..end_idx] {
208 if part.is_empty() {
209 continue;
210 }
211 if let Some(found) = text[pos..end_boundary].find(part) {
212 pos += found + part.len();
213 } else {
214 return false;
215 }
216 }
217
218 true
219}
220
221fn matches_any_pattern(patterns: &[String], tool: &str) -> bool {
223 patterns.iter().any(|p| glob_matches(p, tool))
224}
225
226#[allow(clippy::cast_possible_truncation)]
231fn truncate_args(args: Value, max_bytes: u64) -> Value {
232 if max_bytes == 0 {
233 return args;
234 }
235 let serialised = match serde_json::to_string(&args) {
236 Ok(s) => s,
237 Err(_) => return args,
238 };
239 if serialised.len() <= max_bytes as usize {
240 args
241 } else {
242 let mut end = max_bytes as usize;
243 while end > 0 && !serialised.is_char_boundary(end) {
244 end -= 1;
245 }
246 Value::String(format!("{}...[truncated]", &serialised[..end]))
247 }
248}
249
250#[async_trait]
251impl HookHandler for WebhookAuditHook {
252 fn name(&self) -> &str {
253 "webhook-audit"
254 }
255
256 fn priority(&self) -> i32 {
257 -100
258 }
259
260 async fn before_tool_call(&self, name: String, args: Value) -> HookResult<(String, Value)> {
261 if self.config.include_args && matches_any_pattern(&self.config.tool_patterns, &name) {
262 ::zeroclaw_log::record!(
263 DEBUG,
264 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
265 .with_attrs(::serde_json::json!({"hook": "webhook-audit", "tool": name})),
266 "capturing args for audit"
267 );
268 self.pending_args
269 .lock()
270 .unwrap_or_else(|e| e.into_inner())
271 .entry(name.clone())
272 .or_default()
273 .push(args.clone());
274 }
275 HookResult::Continue((name, args))
276 }
277
278 async fn on_after_tool_call(&self, tool: &str, result: &ToolResult, duration: Duration) {
279 if self.config.url.is_empty() {
281 return;
282 }
283
284 if !matches_any_pattern(&self.config.tool_patterns, tool) {
286 return;
287 }
288
289 let args_value: Value = if self.config.include_args {
291 let raw = {
292 let mut map = self.pending_args.lock().unwrap_or_else(|e| e.into_inner());
293 let entry = map.get_mut(tool).and_then(|v| {
294 if v.is_empty() {
295 None
296 } else {
297 Some(v.remove(0))
298 }
299 });
300 if map.get(tool).is_some_and(|v| v.is_empty()) {
302 map.remove(tool);
303 }
304 entry
305 };
306 match raw {
307 Some(a) => truncate_args(a, self.config.max_args_bytes),
308 None => Value::Null,
309 }
310 } else {
311 Value::Null
312 };
313
314 #[allow(clippy::cast_possible_truncation)]
315 let duration_ms = duration.as_millis() as u64;
316
317 let payload = serde_json::json!({
318 "event": "tool_call",
319 "timestamp": chrono::Utc::now().to_rfc3339(),
320 "tool": tool,
321 "success": result.success,
322 "duration_ms": duration_ms,
323 "error": result.error,
324 "args": args_value,
325 });
326
327 let client = self.client.clone();
328 let url = self.config.url.clone();
329
330 tokio::spawn(async move {
332 match client.post(&url).json(&payload).send().await {
333 Ok(resp) => {
334 if !resp.status().is_success() {
335 ::zeroclaw_log::record!(ERROR, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail).with_outcome(::zeroclaw_log::EventOutcome::Failure).with_attrs(::serde_json::json!({"hook": "webhook-audit", "url": url, "status": resp.status().to_string()})), "webhook endpoint returned non-success status");
336 }
337 }
338 Err(e) => {
339 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"hook": "webhook-audit", "url": url, "error": format!("{}", e)})), "failed to POST audit payload");
340 }
341 }
342 });
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
353 fn glob_exact_match() {
354 assert!(glob_matches("file_write", "file_write"));
355 assert!(!glob_matches("file_write", "file_read"));
356 }
357
358 #[test]
359 fn glob_wildcard_suffix() {
360 assert!(glob_matches("mcp__*", "mcp__github"));
361 assert!(glob_matches("mcp__*", "mcp__"));
362 assert!(!glob_matches("mcp__*", "mcp_github"));
363 }
364
365 #[test]
366 fn glob_wildcard_prefix() {
367 assert!(glob_matches("*_write", "file_write"));
368 assert!(glob_matches("*_write", "_write"));
369 assert!(!glob_matches("*_write", "file_read"));
370 }
371
372 #[test]
373 fn glob_wildcard_middle() {
374 assert!(glob_matches("mcp__*__create", "mcp__github__create"));
375 assert!(glob_matches("mcp__*__create", "mcp____create"));
376 assert!(!glob_matches("mcp__*__create", "mcp__github__delete"));
377 }
378
379 #[test]
380 fn glob_star_matches_everything() {
381 assert!(glob_matches("*", "anything_at_all"));
382 assert!(glob_matches("*", ""));
383 }
384
385 #[test]
386 fn glob_empty_pattern() {
387 assert!(glob_matches("", ""));
388 assert!(!glob_matches("", "something"));
389 }
390
391 #[test]
394 fn matches_any_pattern_works() {
395 let patterns = vec!["Bash".to_string(), "mcp__*".to_string()];
396 assert!(matches_any_pattern(&patterns, "Bash"));
397 assert!(matches_any_pattern(&patterns, "mcp__github"));
398 assert!(!matches_any_pattern(&patterns, "Write"));
399 }
400
401 #[test]
402 fn empty_patterns_matches_nothing() {
403 let patterns: Vec<String> = vec![];
404 assert!(!matches_any_pattern(&patterns, "anything"));
405 }
406
407 fn make_hook(patterns: Vec<&str>, include_args: bool) -> WebhookAuditHook {
410 WebhookAuditHook::new(WebhookAuditConfig {
413 enabled: true,
414 url: "https://audit.example.com/webhook".to_string(),
415 tool_patterns: patterns.into_iter().map(String::from).collect(),
416 include_args,
417 max_args_bytes: 4096,
418 })
419 }
420
421 #[tokio::test]
422 async fn before_tool_call_captures_args_when_enabled() {
423 let hook = make_hook(vec!["Bash", "mcp__*"], true);
424 let args = serde_json::json!({"command": "ls"});
425 let result = hook.before_tool_call("Bash".into(), args.clone()).await;
426 assert!(!result.is_cancel());
427
428 let pending = hook.pending_args.lock().unwrap();
429 assert_eq!(pending.get("Bash"), Some(&vec![args]));
430 }
431
432 #[tokio::test]
433 async fn before_tool_call_concurrent_same_tool_no_data_loss() {
434 let hook = make_hook(vec!["Bash"], true);
435 let args1 = serde_json::json!({"command": "ls"});
436 let args2 = serde_json::json!({"command": "pwd"});
437 hook.before_tool_call("Bash".into(), args1.clone()).await;
438 hook.before_tool_call("Bash".into(), args2.clone()).await;
439
440 let pending = hook.pending_args.lock().unwrap();
441 let bash_args = pending.get("Bash").unwrap();
442 assert_eq!(bash_args.len(), 2);
443 assert_eq!(bash_args[0], args1);
444 assert_eq!(bash_args[1], args2);
445 }
446
447 #[tokio::test]
448 async fn before_tool_call_skips_non_matching_tools() {
449 let hook = make_hook(vec!["Bash"], true);
450 let args = serde_json::json!({"path": "/tmp"});
451 let result = hook.before_tool_call("Write".into(), args).await;
452 assert!(!result.is_cancel());
453
454 let pending = hook.pending_args.lock().unwrap();
455 assert!(pending.is_empty());
456 }
457
458 #[tokio::test]
459 async fn before_tool_call_skips_when_include_args_false() {
460 let hook = make_hook(vec!["Bash"], false);
461 let args = serde_json::json!({"command": "ls"});
462 let result = hook.before_tool_call("Bash".into(), args).await;
463 assert!(!result.is_cancel());
464
465 let pending = hook.pending_args.lock().unwrap();
466 assert!(pending.is_empty());
467 }
468
469 #[test]
472 fn truncate_args_within_limit() {
473 let args = serde_json::json!({"key": "val"});
474 let result = truncate_args(args.clone(), 1000);
475 assert_eq!(result, args);
476 }
477
478 #[test]
479 fn truncate_args_over_limit() {
480 let args = serde_json::json!({"key": "a]long value that exceeds limit"});
481 let result = truncate_args(args, 10);
482 assert!(result.is_string());
483 let s = result.as_str().unwrap();
484 assert!(s.ends_with("...[truncated]"));
485 }
486
487 #[test]
488 fn truncate_args_zero_means_no_limit() {
489 let args = serde_json::json!({"key": "value"});
490 let result = truncate_args(args.clone(), 0);
491 assert_eq!(result, args);
492 }
493
494 #[tokio::test]
497 async fn on_after_tool_call_skips_non_matching() {
498 let hook = make_hook(vec!["Bash"], true);
499 let result = ToolResult {
500 success: true,
501 output: "ok".into(),
502 error: None,
503 };
504 hook.on_after_tool_call("Write", &result, Duration::from_millis(10))
506 .await;
507 let pending = hook.pending_args.lock().unwrap();
509 assert!(pending.is_empty());
510 }
511
512 #[tokio::test]
513 async fn on_after_tool_call_skips_empty_url() {
514 let hook = WebhookAuditHook::new(WebhookAuditConfig {
516 enabled: true,
517 url: String::new(),
518 tool_patterns: vec!["Bash".to_string()],
519 include_args: false,
520 max_args_bytes: 4096,
521 });
522 let result = ToolResult {
523 success: true,
524 output: "ok".into(),
525 error: None,
526 };
527 hook.on_after_tool_call("Bash", &result, Duration::from_millis(5))
529 .await;
530 }
531
532 #[test]
535 fn validate_url_rejects_loopback_ipv4() {
536 assert!(validate_webhook_url("https://127.0.0.1/hook").is_err());
537 assert!(validate_webhook_url("https://127.0.0.100/hook").is_err());
538 }
539
540 #[test]
541 fn validate_url_rejects_loopback_ipv6() {
542 assert!(validate_webhook_url("https://[::1]/hook").is_err());
543 }
544
545 #[test]
546 fn validate_url_rejects_private_rfc1918() {
547 assert!(validate_webhook_url("https://10.0.0.1/hook").is_err());
548 assert!(validate_webhook_url("https://172.16.5.1/hook").is_err());
549 assert!(validate_webhook_url("https://192.168.1.1/hook").is_err());
550 }
551
552 #[test]
553 fn validate_url_rejects_link_local() {
554 assert!(validate_webhook_url("https://169.254.1.1/hook").is_err());
555 assert!(validate_webhook_url("https://[fe80::1]/hook").is_err());
556 }
557
558 #[test]
559 fn validate_url_rejects_http_non_localhost() {
560 assert!(validate_webhook_url("http://example.com/hook").is_err());
561 }
562
563 #[test]
564 fn validate_url_accepts_https_public() {
565 assert!(validate_webhook_url("https://audit.example.com/webhook").is_ok());
566 assert!(validate_webhook_url("https://8.8.8.8/hook").is_ok());
567 }
568
569 #[test]
570 fn validate_url_rejects_non_http_scheme() {
571 assert!(validate_webhook_url("ftp://example.com/hook").is_err());
572 }
573}