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