Skip to main content

sparrow/hooks/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use crate::event::Event;
6use crate::sandbox::Sandbox;
7
8// ─── Hook event types (12 lifecycle points) ────────────────────────────────────
9
10#[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    /// Fires immediately before a compaction pass so hooks can dump state.
25    PreCompact,
26    /// Fires once compaction has finished and the handoff doc is on disk.
27    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// ─── Hook definition ───────────────────────────────────────────────────────────
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Hook {
53    pub id: String,
54    pub event: HookEvent,
55    /// Regex pattern to match (e.g., tool name for PreToolUse)
56    pub matcher: Option<String>,
57    /// Shell command to execute (or builtin name)
58    pub command: String,
59    /// Whether this hook blocks execution until complete
60    pub blocking: bool,
61    /// Whether this hook is enabled
62    #[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// ─── Hook result ───────────────────────────────────────────────────────────────
86
87#[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
97// ─── Hook registry ─────────────────────────────────────────────────────────────
98
99pub 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    /// Execute all matching hooks for an event
121    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; // Blocking hook with veto stops execution
140                }
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
188// ─── Default hooks ─────────────────────────────────────────────────────────────
189
190pub 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, // Disabled by default — enable to protect lockfiles
207        },
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}