Skip to main content

zeph_config/
hooks.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use crate::subagent::{HookDef, HookMatcher};
9
10fn default_debounce_ms() -> u64 {
11    500
12}
13
14/// Configuration for hooks triggered when watched files change.
15#[derive(Debug, Clone, Deserialize, Serialize)]
16#[serde(default)]
17pub struct FileChangedConfig {
18    /// Paths to watch for changes. Resolved relative to the project root (cwd at startup).
19    pub watch_paths: Vec<PathBuf>,
20    /// Debounce interval in milliseconds. Default: 500.
21    #[serde(default = "default_debounce_ms")]
22    pub debounce_ms: u64,
23    /// Hooks fired when a watched file changes.
24    #[serde(default)]
25    pub hooks: Vec<HookDef>,
26}
27
28impl Default for FileChangedConfig {
29    fn default() -> Self {
30        Self {
31            watch_paths: Vec::new(),
32            debounce_ms: default_debounce_ms(),
33            hooks: Vec::new(),
34        }
35    }
36}
37
38/// Top-level hooks configuration section.
39///
40/// Each sub-section corresponds to a lifecycle event. All sections default to
41/// empty (no hooks). Events fire in the order hooks are listed.
42#[derive(Debug, Clone, Default, Deserialize, Serialize)]
43#[serde(default)]
44pub struct HooksConfig {
45    /// Hooks fired when the agent's working directory changes via `set_working_directory`.
46    pub cwd_changed: Vec<HookDef>,
47    /// File-change watcher configuration with associated hooks.
48    pub file_changed: Option<FileChangedConfig>,
49    /// Hooks fired when a tool execution is blocked by a `RuntimeLayer::before_tool` check.
50    ///
51    /// Environment variables set for `Command` hooks:
52    /// - `ZEPH_DENIED_TOOL` — the name of the tool that was blocked.
53    /// - `ZEPH_DENY_REASON` — human-readable reason string from the layer.
54    pub permission_denied: Vec<HookDef>,
55    /// Hooks fired after each agent turn completes (#3327).
56    ///
57    /// Runs regardless of the `[notifications]` config. When a `[notifications]` notifier is
58    /// also configured, these hooks share its `should_fire` gate (respecting `min_turn_duration_ms`,
59    /// `only_on_error`, and `enabled`). When no notifier is configured, hooks fire on every
60    /// completed turn.
61    ///
62    /// Use `min_duration_ms` in a wrapper script or the `[notifications].min_turn_duration_ms`
63    /// gate to avoid firing on trivial responses.
64    ///
65    /// Environment variables set for `Command` hooks:
66    /// - `ZEPH_TURN_DURATION_MS`   — wall-clock duration of the turn in milliseconds.
67    /// - `ZEPH_TURN_STATUS`        — `"success"` or `"error"`.
68    /// - `ZEPH_TURN_PREVIEW`       — redacted first ≤ 160 chars of the assistant response.
69    /// - `ZEPH_TURN_LLM_REQUESTS`  — number of completed LLM round-trips this turn.
70    #[serde(default)]
71    pub turn_complete: Vec<HookDef>,
72    /// Hooks fired before each tool execution, matched by tool name pattern.
73    ///
74    /// Uses pipe-separated pattern matching (same as subagent hooks). Hooks fire
75    /// before the `RuntimeLayer::before_tool` permission check — they observe every
76    /// attempted tool call, including calls that will be subsequently blocked.
77    ///
78    /// Hook serialization within a tier: hooks for tools in the same dependency tier
79    /// are dispatched sequentially (one tool's hooks complete before the next tool's
80    /// hooks start). Hooks for tools in different tiers may overlap.
81    ///
82    /// Hooks are fail-open: errors are logged but do not block tool execution.
83    ///
84    /// Environment variables set for `Command` hooks:
85    /// - `ZEPH_TOOL_NAME`      — name of the tool being invoked.
86    /// - `ZEPH_TOOL_ARGS_JSON` — JSON-serialized tool arguments (truncated at 64 KiB).
87    /// - `ZEPH_SESSION_ID`     — current conversation identifier, omitted when unavailable.
88    #[serde(default)]
89    pub pre_tool_use: Vec<HookMatcher>,
90    /// Hooks fired after each tool execution completes, matched by tool name pattern.
91    ///
92    /// Fires after the tool result is available. Same pattern matching and
93    /// fail-open semantics as `pre_tool_use`.
94    ///
95    /// Environment variables set for `Command` hooks:
96    /// - `ZEPH_TOOL_NAME`        — name of the tool that was invoked.
97    /// - `ZEPH_TOOL_ARGS_JSON`   — JSON-serialized tool arguments (truncated at 64 KiB).
98    /// - `ZEPH_SESSION_ID`       — current conversation identifier, omitted when unavailable.
99    /// - `ZEPH_TOOL_DURATION_MS` — wall-clock execution time in milliseconds.
100    #[serde(default)]
101    pub post_tool_use: Vec<HookMatcher>,
102}
103
104impl HooksConfig {
105    /// Returns `true` when no hooks are configured (all sections are empty or absent).
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use zeph_config::hooks::HooksConfig;
111    ///
112    /// assert!(HooksConfig::default().is_empty());
113    /// ```
114    #[must_use]
115    pub fn is_empty(&self) -> bool {
116        self.cwd_changed.is_empty()
117            && self.file_changed.is_none()
118            && self.permission_denied.is_empty()
119            && self.turn_complete.is_empty()
120            && self.pre_tool_use.is_empty()
121            && self.post_tool_use.is_empty()
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::subagent::HookAction;
129
130    fn cmd_hook(command: &str) -> HookDef {
131        HookDef {
132            action: HookAction::Command {
133                command: command.into(),
134            },
135            timeout_secs: 10,
136            fail_closed: false,
137        }
138    }
139
140    #[test]
141    fn hooks_config_default_is_empty() {
142        let cfg = HooksConfig::default();
143        assert!(cfg.is_empty());
144    }
145
146    #[test]
147    fn file_changed_config_default_debounce() {
148        let cfg = FileChangedConfig::default();
149        assert_eq!(cfg.debounce_ms, 500);
150        assert!(cfg.watch_paths.is_empty());
151        assert!(cfg.hooks.is_empty());
152    }
153
154    #[test]
155    fn hooks_config_parses_from_toml() {
156        let toml = r#"
157[[cwd_changed]]
158type = "command"
159command = "echo changed"
160timeout_secs = 10
161fail_closed = false
162
163[file_changed]
164watch_paths = ["src/", "Cargo.toml"]
165debounce_ms = 300
166[[file_changed.hooks]]
167type = "command"
168command = "cargo check"
169timeout_secs = 30
170fail_closed = false
171
172[[permission_denied]]
173type = "command"
174command = "echo denied"
175timeout_secs = 5
176fail_closed = false
177"#;
178        let cfg: HooksConfig = toml::from_str(toml).unwrap();
179        assert_eq!(cfg.cwd_changed.len(), 1);
180        assert!(
181            matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo changed")
182        );
183        let fc = cfg.file_changed.as_ref().unwrap();
184        assert_eq!(fc.watch_paths.len(), 2);
185        assert_eq!(fc.debounce_ms, 300);
186        assert_eq!(fc.hooks.len(), 1);
187        assert_eq!(cfg.permission_denied.len(), 1);
188        assert!(
189            matches!(&cfg.permission_denied[0].action, HookAction::Command { command } if command == "echo denied")
190        );
191    }
192
193    #[test]
194    fn hooks_config_parses_mcp_tool_hook() {
195        let toml = r#"
196[[permission_denied]]
197type = "mcp_tool"
198server = "policy"
199tool = "audit"
200[permission_denied.args]
201severity = "high"
202"#;
203        let cfg: HooksConfig = toml::from_str(toml).unwrap();
204        assert_eq!(cfg.permission_denied.len(), 1);
205        assert!(matches!(
206            &cfg.permission_denied[0].action,
207            HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
208        ));
209    }
210
211    #[test]
212    fn hooks_config_not_empty_with_cwd_hooks() {
213        let cfg = HooksConfig {
214            cwd_changed: vec![cmd_hook("echo hi")],
215            file_changed: None,
216            permission_denied: Vec::new(),
217            turn_complete: Vec::new(),
218            pre_tool_use: Vec::new(),
219            post_tool_use: Vec::new(),
220        };
221        assert!(!cfg.is_empty());
222    }
223
224    #[test]
225    fn hooks_config_not_empty_with_permission_denied_hooks() {
226        let cfg = HooksConfig {
227            cwd_changed: Vec::new(),
228            file_changed: None,
229            permission_denied: vec![cmd_hook("echo denied")],
230            turn_complete: Vec::new(),
231            pre_tool_use: Vec::new(),
232            post_tool_use: Vec::new(),
233        };
234        assert!(!cfg.is_empty());
235    }
236
237    #[test]
238    fn hooks_config_not_empty_with_turn_complete_hooks() {
239        let cfg = HooksConfig {
240            cwd_changed: Vec::new(),
241            file_changed: None,
242            permission_denied: Vec::new(),
243            turn_complete: vec![cmd_hook("notify-send Zeph done")],
244            pre_tool_use: Vec::new(),
245            post_tool_use: Vec::new(),
246        };
247        assert!(!cfg.is_empty());
248    }
249
250    #[test]
251    fn hooks_config_is_empty_when_all_empty_including_turn_complete() {
252        let cfg = HooksConfig {
253            cwd_changed: Vec::new(),
254            file_changed: None,
255            permission_denied: Vec::new(),
256            turn_complete: Vec::new(),
257            pre_tool_use: Vec::new(),
258            post_tool_use: Vec::new(),
259        };
260        assert!(cfg.is_empty());
261    }
262
263    #[test]
264    fn hooks_config_parses_turn_complete_from_toml() {
265        let toml = r#"
266[[turn_complete]]
267type = "command"
268command = "osascript -e 'display notification \"$ZEPH_TURN_PREVIEW\" with title \"Zeph\"'"
269timeout_secs = 3
270fail_closed = false
271"#;
272        let cfg: HooksConfig = toml::from_str(toml).unwrap();
273        assert_eq!(cfg.turn_complete.len(), 1);
274        assert!(cfg.cwd_changed.is_empty());
275        assert!(cfg.permission_denied.is_empty());
276    }
277
278    #[test]
279    fn hooks_config_not_empty_with_pre_tool_use() {
280        use crate::subagent::HookMatcher;
281        let cfg = HooksConfig {
282            cwd_changed: Vec::new(),
283            file_changed: None,
284            permission_denied: Vec::new(),
285            turn_complete: Vec::new(),
286            pre_tool_use: vec![HookMatcher {
287                matcher: "Edit|Write".to_owned(),
288                hooks: vec![cmd_hook("echo pre")],
289            }],
290            post_tool_use: Vec::new(),
291        };
292        assert!(!cfg.is_empty());
293    }
294
295    #[test]
296    fn hooks_config_parses_pre_and_post_tool_use_from_toml() {
297        let toml = r#"
298[[pre_tool_use]]
299matcher = "Edit|Write"
300[[pre_tool_use.hooks]]
301type = "command"
302command = "echo pre $ZEPH_TOOL_NAME"
303timeout_secs = 5
304fail_closed = false
305
306[[post_tool_use]]
307matcher = "Shell"
308[[post_tool_use.hooks]]
309type = "command"
310command = "echo post $ZEPH_TOOL_DURATION_MS"
311timeout_secs = 5
312fail_closed = false
313"#;
314        let cfg: HooksConfig = toml::from_str(toml).unwrap();
315        assert_eq!(cfg.pre_tool_use.len(), 1);
316        assert_eq!(cfg.pre_tool_use[0].matcher, "Edit|Write");
317        assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
318        assert_eq!(cfg.post_tool_use.len(), 1);
319        assert_eq!(cfg.post_tool_use[0].matcher, "Shell");
320        assert!(!cfg.is_empty());
321    }
322
323    /// Exercises the full testing.toml hooks pattern: `cwd_changed` + `file_changed` + `permission_denied`
324    /// all in one TOML document, in the order they appear in testing.toml. Prevents regression of
325    /// issue #3625 where hooks appeared empty despite correct TOML config.
326    #[test]
327    fn hooks_config_parses_all_sections_in_sequence() {
328        let toml = r#"
329[[cwd_changed]]
330type = "command"
331command = "echo 'CWD_CHANGED_HOOK_FIRED'"
332timeout_secs = 10
333fail_closed = false
334
335[file_changed]
336watch_paths = ["src/", "Cargo.toml"]
337debounce_ms = 500
338[[file_changed.hooks]]
339type = "command"
340command = "cargo check"
341timeout_secs = 30
342fail_closed = false
343
344[[permission_denied]]
345type = "command"
346command = "echo 'PERMISSION_DENIED_HOOK_FIRED'"
347timeout_secs = 5
348fail_closed = false
349"#;
350        let cfg: HooksConfig = toml::from_str(toml).unwrap();
351        assert_eq!(cfg.cwd_changed.len(), 1, "expected 1 cwd_changed hook");
352        assert!(
353            matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo 'CWD_CHANGED_HOOK_FIRED'")
354        );
355        let fc = cfg
356            .file_changed
357            .as_ref()
358            .expect("file_changed must be Some");
359        assert_eq!(fc.hooks.len(), 1, "expected 1 file_changed hook");
360        assert_eq!(fc.debounce_ms, 500);
361        assert_eq!(
362            cfg.permission_denied.len(),
363            1,
364            "expected 1 permission_denied hook"
365        );
366        assert!(!cfg.is_empty(), "hooks config must not be empty");
367    }
368}