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        // Built-in hooks: in-process Rust checks invoked by an opaque
153        // `builtin:name <args>` command string. Keeps the wiring identical
154        // to shell hooks (same exit_code → veto contract) without paying
155        // shell-exec cost for the safety net we always want enabled.
156        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
196/// In-process implementation of `builtin:NAME` hooks.
197///
198/// The first word of `spec` is the builtin name; the rest is whatever
199/// context the caller passed (the engine passes "tool_name args_json").
200/// Returning `exit_code != 0` makes the surrounding blocking hook veto
201/// the action — matching the shell-hook contract exactly.
202fn 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        // Block tool calls whose payload mentions sensitive files. Keep the
209        // list intentionally short and well-known so false positives are
210        // rare: anything that ends up in this list is a "yes, you really
211        // do want to be asked first" path. Operators can disable the hook
212        // entirely with `sparrow permissions` if they need to edit one.
213        "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, // upgraded to veto by the blocking-hook branch
233                    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
256// ─── Default hooks ─────────────────────────────────────────────────────────────
257
258pub 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, // Disabled by default — enable to protect lockfiles
275        },
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        // Default-enabled safety net: block any write/edit/exec whose
285        // arguments mention well-known sensitive paths (.env, auth.enc,
286        // ssh keys, .pem/.pfx, credentials.json). Built-in, so it runs
287        // in-process — no shell, no platform-specific quoting.
288        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}