Skip to main content

microclaw_engine/
hooks.rs

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    /// Fires once when an interactive turn finalizes (after the last model
24    /// response, before delivery). Observational: Block outcomes are ignored.
25    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            // noop
191        } 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    /// Observational post-turn event: fired once when an interactive turn
408    /// finalizes. Block outcomes are ignored by the caller (the response is
409    /// already produced); hooks use this for logging/notification side
410    /// effects.
411    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
489/// Extract the hook's JSON reply from stdout, tolerating surrounding noise.
490///
491/// Hooks run under `sh -lc`, a LOGIN shell — user profiles (`~/.profile`,
492/// nvm init, greetings) may print to stdout before the hook's own output, and
493/// a strict whole-buffer parse would then reject every hook on that machine.
494/// Strategy: try the whole buffer first, then attempt a parse from each `{`
495/// (ignoring trailing data), taking the first candidate that decodes.
496fn 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            // Prefer a candidate that actually carries hook fields — noise
506            // like a literal `{}` in profile output decodes but says nothing.
507            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 discovered hooks
706    List,
707    /// Show hook detail by name
708    Info { name: String },
709    /// Enable a hook by name
710    Enable { name: String },
711    /// Disable a hook by name
712    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        // Clean JSON parses as before.
747        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        // Profile noise before the JSON (the `sh -lc` login-shell case).
752        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        // Trailing noise after the JSON.
758        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        // A meaningless `{}` in noise loses to a real reply later on.
763        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        // No JSON at all -> None.
768        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}