1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use crate::event::Event;
6use crate::sandbox::Sandbox;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub enum HookEvent {
12 SessionStart,
13 PreRun,
14 PreToolUse,
15 PostToolUse,
16 PreCheckpoint,
17 PostCheckpoint,
18 PostRun,
19 OnError,
20 OnApprovalRequested,
21 OnBudgetThreshold,
22 OnSkillLearned,
23 OnModelSwitched,
24 PreCompact,
26 PostCompact,
28}
29
30impl HookEvent {
31 pub fn from_event(event: &Event) -> Option<Self> {
32 match event {
33 Event::RunStarted { .. } => Some(HookEvent::PreRun),
34 Event::ToolUseProposed { .. } => Some(HookEvent::PreToolUse),
35 Event::ToolOutput { .. } => Some(HookEvent::PostToolUse),
36 Event::CheckpointCreated { .. } => Some(HookEvent::PreCheckpoint),
37 Event::RunFinished { .. } => Some(HookEvent::PostRun),
38 Event::Error { .. } => Some(HookEvent::OnError),
39 Event::ApprovalRequested { .. } => Some(HookEvent::OnApprovalRequested),
40 Event::CostUpdate { .. } => Some(HookEvent::OnBudgetThreshold),
41 Event::SkillLearned { .. } => Some(HookEvent::OnSkillLearned),
42 Event::ModelSwitched { .. } => Some(HookEvent::OnModelSwitched),
43 Event::Compacted { .. } => Some(HookEvent::PostCompact),
44 _ => None,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Hook {
53 pub id: String,
54 pub event: HookEvent,
55 pub matcher: Option<String>,
57 pub command: String,
59 pub blocking: bool,
61 #[serde(default = "default_true")]
63 pub enabled: bool,
64}
65
66fn default_true() -> bool {
67 true
68}
69
70impl Hook {
71 pub fn matches(&self, event: &HookEvent, context: &str) -> bool {
72 if self.event != *event {
73 return false;
74 }
75 if let Some(ref pattern) = self.matcher {
76 if let Ok(re) = regex::Regex::new(pattern) {
77 return re.is_match(context);
78 }
79 return context.contains(pattern.as_str());
80 }
81 true
82 }
83}
84
85#[derive(Debug, Clone)]
88pub struct HookResult {
89 pub hook_id: String,
90 pub exit_code: i32,
91 pub stdout: String,
92 pub stderr: String,
93 pub veto: bool,
94 pub veto_reason: Option<String>,
95}
96
97pub struct HookRegistry {
100 hooks: Vec<Hook>,
101 sandbox: Arc<dyn Sandbox>,
102}
103
104impl HookRegistry {
105 pub fn new(sandbox: Arc<dyn Sandbox>) -> Self {
106 Self {
107 hooks: Vec::new(),
108 sandbox,
109 }
110 }
111
112 pub fn load(&mut self, config_hooks: Vec<Hook>) {
113 self.hooks = config_hooks;
114 }
115
116 pub fn add(&mut self, hook: Hook) {
117 self.hooks.push(hook);
118 }
119
120 pub async fn execute(&self, event: &HookEvent, context: &str) -> Vec<HookResult> {
122 let mut results = Vec::new();
123
124 for hook in &self.hooks {
125 if !hook.enabled || !hook.matches(event, context) {
126 continue;
127 }
128
129 if hook.blocking {
130 let result = self.run_command(&hook.command, &hook.id).await;
131 if result.exit_code != 0 {
132 let mut r = result;
133 r.veto = true;
134 r.veto_reason = Some(format!(
135 "Hook '{}' blocked action (exit code {})",
136 hook.id, r.exit_code
137 ));
138 results.push(r);
139 break; }
141 results.push(result);
142 } else {
143 let result = self.run_command(&hook.command, &hook.id).await;
144 results.push(result);
145 }
146 }
147
148 results
149 }
150
151 async fn run_command(&self, command: &str, hook_id: &str) -> HookResult {
152 let cmd = crate::sandbox::Command {
153 program: if cfg!(windows) { "cmd" } else { "sh" }.into(),
154 args: vec![
155 if cfg!(windows) { "/c" } else { "-c" }.into(),
156 command.to_string(),
157 ],
158 env: HashMap::new(),
159 workdir: self.sandbox.root().to_path_buf(),
160 };
161
162 let limits = crate::sandbox::Limits {
163 timeout_ms: 30_000,
164 max_output_bytes: 64 * 1024,
165 };
166
167 match self.sandbox.exec(&cmd, &limits).await {
168 Ok(output) => HookResult {
169 hook_id: hook_id.into(),
170 exit_code: output.exit_code,
171 stdout: output.stdout,
172 stderr: output.stderr,
173 veto: false,
174 veto_reason: None,
175 },
176 Err(e) => HookResult {
177 hook_id: hook_id.into(),
178 exit_code: -1,
179 stdout: String::new(),
180 stderr: format!("Hook execution failed: {}", e),
181 veto: false,
182 veto_reason: None,
183 },
184 }
185 }
186}
187
188pub fn default_hooks() -> Vec<Hook> {
191 vec![
192 Hook {
193 id: "format-on-edit".into(),
194 event: HookEvent::PostToolUse,
195 matcher: Some("edit|fs_write".into()),
196 command: "echo 'hook: post-edit formatting' ".into(),
197 blocking: false,
198 enabled: true,
199 },
200 Hook {
201 id: "block-lock-files".into(),
202 event: HookEvent::PreToolUse,
203 matcher: Some("fs_write".into()),
204 command: "echo 'hook: lock file protected' && exit 1".into(),
205 blocking: true,
206 enabled: false, },
208 Hook {
209 id: "cost-threshold-notify".into(),
210 event: HookEvent::OnBudgetThreshold,
211 matcher: None,
212 command: "echo 'hook: cost threshold reached'".into(),
213 blocking: false,
214 enabled: true,
215 },
216 ]
217}