Skip to main content

opendev_hooks/
manager.rs

1//! Hook manager — orchestrates hook execution for lifecycle events.
2//!
3//! Takes a snapshot of [`HookConfig`] at construction time. Mid-session changes
4//! to settings.json are not reflected (security: prevents config TOCTOU).
5
6use crate::executor::{HookExecutor, HookResult};
7use crate::models::{HookConfig, HookEvent};
8use serde_json::{Map, Value};
9use tracing::warn;
10
11/// Aggregated outcome from running all hooks for an event.
12#[derive(Debug, Clone, Default)]
13pub struct HookOutcome {
14    /// Whether any hook requested blocking the operation.
15    pub blocked: bool,
16    /// Human-readable reason for the block.
17    pub block_reason: String,
18    /// Individual results from each hook command.
19    pub results: Vec<HookResult>,
20    /// Additional context injected by a hook (appended to tool output).
21    pub additional_context: Option<String>,
22    /// Updated input provided by a hook (replaces tool input).
23    pub updated_input: Option<Value>,
24    /// Permission decision from a hook (e.g., "allow", "deny").
25    pub permission_decision: Option<String>,
26    /// General decision string from a hook.
27    pub decision: Option<String>,
28}
29
30impl HookOutcome {
31    /// Whether all hooks passed without blocking.
32    pub fn allowed(&self) -> bool {
33        !self.blocked
34    }
35}
36
37/// Orchestrates hook execution for lifecycle events.
38///
39/// The manager holds a frozen snapshot of the hook configuration, an executor
40/// for running subprocess commands, and session metadata used to build stdin
41/// payloads for hook commands.
42pub struct HookManager {
43    config: HookConfig,
44    session_id: String,
45    cwd: String,
46    executor: HookExecutor,
47}
48
49impl HookManager {
50    /// Create a new hook manager.
51    ///
52    /// The `config` should already have `compile_all()` and
53    /// `strip_unknown_events()` called on it.
54    pub fn new(config: HookConfig, session_id: impl Into<String>, cwd: impl Into<String>) -> Self {
55        Self {
56            config,
57            session_id: session_id.into(),
58            cwd: cwd.into(),
59            executor: HookExecutor::new(),
60        }
61    }
62
63    /// Create a manager with no hooks configured (no-op for all events).
64    pub fn noop() -> Self {
65        Self::new(HookConfig::empty(), "", "")
66    }
67
68    /// Fast check: are there hooks registered for this event?
69    pub fn has_hooks_for(&self, event: HookEvent) -> bool {
70        self.config.has_hooks_for(event)
71    }
72
73    /// Run all matching hooks for an event.
74    ///
75    /// Hooks execute sequentially. Short-circuits on block (exit code 2).
76    ///
77    /// # Arguments
78    /// - `event`: The lifecycle event.
79    /// - `match_value`: Value to test against matcher regex (e.g., tool name).
80    /// - `event_data`: Additional event-specific data for the stdin payload.
81    pub async fn run_hooks(
82        &self,
83        event: HookEvent,
84        match_value: Option<&str>,
85        event_data: Option<&Value>,
86    ) -> HookOutcome {
87        let mut outcome = HookOutcome::default();
88
89        let matchers = self.config.get_matchers(event);
90        if matchers.is_empty() {
91            return outcome;
92        }
93
94        for matcher in matchers {
95            if !matcher.matches(match_value) {
96                continue;
97            }
98
99            let stdin_data = self.build_stdin(event, match_value, event_data);
100
101            for command in &matcher.hooks {
102                let result = self.executor.execute(command, &stdin_data).await;
103
104                if result.should_block() {
105                    let parsed = result.parse_json_output();
106                    outcome.block_reason = parsed
107                        .get("reason")
108                        .and_then(|v| v.as_str())
109                        .map(|s| s.to_string())
110                        .unwrap_or_else(|| {
111                            let stderr = result.stderr.trim();
112                            if stderr.is_empty() {
113                                "Blocked by hook".to_string()
114                            } else {
115                                stderr.to_string()
116                            }
117                        });
118                    outcome.decision = parsed
119                        .get("decision")
120                        .and_then(|v| v.as_str())
121                        .map(|s| s.to_string());
122                    outcome.blocked = true;
123                    outcome.results.push(result);
124                    return outcome;
125                }
126
127                if result.success() {
128                    let parsed = result.parse_json_output();
129                    if let Some(ctx) = parsed.get("additionalContext").and_then(|v| v.as_str()) {
130                        outcome.additional_context = Some(ctx.to_string());
131                    }
132                    if let Some(input) = parsed.get("updatedInput") {
133                        outcome.updated_input = Some(input.clone());
134                    }
135                    if let Some(perm) = parsed.get("permissionDecision").and_then(|v| v.as_str()) {
136                        outcome.permission_decision = Some(perm.to_string());
137                    }
138                    if let Some(dec) = parsed.get("decision").and_then(|v| v.as_str()) {
139                        outcome.decision = Some(dec.to_string());
140                    }
141                } else if let Some(ref err) = result.error {
142                    warn!(
143                        event = %event,
144                        error = %err,
145                        "Hook command error"
146                    );
147                }
148
149                outcome.results.push(result);
150            }
151        }
152
153        outcome
154    }
155
156    /// Fire-and-forget hook execution.
157    ///
158    /// Spawns hook execution as a background tokio task. Used for events
159    /// where we don't need to wait for the result (e.g., PostToolUse logging).
160    pub fn run_hooks_async(
161        &self,
162        event: HookEvent,
163        match_value: Option<String>,
164        event_data: Option<Value>,
165    ) where
166        Self: Send + Sync + 'static,
167    {
168        if !self.has_hooks_for(event) {
169            return;
170        }
171
172        // Clone what we need for the spawned task
173        let config = self.config.clone();
174        let session_id = self.session_id.clone();
175        let cwd = self.cwd.clone();
176
177        tokio::spawn(async move {
178            let manager = HookManager::new(config, session_id, cwd);
179            let _ = manager
180                .run_hooks(event, match_value.as_deref(), event_data.as_ref())
181                .await;
182        });
183    }
184
185    /// Build the JSON payload sent to hook commands on stdin.
186    ///
187    /// Follows the hook protocol:
188    /// - `session_id`: Current session ID
189    /// - `cwd`: Current working directory
190    /// - `hook_event_name`: The event name (e.g., "PreToolUse")
191    /// - `tool_name`: Tool name (for tool events)
192    /// - `agent_type`: Agent type (for subagent events)
193    /// - `startup_type`: Startup type (for SessionStart)
194    /// - `trigger`: Trigger type (for PreCompact)
195    /// - Additional fields from `event_data`
196    fn build_stdin(
197        &self,
198        event: HookEvent,
199        match_value: Option<&str>,
200        event_data: Option<&Value>,
201    ) -> Value {
202        let mut payload = Map::new();
203
204        payload.insert(
205            "session_id".to_string(),
206            Value::String(self.session_id.clone()),
207        );
208        payload.insert("cwd".to_string(), Value::String(self.cwd.clone()));
209        payload.insert(
210            "hook_event_name".to_string(),
211            Value::String(event.as_str().to_string()),
212        );
213
214        let mv = match_value.unwrap_or("");
215
216        // Tool events include tool_name
217        if event.is_tool_event() {
218            payload.insert("tool_name".to_string(), Value::String(mv.to_string()));
219        }
220
221        // Subagent events include agent_type
222        if event.is_subagent_event() {
223            payload.insert("agent_type".to_string(), Value::String(mv.to_string()));
224        }
225
226        // SessionStart includes startup_type
227        if event == HookEvent::SessionStart {
228            payload.insert(
229                "startup_type".to_string(),
230                Value::String(if mv.is_empty() { "startup" } else { mv }.to_string()),
231            );
232        }
233
234        // PreCompact includes trigger
235        if event == HookEvent::PreCompact {
236            payload.insert(
237                "trigger".to_string(),
238                Value::String(if mv.is_empty() { "auto" } else { mv }.to_string()),
239            );
240        }
241
242        // Merge event-specific data
243        if let Some(Value::Object(data)) = event_data {
244            // Standard fields first
245            for key in &[
246                "tool_input",
247                "tool_response",
248                "user_prompt",
249                "agent_task",
250                "agent_result",
251            ] {
252                if let Some(val) = data.get(*key) {
253                    payload.insert((*key).to_string(), val.clone());
254                }
255            }
256            // Pass through any other data not already in payload
257            for (key, val) in data {
258                if !payload.contains_key(key) {
259                    payload.insert(key.clone(), val.clone());
260                }
261            }
262        }
263
264        Value::Object(payload)
265    }
266}
267
268#[cfg(test)]
269#[path = "manager_tests.rs"]
270mod tests;