gestura_core_hooks/
engine.rs

1//! Hook engine.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use tokio::time::timeout;
7
8use gestura_core_foundation::error::{AppError, Result};
9
10use super::executor::{HookExecutor, HookOutput, ProcessHookExecutor};
11use super::template::{TemplateVars, render_template};
12use super::types::{HookContext, HookEvent, HooksSettings};
13
14/// A record of a hook execution.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct HookExecutionRecord {
17    /// Hook name.
18    pub name: String,
19    /// Event emitted.
20    pub event: HookEvent,
21    /// Rendered program.
22    pub program: String,
23    /// Rendered args.
24    pub args: Vec<String>,
25    /// Execution output.
26    pub output: HookOutput,
27}
28
29/// Hook engine.
30///
31/// The engine is safe-by-default:
32/// - disabled unless `settings.enabled == true`
33/// - refuses to execute programs not present in `settings.allowed_programs`
34pub struct HookEngine {
35    settings: HooksSettings,
36    executor: Arc<dyn HookExecutor>,
37}
38
39impl HookEngine {
40    /// Create a hook engine using the default OS-process executor.
41    pub fn new(settings: HooksSettings) -> Self {
42        Self {
43            settings,
44            executor: Arc::new(ProcessHookExecutor),
45        }
46    }
47
48    /// Create a hook engine with a custom executor.
49    pub fn new_with_executor(settings: HooksSettings, executor: Arc<dyn HookExecutor>) -> Self {
50        Self { settings, executor }
51    }
52
53    /// Execute all hooks registered for `event`.
54    pub async fn run(
55        &self,
56        event: HookEvent,
57        ctx: &HookContext,
58    ) -> Result<Vec<HookExecutionRecord>> {
59        if !self.settings.enabled {
60            return Ok(Vec::new());
61        }
62
63        let vars = context_to_vars(ctx);
64        let mut records = Vec::new();
65
66        for hook in self.settings.hooks.iter().filter(|h| h.event == event) {
67            let program = render_template(&hook.command.program, &vars);
68            let args: Vec<String> = hook
69                .command
70                .args
71                .iter()
72                .map(|a| render_template(a, &vars))
73                .collect();
74
75            if !self.settings.allowed_programs.iter().any(|p| p == &program) {
76                return Err(AppError::PermissionDenied(format!(
77                    "Hook '{}' attempted to execute disallowed program '{program}'",
78                    hook.name
79                )));
80            }
81
82            let cwd = ctx.workspace_dir.as_deref();
83            let output = timeout(
84                std::time::Duration::from_millis(self.settings.timeout_ms),
85                self.executor
86                    .execute(&program, &args, cwd, self.settings.max_output_bytes),
87            )
88            .await
89            .map_err(|_| {
90                AppError::Timeout(format!(
91                    "Hook '{}' exceeded timeout ({}ms)",
92                    hook.name, self.settings.timeout_ms
93                ))
94            })??;
95
96            records.push(HookExecutionRecord {
97                name: hook.name.clone(),
98                event,
99                program,
100                args,
101                output,
102            });
103        }
104
105        Ok(records)
106    }
107}
108
109/// Convert [`HookContext`] into template variables.
110///
111/// Keys are intentionally flat and stable.
112fn context_to_vars(ctx: &HookContext) -> TemplateVars {
113    let mut vars: HashMap<String, String> = HashMap::new();
114    if let Some(id) = &ctx.session_id {
115        vars.insert("session_id".to_string(), id.clone());
116    }
117    if let Some(name) = &ctx.tool_name {
118        vars.insert("tool_name".to_string(), name.clone());
119    }
120    if let Some(args) = &ctx.tool_arguments_json {
121        vars.insert("tool_args".to_string(), args.clone());
122    }
123    if let Some(success) = ctx.tool_success {
124        vars.insert("tool_success".to_string(), success.to_string());
125    }
126    if let Some(out) = &ctx.tool_output {
127        vars.insert("tool_output".to_string(), out.clone());
128    }
129    if let Some(prompt) = &ctx.pipeline_prompt {
130        vars.insert("pipeline_prompt".to_string(), prompt.clone());
131    }
132    vars
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::types::{HookCommandTemplate, HookDefinition, HookEvent, HooksSettings};
139
140    #[tokio::test]
141    async fn engine_skips_when_disabled() {
142        let settings = HooksSettings::default();
143        let engine = HookEngine::new(settings);
144        let ctx = HookContext::default();
145        let out = engine.run(HookEvent::PrePipeline, &ctx).await.unwrap();
146        assert!(out.is_empty());
147    }
148
149    #[tokio::test]
150    async fn engine_denies_disallowed_program() {
151        let mut settings = HooksSettings {
152            enabled: true,
153            ..Default::default()
154        };
155        settings.hooks.push(HookDefinition {
156            name: "deny".to_string(),
157            event: HookEvent::PrePipeline,
158            command: HookCommandTemplate {
159                program: "sh".to_string(),
160                args: vec!["-c".to_string(), "echo hi".to_string()],
161            },
162        });
163
164        let engine = HookEngine::new(settings);
165        let ctx = HookContext::default();
166        let err = engine.run(HookEvent::PrePipeline, &ctx).await.unwrap_err();
167        assert!(matches!(err, AppError::PermissionDenied(_)));
168    }
169
170    #[cfg(unix)]
171    #[tokio::test]
172    async fn engine_executes_allowed_program_and_renders_templates() {
173        let mut settings = HooksSettings {
174            enabled: true,
175            allowed_programs: vec!["sh".to_string()],
176            ..Default::default()
177        };
178        settings.hooks.push(HookDefinition {
179            name: "echo-tool".to_string(),
180            event: HookEvent::PreTool,
181            command: HookCommandTemplate {
182                program: "sh".to_string(),
183                args: vec!["-c".to_string(), "printf %s {{tool_name}}".to_string()],
184            },
185        });
186
187        let engine = HookEngine::new(settings);
188        let ctx = HookContext {
189            tool_name: Some("git".to_string()),
190            ..Default::default()
191        };
192
193        let out = engine.run(HookEvent::PreTool, &ctx).await.unwrap();
194        assert_eq!(out.len(), 1);
195        assert_eq!(out[0].output.stdout, "git");
196    }
197}