gestura_core_hooks/
engine.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct HookExecutionRecord {
17 pub name: String,
19 pub event: HookEvent,
21 pub program: String,
23 pub args: Vec<String>,
25 pub output: HookOutput,
27}
28
29pub struct HookEngine {
35 settings: HooksSettings,
36 executor: Arc<dyn HookExecutor>,
37}
38
39impl HookEngine {
40 pub fn new(settings: HooksSettings) -> Self {
42 Self {
43 settings,
44 executor: Arc::new(ProcessHookExecutor),
45 }
46 }
47
48 pub fn new_with_executor(settings: HooksSettings, executor: Arc<dyn HookExecutor>) -> Self {
50 Self { settings, executor }
51 }
52
53 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
109fn 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}