Skip to main content

hematite/agent/
hooks.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::process::Command;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum HookEvent {
7    PreToolUse,
8    PostToolUse,
9}
10
11impl HookEvent {
12    fn as_str(self) -> &'static str {
13        match self {
14            Self::PreToolUse => "PreToolUse",
15            Self::PostToolUse => "PostToolUse",
16        }
17    }
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct HookRunResult {
22    pub denied: bool,
23    pub messages: Vec<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct RuntimeHookConfig {
28    #[serde(default)]
29    pub pre_tool_use: Vec<String>,
30    #[serde(default)]
31    pub post_tool_use: Vec<String>,
32}
33
34pub struct HookRunner {
35    config: RuntimeHookConfig,
36}
37
38impl HookRunner {
39    pub fn new(config: RuntimeHookConfig) -> Self {
40        Self { config }
41    }
42
43    pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &Value) -> HookRunResult {
44        self.run_commands(
45            HookEvent::PreToolUse,
46            &self.config.pre_tool_use,
47            tool_name,
48            tool_input,
49            None,
50            false,
51        )
52    }
53
54    pub fn run_post_tool_use(
55        &self,
56        tool_name: &str,
57        tool_input: &Value,
58        tool_output: &str,
59        is_error: bool,
60    ) -> HookRunResult {
61        self.run_commands(
62            HookEvent::PostToolUse,
63            &self.config.post_tool_use,
64            tool_name,
65            tool_input,
66            Some(tool_output),
67            is_error,
68        )
69    }
70
71    fn run_commands(
72        &self,
73        event: HookEvent,
74        commands: &[String],
75        tool_name: &str,
76        tool_input: &Value,
77        tool_output: Option<&str>,
78        is_error: bool,
79    ) -> HookRunResult {
80        let mut messages = Vec::new();
81        let mut denied = false;
82
83        for command_str in commands {
84            let mut cmd = if cfg!(windows) {
85                let mut c = Command::new("cmd");
86                c.arg("/C").arg(command_str);
87                c
88            } else {
89                let mut c = Command::new("sh");
90                c.arg("-c").arg(command_str);
91                c
92            };
93
94            cmd.env("HEMATITE_HOOK_EVENT", event.as_str());
95            cmd.env("HEMATITE_TOOL_NAME", tool_name);
96            cmd.env("HEMATITE_TOOL_INPUT", tool_input.to_string());
97            cmd.env("HEMATITE_TOOL_ERROR", if is_error { "1" } else { "0" });
98            if let Some(out) = tool_output {
99                cmd.env("HEMATITE_TOOL_OUTPUT", out);
100            }
101
102            match cmd.output() {
103                Ok(output) => {
104                    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
105                    if !stdout.is_empty() {
106                        messages.push(stdout);
107                    }
108
109                    // Exit code 2 means "DENY" — hook explicitly blocks the tool call
110                    if output.status.code() == Some(2) {
111                        denied = true;
112                        break;
113                    }
114                }
115                Err(e) => {
116                    messages.push(format!("Hook failed to start: {}", e));
117                }
118            }
119        }
120
121        HookRunResult { denied, messages }
122    }
123}