1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::time::Duration;
5
6use crate::internal::storage::db::{call_blocking, Database};
7use anyhow::{anyhow, Result};
8use clap::{Parser, Subcommand};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use tokio::io::AsyncWriteExt;
12use tokio::sync::RwLock;
13use tracing::warn;
14
15use crate::config::Config;
16use crate::tools::ToolResult;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub enum HookEvent {
20 BeforeLLMCall,
21 BeforeToolCall,
22 AfterToolCall,
23 AfterTurn,
26}
27
28impl HookEvent {
29 fn as_str(self) -> &'static str {
30 match self {
31 HookEvent::BeforeLLMCall => "BeforeLLMCall",
32 HookEvent::BeforeToolCall => "BeforeToolCall",
33 HookEvent::AfterToolCall => "AfterToolCall",
34 HookEvent::AfterTurn => "AfterTurn",
35 }
36 }
37
38 fn from_str(v: &str) -> Option<Self> {
39 match v.trim() {
40 "BeforeLLMCall" => Some(HookEvent::BeforeLLMCall),
41 "BeforeToolCall" => Some(HookEvent::BeforeToolCall),
42 "AfterToolCall" => Some(HookEvent::AfterToolCall),
43 "AfterTurn" => Some(HookEvent::AfterTurn),
44 _ => None,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Serialize)]
50pub struct HookInfo {
51 pub name: String,
52 pub description: String,
53 pub events: Vec<String>,
54 pub command: String,
55 pub timeout_ms: u64,
56 pub enabled: bool,
57 pub path: String,
58}
59
60#[derive(Debug, Clone, Deserialize)]
61struct HookFrontmatter {
62 name: Option<String>,
63 description: Option<String>,
64 #[serde(default)]
65 events: Vec<String>,
66 command: Option<String>,
67 enabled: Option<bool>,
68 timeout_ms: Option<u64>,
69 priority: Option<i32>,
70}
71
72#[derive(Debug, Clone)]
73struct HookDef {
74 name: String,
75 description: String,
76 events: Vec<HookEvent>,
77 command: String,
78 timeout_ms: u64,
79 enabled_by_default: bool,
80 priority: i32,
81 dir: PathBuf,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85struct HookResponse {
86 action: Option<String>,
87 reason: Option<String>,
88 patch: Option<serde_json::Value>,
89}
90
91#[derive(Debug, Clone)]
92pub enum HookOutcome {
93 Allow { patches: Vec<serde_json::Value> },
94 Block { reason: String },
95}
96
97#[derive(Clone)]
98pub struct HookManager {
99 hooks_dir_candidates: Vec<PathBuf>,
100 state_file: PathBuf,
101 hooks: Arc<RwLock<Vec<HookDef>>>,
102 state_overrides: Arc<RwLock<HashMap<String, bool>>>,
103 db: Option<Arc<Database>>,
104 enabled: bool,
105 max_input_bytes: usize,
106 max_output_bytes: usize,
107}
108
109impl HookManager {
110 pub fn from_config(config: &Config) -> Self {
111 let data_dir = PathBuf::from(&config.data_dir);
112 let root_dir = if data_dir.ends_with("runtime") {
113 data_dir
114 .parent()
115 .map(Path::to_path_buf)
116 .unwrap_or_else(|| data_dir.clone())
117 } else {
118 data_dir.clone()
119 };
120 let runtime_dir = if data_dir.ends_with("runtime") {
121 data_dir.clone()
122 } else {
123 data_dir.join("runtime")
124 };
125 let hooks_root = root_dir.join("hooks");
126 let local_hooks = PathBuf::from("hooks");
127 let state_file = runtime_dir.join("hooks_state.json");
128
129 let (enabled, max_input_bytes, max_output_bytes) = hooks_runtime_settings(config);
130 let manager = Self {
131 hooks_dir_candidates: vec![hooks_root, local_hooks],
132 state_file,
133 hooks: Arc::new(RwLock::new(Vec::new())),
134 state_overrides: Arc::new(RwLock::new(HashMap::new())),
135 db: None,
136 enabled,
137 max_input_bytes,
138 max_output_bytes,
139 };
140 manager.reload_sync();
141 manager
142 }
143
144 pub fn with_db(mut self, db: Arc<Database>) -> Self {
145 self.db = Some(db);
146 self
147 }
148
149 #[cfg(any(test, feature = "test-support"))]
150 pub fn for_tests() -> Self {
151 let tmp = std::env::temp_dir().join(format!("microclaw-hooks-{}", uuid::Uuid::new_v4()));
152 let _ = std::fs::create_dir_all(&tmp);
153 let manager = Self {
154 hooks_dir_candidates: vec![tmp.join("hooks")],
155 state_file: tmp.join("runtime").join("hooks_state.json"),
156 hooks: Arc::new(RwLock::new(Vec::new())),
157 state_overrides: Arc::new(RwLock::new(HashMap::new())),
158 db: None,
159 enabled: true,
160 max_input_bytes: 128 * 1024,
161 max_output_bytes: 64 * 1024,
162 };
163 manager.reload_sync();
164 manager
165 }
166
167 #[cfg(test)]
168 pub fn from_test_paths(hooks_dir: PathBuf, state_file: PathBuf) -> Self {
169 let manager = Self {
170 hooks_dir_candidates: vec![hooks_dir],
171 state_file,
172 hooks: Arc::new(RwLock::new(Vec::new())),
173 state_overrides: Arc::new(RwLock::new(HashMap::new())),
174 db: None,
175 enabled: true,
176 max_input_bytes: 128 * 1024,
177 max_output_bytes: 64 * 1024,
178 };
179 manager.reload_sync();
180 manager
181 }
182
183 pub fn reload_sync(&self) {
184 let hooks = discover_hooks(&self.hooks_dir_candidates);
185 let state = read_state_file(&self.state_file).unwrap_or_default();
186 if let Some(parent) = self.state_file.parent() {
187 let _ = std::fs::create_dir_all(parent);
188 }
189 if self.state_file.exists() {
190 } else {
192 let _ = write_state_file(&self.state_file, &state);
193 }
194 if let Ok(mut g) = self.hooks.try_write() {
195 *g = hooks;
196 }
197 if let Ok(mut g) = self.state_overrides.try_write() {
198 *g = state;
199 }
200 }
201
202 pub async fn list(&self) -> Vec<HookInfo> {
203 let hooks = self.hooks.read().await;
204 let state = self.state_overrides.read().await;
205 let mut out = hooks
206 .iter()
207 .map(|h| HookInfo {
208 name: h.name.clone(),
209 description: h.description.clone(),
210 events: h.events.iter().map(|e| e.as_str().to_string()).collect(),
211 command: h.command.clone(),
212 timeout_ms: h.timeout_ms,
213 enabled: state.get(&h.name).copied().unwrap_or(h.enabled_by_default),
214 path: h.dir.join("HOOK.md").to_string_lossy().to_string(),
215 })
216 .collect::<Vec<_>>();
217 out.sort_by(|a, b| a.name.cmp(&b.name));
218 out
219 }
220
221 pub async fn info(&self, name: &str) -> Option<HookInfo> {
222 self.list().await.into_iter().find(|h| h.name == name)
223 }
224
225 pub async fn set_enabled(&self, name: &str, enabled: bool) -> Result<()> {
226 let hooks = self.hooks.read().await;
227 if hooks.iter().all(|h| h.name != name) {
228 return Err(anyhow!("hook not found: {name}"));
229 }
230 drop(hooks);
231 {
232 let mut state = self.state_overrides.write().await;
233 state.insert(name.to_string(), enabled);
234 write_state_file(&self.state_file, &state)?;
235 }
236 Ok(())
237 }
238
239 pub async fn run(&self, event: HookEvent, payload: serde_json::Value) -> Result<HookOutcome> {
240 if !self.enabled {
241 return Ok(HookOutcome::Allow {
242 patches: Vec::new(),
243 });
244 }
245 let hooks = self.hooks.read().await.clone();
246 let states = self.state_overrides.read().await.clone();
247
248 let mut matched = hooks
249 .into_iter()
250 .filter(|h| h.events.contains(&event))
251 .collect::<Vec<_>>();
252 matched.sort_by(|a, b| {
253 a.priority
254 .cmp(&b.priority)
255 .then_with(|| a.name.cmp(&b.name))
256 });
257
258 let mut patches = Vec::new();
259 for hook in matched {
260 let enabled = states
261 .get(&hook.name)
262 .copied()
263 .unwrap_or(hook.enabled_by_default);
264 if !enabled {
265 continue;
266 }
267 let output = run_hook_command(
268 &hook,
269 event,
270 &payload,
271 self.max_input_bytes,
272 self.max_output_bytes,
273 )
274 .await;
275 let response = match output {
276 Ok(r) => r,
277 Err(e) => {
278 warn!("hook '{}' failed: {}", hook.name, e);
279 let detail = e.to_string();
280 self.log_hook_audit(&hook.name, event.as_str(), "error", Some(&detail))
281 .await;
282 continue;
283 }
284 };
285 let action = response
286 .action
287 .as_deref()
288 .unwrap_or("allow")
289 .trim()
290 .to_ascii_lowercase();
291 match action.as_str() {
292 "allow" => {}
293 "block" => {
294 let reason = response
295 .reason
296 .filter(|s| !s.trim().is_empty())
297 .unwrap_or_else(|| format!("blocked by hook {}", hook.name));
298 self.log_hook_audit(&hook.name, event.as_str(), "block", Some(&reason))
299 .await;
300 return Ok(HookOutcome::Block { reason });
301 }
302 "modify" => {
303 if let Some(p) = response.patch {
304 patches.push(p);
305 }
306 self.log_hook_audit(&hook.name, event.as_str(), "modify", None)
307 .await;
308 }
309 _ => {}
310 }
311 }
312 Ok(HookOutcome::Allow { patches })
313 }
314
315 async fn log_hook_audit(&self, actor: &str, action: &str, status: &str, detail: Option<&str>) {
316 let Some(db) = self.db.clone() else {
317 return;
318 };
319 let actor = actor.to_string();
320 let action = action.to_string();
321 let status = status.to_string();
322 let detail = detail.map(str::to_string);
323 let _ = call_blocking(db, move |d| {
324 d.log_audit_event("hook", &actor, &action, None, &status, detail.as_deref())
325 .map(|_| ())
326 })
327 .await;
328 }
329
330 pub async fn run_before_llm(
331 &self,
332 chat_id: i64,
333 caller_channel: &str,
334 iteration: usize,
335 system_prompt: &str,
336 messages_len: usize,
337 tools_len: usize,
338 ) -> Result<HookOutcome> {
339 self.run(
340 HookEvent::BeforeLLMCall,
341 json!({
342 "event": HookEvent::BeforeLLMCall.as_str(),
343 "chat_id": chat_id,
344 "caller_channel": caller_channel,
345 "iteration": iteration,
346 "system_prompt": system_prompt,
347 "messages_len": messages_len,
348 "tools_len": tools_len
349 }),
350 )
351 .await
352 }
353
354 pub async fn run_before_tool(
355 &self,
356 chat_id: i64,
357 caller_channel: &str,
358 iteration: usize,
359 tool_name: &str,
360 tool_input: &serde_json::Value,
361 ) -> Result<HookOutcome> {
362 self.run(
363 HookEvent::BeforeToolCall,
364 json!({
365 "event": HookEvent::BeforeToolCall.as_str(),
366 "chat_id": chat_id,
367 "caller_channel": caller_channel,
368 "iteration": iteration,
369 "tool_name": tool_name,
370 "tool_input": tool_input
371 }),
372 )
373 .await
374 }
375
376 pub async fn run_after_tool(
377 &self,
378 chat_id: i64,
379 caller_channel: &str,
380 iteration: usize,
381 tool_name: &str,
382 tool_input: &serde_json::Value,
383 result: &ToolResult,
384 ) -> Result<HookOutcome> {
385 self.run(
386 HookEvent::AfterToolCall,
387 json!({
388 "event": HookEvent::AfterToolCall.as_str(),
389 "chat_id": chat_id,
390 "caller_channel": caller_channel,
391 "iteration": iteration,
392 "tool_name": tool_name,
393 "tool_input": tool_input,
394 "result": {
395 "content": result.content,
396 "is_error": result.is_error,
397 "status_code": result.status_code,
398 "bytes": result.bytes,
399 "duration_ms": result.duration_ms,
400 "error_type": result.error_type
401 }
402 }),
403 )
404 .await
405 }
406
407 pub async fn run_after_turn(
412 &self,
413 chat_id: i64,
414 caller_channel: &str,
415 iterations: usize,
416 tools_used: &[String],
417 response_preview: &str,
418 ) -> Result<HookOutcome> {
419 self.run(
420 HookEvent::AfterTurn,
421 json!({
422 "event": HookEvent::AfterTurn.as_str(),
423 "chat_id": chat_id,
424 "caller_channel": caller_channel,
425 "iterations": iterations,
426 "tools_used": tools_used,
427 "response_preview": response_preview
428 }),
429 )
430 .await
431 }
432}
433
434async fn run_hook_command(
435 hook: &HookDef,
436 event: HookEvent,
437 payload: &serde_json::Value,
438 max_input_bytes: usize,
439 max_output_bytes: usize,
440) -> Result<HookResponse> {
441 let shell = if cfg!(windows) { "cmd" } else { "sh" };
442 let shell_arg = if cfg!(windows) { "/C" } else { "-lc" };
443
444 let mut command = tokio::process::Command::new(shell);
445 command
446 .arg(shell_arg)
447 .arg(&hook.command)
448 .current_dir(&hook.dir)
449 .stdin(std::process::Stdio::piped())
450 .stdout(std::process::Stdio::piped())
451 .stderr(std::process::Stdio::piped())
452 .env("MICROCLAW_HOOK_EVENT", event.as_str())
453 .env("MICROCLAW_HOOK_NAME", &hook.name);
454
455 let mut child = command.spawn()?;
456 if let Some(stdin) = child.stdin.as_mut() {
457 let body = serde_json::to_vec(payload)?;
458 if body.len() > max_input_bytes {
459 return Err(anyhow!("hook input exceeds max bytes"));
460 }
461 stdin.write_all(&body).await?;
462 }
463 let timeout = Duration::from_millis(hook.timeout_ms.clamp(10, 120_000));
464 let output = tokio::time::timeout(timeout, child.wait_with_output())
465 .await
466 .map_err(|_| anyhow!("hook timed out after {}ms", hook.timeout_ms))??;
467 if !output.status.success() {
468 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
469 return Err(anyhow!("hook exit {}: {}", output.status, stderr));
470 }
471 if output.stdout.len() > max_output_bytes {
472 return Err(anyhow!("hook output exceeds max bytes"));
473 }
474 if output.stdout.is_empty() {
475 return Ok(HookResponse {
476 action: Some("allow".to_string()),
477 reason: None,
478 patch: None,
479 });
480 }
481 parse_hook_stdout(&output.stdout).ok_or_else(|| {
482 anyhow!(
483 "hook stdout contained no parseable JSON reply: {}",
484 String::from_utf8_lossy(&output.stdout[..output.stdout.len().min(200)])
485 )
486 })
487}
488
489fn parse_hook_stdout(stdout: &[u8]) -> Option<HookResponse> {
497 if let Ok(r) = serde_json::from_slice::<HookResponse>(stdout) {
498 return Some(r);
499 }
500 let text = String::from_utf8_lossy(stdout);
501 let mut fallback: Option<HookResponse> = None;
502 for (i, _) in text.match_indices('{') {
503 let mut stream = serde_json::Deserializer::from_str(&text[i..]).into_iter::<HookResponse>();
504 if let Some(Ok(r)) = stream.next() {
505 if r.action.is_some() || r.reason.is_some() || r.patch.is_some() {
508 return Some(r);
509 }
510 fallback.get_or_insert(r);
511 }
512 }
513 fallback
514}
515
516fn hooks_runtime_settings(config: &Config) -> (bool, usize, usize) {
517 let mut enabled = true;
518 let mut max_input = 128 * 1024;
519 let mut max_output = 64 * 1024;
520 if let Some(v) = config.channels.get("hooks").and_then(|v| v.as_mapping()) {
521 let get_bool = |k: &str| {
522 v.get(serde_yaml::Value::String(k.to_string()))
523 .and_then(|x| x.as_bool())
524 };
525 let get_u64 = |k: &str| {
526 v.get(serde_yaml::Value::String(k.to_string()))
527 .and_then(|x| x.as_u64())
528 };
529 if let Some(e) = get_bool("enabled") {
530 enabled = e;
531 }
532 if let Some(n) = get_u64("max_input_bytes") {
533 max_input = n as usize;
534 }
535 if let Some(n) = get_u64("max_output_bytes") {
536 max_output = n as usize;
537 }
538 }
539 (
540 enabled,
541 max_input.clamp(1024, 4 * 1024 * 1024),
542 max_output.clamp(512, 2 * 1024 * 1024),
543 )
544}
545
546fn write_state_file(path: &Path, state: &HashMap<String, bool>) -> Result<()> {
547 if let Some(parent) = path.parent() {
548 std::fs::create_dir_all(parent)?;
549 }
550 let body = serde_json::to_string_pretty(state)?;
551 std::fs::write(path, body)?;
552 Ok(())
553}
554
555fn read_state_file(path: &Path) -> Result<HashMap<String, bool>> {
556 if !path.exists() {
557 return Ok(HashMap::new());
558 }
559 let raw = std::fs::read_to_string(path)?;
560 Ok(serde_json::from_str::<HashMap<String, bool>>(&raw).unwrap_or_default())
561}
562
563fn discover_hooks(candidates: &[PathBuf]) -> Vec<HookDef> {
564 let mut out = Vec::new();
565 for root in candidates {
566 let rd = match std::fs::read_dir(root) {
567 Ok(r) => r,
568 Err(_) => continue,
569 };
570 for entry in rd.flatten() {
571 let path = entry.path();
572 if !path.is_dir() {
573 continue;
574 }
575 let hook_md = path.join("HOOK.md");
576 if !hook_md.exists() {
577 continue;
578 }
579 match parse_hook_md(&hook_md, &path) {
580 Some(h) => out.push(h),
581 None => warn!("invalid hook metadata at {}", hook_md.display()),
582 }
583 }
584 }
585 out
586}
587
588fn parse_hook_md(hook_md_path: &Path, dir: &Path) -> Option<HookDef> {
589 let content = std::fs::read_to_string(hook_md_path).ok()?;
590 let trimmed = content.trim_start_matches('\u{feff}');
591 if !trimmed.starts_with("---") {
592 return None;
593 }
594 let mut lines = trimmed.lines();
595 let _ = lines.next()?;
596 let mut yaml = String::new();
597 for line in lines {
598 if line.trim() == "---" || line.trim() == "..." {
599 break;
600 }
601 yaml.push_str(line);
602 yaml.push('\n');
603 }
604 let fm: HookFrontmatter = serde_yaml::from_str(&yaml).ok()?;
605 let command = fm.command?.trim().to_string();
606 if command.is_empty() {
607 return None;
608 }
609 let events = fm
610 .events
611 .into_iter()
612 .filter_map(|e| HookEvent::from_str(&e))
613 .collect::<Vec<_>>();
614 if events.is_empty() {
615 return None;
616 }
617 let name = fm.name.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| {
618 dir.file_name()
619 .unwrap_or_default()
620 .to_string_lossy()
621 .to_string()
622 });
623 Some(HookDef {
624 name,
625 description: fm.description.unwrap_or_default(),
626 events,
627 command,
628 timeout_ms: fm.timeout_ms.unwrap_or(1500),
629 enabled_by_default: fm.enabled.unwrap_or(true),
630 priority: fm.priority.unwrap_or(100),
631 dir: dir.to_path_buf(),
632 })
633}
634
635pub async fn handle_hooks_cli(args: &[String]) -> Result<()> {
636 let cli = match HooksCli::try_parse_from(
637 std::iter::once("hooks").chain(args.iter().map(std::string::String::as_str)),
638 ) {
639 Ok(cli) => cli,
640 Err(err)
641 if matches!(
642 err.kind(),
643 clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
644 ) =>
645 {
646 err.print()?;
647 return Ok(());
648 }
649 Err(err) => return Err(anyhow!(err.to_string())),
650 };
651 let cmd = cli.command.unwrap_or(HooksCommand::List);
652 let config = Config::load()?;
653 let mgr = HookManager::from_config(&config);
654 match cmd {
655 HooksCommand::List => {
656 let hooks = mgr.list().await;
657 if hooks.is_empty() {
658 println!("No hooks discovered.");
659 return Ok(());
660 }
661 for h in hooks {
662 println!(
663 "{}\tenabled={}\tevents={}\tcommand={}",
664 h.name,
665 h.enabled,
666 h.events.join(","),
667 h.command
668 );
669 }
670 Ok(())
671 }
672 HooksCommand::Info { name } => {
673 let Some(info) = mgr.info(&name).await else {
674 return Err(anyhow!("hook not found: {name}"));
675 };
676 println!("{}", serde_json::to_string_pretty(&info)?);
677 Ok(())
678 }
679 HooksCommand::Enable { name } => {
680 mgr.set_enabled(&name, true).await?;
681 println!("enabled: {name}");
682 Ok(())
683 }
684 HooksCommand::Disable { name } => {
685 mgr.set_enabled(&name, false).await?;
686 println!("disabled: {name}");
687 Ok(())
688 }
689 }
690}
691
692#[derive(Debug, Parser)]
693#[command(
694 name = "microclaw hooks",
695 about = "Manage runtime hooks",
696 disable_help_subcommand = true
697)]
698struct HooksCli {
699 #[command(subcommand)]
700 command: Option<HooksCommand>,
701}
702
703#[derive(Debug, Subcommand)]
704enum HooksCommand {
705 List,
707 Info { name: String },
709 Enable { name: String },
711 Disable { name: String },
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use serde_json::json;
719
720 #[test]
721 fn test_parse_hook_md() {
722 let dir = std::env::temp_dir().join(format!("hook_parse_{}", uuid::Uuid::new_v4()));
723 std::fs::create_dir_all(&dir).unwrap();
724 let file = dir.join("HOOK.md");
725 std::fs::write(
726 &file,
727 r#"---
728name: test-hook
729description: test
730events: [BeforeLLMCall, BeforeToolCall]
731command: "echo '{\"action\":\"allow\"}'"
732enabled: true
733timeout_ms: 1000
734---
735body
736"#,
737 )
738 .unwrap();
739 let parsed = parse_hook_md(&file, &dir).unwrap();
740 assert_eq!(parsed.name, "test-hook");
741 assert_eq!(parsed.events.len(), 2);
742 }
743
744 #[test]
745 fn parse_hook_stdout_tolerates_login_shell_noise() {
746 let clean = br#"{"action":"block","reason":"nope"}"#;
748 let r = parse_hook_stdout(clean).unwrap();
749 assert_eq!(r.action.as_deref(), Some("block"));
750
751 let noisy = b"nvm\nWelcome to devbox!\n{\"action\":\"block\",\"reason\":\"nope\"}\n";
753 let r = parse_hook_stdout(noisy).unwrap();
754 assert_eq!(r.action.as_deref(), Some("block"));
755 assert_eq!(r.reason.as_deref(), Some("nope"));
756
757 let trailing = b"{\"action\":\"allow\"}\nbye\n";
759 let r = parse_hook_stdout(trailing).unwrap();
760 assert_eq!(r.action.as_deref(), Some("allow"));
761
762 let decoy = b"env {} ready\n{\"action\":\"block\"}\n";
764 let r = parse_hook_stdout(decoy).unwrap();
765 assert_eq!(r.action.as_deref(), Some("block"));
766
767 assert!(parse_hook_stdout(b"just some text").is_none());
769 }
770
771 #[tokio::test]
772 async fn test_hook_run_block_and_state_toggle() {
773 let root = std::env::temp_dir().join(format!("hook_run_{}", uuid::Uuid::new_v4()));
774 let hooks_dir = root.join("hooks");
775 let hook_dir = hooks_dir.join("block-bash");
776 std::fs::create_dir_all(&hook_dir).unwrap();
777 #[cfg(windows)]
778 let hook_command = "hook.cmd";
779 #[cfg(not(windows))]
780 let hook_command = "sh hook.sh";
781 std::fs::write(
782 hook_dir.join("HOOK.md"),
783 format!(
784 r#"---
785name: block-bash
786description: block bash
787events: [BeforeToolCall]
788command: "{}"
789enabled: true
790timeout_ms: 2000
791---
792"#,
793 hook_command
794 ),
795 )
796 .unwrap();
797 #[cfg(windows)]
798 std::fs::write(
799 hook_dir.join("hook.cmd"),
800 r#"@echo off
801setlocal enableextensions enabledelayedexpansion
802set payload=
803for /f "usebackq delims=" %%A in (`more`) do set payload=%%A
804echo !payload! | findstr /C:"\"tool_name\":\"bash\"" >nul
805if %errorlevel%==0 (
806 echo {"action":"block","reason":"bash blocked"}
807) else (
808 echo {"action":"allow"}
809)
810"#,
811 )
812 .unwrap();
813 #[cfg(not(windows))]
814 std::fs::write(
815 hook_dir.join("hook.sh"),
816 r#"#!/bin/sh
817payload="$(cat)"
818echo "$payload" | grep -q '"tool_name":"bash"'
819if [ $? -eq 0 ]; then
820 echo '{"action":"block","reason":"bash blocked"}'
821else
822 echo '{"action":"allow"}'
823fi
824"#,
825 )
826 .unwrap();
827
828 let manager =
829 HookManager::from_test_paths(hooks_dir, root.join("runtime/hooks_state.json"));
830 let first = manager
831 .run(
832 HookEvent::BeforeToolCall,
833 json!({"tool_name":"bash","tool_input":{"cmd":"ls"}}),
834 )
835 .await
836 .unwrap();
837 match first {
838 HookOutcome::Block { reason } => assert!(reason.contains("blocked")),
839 _ => panic!("expected block"),
840 }
841
842 manager.set_enabled("block-bash", false).await.unwrap();
843 let second = manager
844 .run(
845 HookEvent::BeforeToolCall,
846 json!({"tool_name":"bash","tool_input":{"cmd":"ls"}}),
847 )
848 .await
849 .unwrap();
850 match second {
851 HookOutcome::Allow { .. } => {}
852 _ => panic!("expected allow after disable"),
853 }
854 }
855}