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 if let Some(rest) = command.strip_prefix("builtin:") {
157 return run_builtin(rest, hook_id);
158 }
159
160 let cmd = crate::sandbox::Command {
161 program: if cfg!(windows) { "cmd" } else { "sh" }.into(),
162 args: vec![
163 if cfg!(windows) { "/c" } else { "-c" }.into(),
164 command.to_string(),
165 ],
166 env: HashMap::new(),
167 workdir: self.sandbox.root().to_path_buf(),
168 };
169
170 let limits = crate::sandbox::Limits {
171 timeout_ms: 30_000,
172 max_output_bytes: 64 * 1024,
173 };
174
175 match self.sandbox.exec(&cmd, &limits).await {
176 Ok(output) => HookResult {
177 hook_id: hook_id.into(),
178 exit_code: output.exit_code,
179 stdout: output.stdout,
180 stderr: output.stderr,
181 veto: false,
182 veto_reason: None,
183 },
184 Err(e) => HookResult {
185 hook_id: hook_id.into(),
186 exit_code: -1,
187 stdout: String::new(),
188 stderr: format!("Hook execution failed: {}", e),
189 veto: false,
190 veto_reason: None,
191 },
192 }
193 }
194}
195
196fn run_builtin(spec: &str, hook_id: &str) -> HookResult {
203 let (name, ctx) = match spec.split_once(' ') {
204 Some((n, c)) => (n, c),
205 None => (spec, ""),
206 };
207 match name {
208 "protect-sensitive-files" => {
214 const NEEDLES: &[&str] = &[
215 ".env",
216 "auth.enc",
217 "id_rsa",
218 "id_ed25519",
219 ".pem",
220 ".pfx",
221 ".p12",
222 "credentials.json",
223 "service-account",
224 ];
225 let lower = ctx.to_ascii_lowercase();
226 if let Some(hit) = NEEDLES.iter().find(|n| lower.contains(*n)) {
227 return HookResult {
228 hook_id: hook_id.into(),
229 exit_code: 1,
230 stdout: String::new(),
231 stderr: format!("touches sensitive path matcher: `{}`", hit),
232 veto: false, veto_reason: None,
234 };
235 }
236 HookResult {
237 hook_id: hook_id.into(),
238 exit_code: 0,
239 stdout: String::new(),
240 stderr: String::new(),
241 veto: false,
242 veto_reason: None,
243 }
244 }
245 _ => HookResult {
246 hook_id: hook_id.into(),
247 exit_code: 0,
248 stdout: format!("unknown builtin `{}` — ignored", name),
249 stderr: String::new(),
250 veto: false,
251 veto_reason: None,
252 },
253 }
254}
255
256pub fn default_hooks() -> Vec<Hook> {
259 vec![
260 Hook {
261 id: "format-on-edit".into(),
262 event: HookEvent::PostToolUse,
263 matcher: Some("edit|fs_write".into()),
264 command: "echo 'hook: post-edit formatting' ".into(),
265 blocking: false,
266 enabled: true,
267 },
268 Hook {
269 id: "block-lock-files".into(),
270 event: HookEvent::PreToolUse,
271 matcher: Some("fs_write".into()),
272 command: "echo 'hook: lock file protected' && exit 1".into(),
273 blocking: true,
274 enabled: false, },
276 Hook {
277 id: "cost-threshold-notify".into(),
278 event: HookEvent::OnBudgetThreshold,
279 matcher: None,
280 command: "echo 'hook: cost threshold reached'".into(),
281 blocking: false,
282 enabled: true,
283 },
284 Hook {
289 id: "protect-sensitive-files".into(),
290 event: HookEvent::PreToolUse,
291 matcher: Some("fs_write|edit|multi_edit|exec".into()),
292 command: "builtin:protect-sensitive-files".into(),
293 blocking: true,
294 enabled: true,
295 },
296 ]
297}