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    use std::assert_matches;
176
177    fn cmd_hook(command: &str) -> HookDef {
178        HookDef {
179            action: HookAction::Command {
180                command: command.into(),
181            },
182            timeout_secs: 10,
183            fail_closed: false,
184            r#if: None,
185        }
186    }
187
188    #[test]
189    fn hooks_config_default_is_empty() {
190        let cfg = HooksConfig::default();
191        assert!(cfg.is_empty());
192    }
193
194    #[test]
195    fn file_changed_config_default_debounce() {
196        let cfg = FileChangedConfig::default();
197        assert_eq!(cfg.debounce_ms, 500);
198        assert!(cfg.watch_paths.is_empty());
199        assert!(cfg.hooks.is_empty());
200    }
201
202    #[test]
203    fn hooks_config_parses_from_toml() {
204        let toml = r#"
205[[cwd_changed]]
206type = "command"
207command = "echo changed"
208timeout_secs = 10
209fail_closed = false
210
211[file_changed]
212watch_paths = ["src/", "Cargo.toml"]
213debounce_ms = 300
214[[file_changed.hooks]]
215type = "command"
216command = "cargo check"
217timeout_secs = 30
218fail_closed = false
219
220[[permission_denied]]
221type = "command"
222command = "echo denied"
223timeout_secs = 5
224fail_closed = false
225"#;
226        let cfg: HooksConfig = toml::from_str(toml).unwrap();
227        assert_eq!(cfg.cwd_changed.len(), 1);
228        assert!(
229            matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo changed")
230        );
231        let fc = cfg.file_changed.as_ref().unwrap();
232        assert_eq!(fc.watch_paths.len(), 2);
233        assert_eq!(fc.debounce_ms, 300);
234        assert_eq!(fc.hooks.len(), 1);
235        assert_eq!(cfg.permission_denied.len(), 1);
236        assert!(
237            matches!(&cfg.permission_denied[0].action, HookAction::Command { command } if command == "echo denied")
238        );
239    }
240
241    #[test]
242    fn hooks_config_parses_mcp_tool_hook() {
243        let toml = r#"
244[[permission_denied]]
245type = "mcp_tool"
246server = "policy"
247tool = "audit"
248[permission_denied.args]
249severity = "high"
250"#;
251        let cfg: HooksConfig = toml::from_str(toml).unwrap();
252        assert_eq!(cfg.permission_denied.len(), 1);
253        assert_matches!(
254            &cfg.permission_denied[0].action,
255            HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
256        );
257    }
258
259    #[test]
260    fn hooks_config_not_empty_with_cwd_hooks() {
261        let cfg = HooksConfig {
262            cwd_changed: vec![cmd_hook("echo hi")],
263            file_changed: None,
264            permission_denied: Vec::new(),
265            turn_complete: Vec::new(),
266            hook_block_cap: 8,
267            pre_tool_use: Vec::new(),
268            post_tool_use: Vec::new(),
269        };
270        assert!(!cfg.is_empty());
271    }
272
273    #[test]
274    fn hooks_config_not_empty_with_permission_denied_hooks() {
275        let cfg = HooksConfig {
276            cwd_changed: Vec::new(),
277            file_changed: None,
278            permission_denied: vec![cmd_hook("echo denied")],
279            turn_complete: Vec::new(),
280            hook_block_cap: 8,
281            pre_tool_use: Vec::new(),
282            post_tool_use: Vec::new(),
283        };
284        assert!(!cfg.is_empty());
285    }
286
287    #[test]
288    fn hooks_config_not_empty_with_turn_complete_hooks() {
289        let cfg = HooksConfig {
290            cwd_changed: Vec::new(),
291            file_changed: None,
292            permission_denied: Vec::new(),
293            turn_complete: vec![cmd_hook("notify-send Zeph done")],
294            hook_block_cap: 8,
295            pre_tool_use: Vec::new(),
296            post_tool_use: Vec::new(),
297        };
298        assert!(!cfg.is_empty());
299    }
300
301    #[test]
302    fn hooks_config_is_empty_when_all_empty_including_turn_complete() {
303        let cfg = HooksConfig {
304            cwd_changed: Vec::new(),
305            file_changed: None,
306            permission_denied: Vec::new(),
307            turn_complete: Vec::new(),
308            hook_block_cap: 8,
309            pre_tool_use: Vec::new(),
310            post_tool_use: Vec::new(),
311        };
312        assert!(cfg.is_empty());
313    }
314
315    #[test]
316    fn hooks_config_parses_turn_complete_from_toml() {
317        let toml = r#"
318[[turn_complete]]
319type = "command"
320command = "osascript -e 'display notification \"$ZEPH_TURN_PREVIEW\" with title \"Zeph\"'"
321timeout_secs = 3
322fail_closed = false
323"#;
324        let cfg: HooksConfig = toml::from_str(toml).unwrap();
325        assert_eq!(cfg.turn_complete.len(), 1);
326        assert!(cfg.cwd_changed.is_empty());
327        assert!(cfg.permission_denied.is_empty());
328    }
329
330    #[test]
331    fn hooks_config_not_empty_with_pre_tool_use() {
332        use crate::subagent::HookMatcher;
333        let cfg = HooksConfig {
334            cwd_changed: Vec::new(),
335            file_changed: None,
336            permission_denied: Vec::new(),
337            turn_complete: Vec::new(),
338            hook_block_cap: 8,
339            pre_tool_use: vec![HookMatcher {
340                matcher: "Edit|Write".to_owned(),
341                hooks: vec![cmd_hook("echo pre")],
342            }],
343            post_tool_use: Vec::new(),
344        };
345        assert!(!cfg.is_empty());
346    }
347
348    #[test]
349    fn hooks_config_parses_pre_and_post_tool_use_from_toml() {
350        let toml = r#"
351[[pre_tool_use]]
352matcher = "Edit|Write"
353[[pre_tool_use.hooks]]
354type = "command"
355command = "echo pre $ZEPH_TOOL_NAME"
356timeout_secs = 5
357fail_closed = false
358
359[[post_tool_use]]
360matcher = "Shell"
361[[post_tool_use.hooks]]
362type = "command"
363command = "echo post $ZEPH_TOOL_DURATION_MS"
364timeout_secs = 5
365fail_closed = false
366"#;
367        let cfg: HooksConfig = toml::from_str(toml).unwrap();
368        assert_eq!(cfg.pre_tool_use.len(), 1);
369        assert_eq!(cfg.pre_tool_use[0].matcher, "Edit|Write");
370        assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
371        assert_eq!(cfg.post_tool_use.len(), 1);
372        assert_eq!(cfg.post_tool_use[0].matcher, "Shell");
373        assert!(!cfg.is_empty());
374    }
375
376    /// Exercises the full testing.toml hooks pattern: `cwd_changed` + `file_changed` + `permission_denied`
377    /// all in one TOML document, in the order they appear in testing.toml. Prevents regression of
378    /// issue #3625 where hooks appeared empty despite correct TOML config.
379    #[test]
380    fn hooks_config_parses_all_sections_in_sequence() {
381        let toml = r#"
382[[cwd_changed]]
383type = "command"
384command = "echo 'CWD_CHANGED_HOOK_FIRED'"
385timeout_secs = 10
386fail_closed = false
387
388[file_changed]
389watch_paths = ["src/", "Cargo.toml"]
390debounce_ms = 500
391[[file_changed.hooks]]
392type = "command"
393command = "cargo check"
394timeout_secs = 30
395fail_closed = false
396
397[[permission_denied]]
398type = "command"
399command = "echo 'PERMISSION_DENIED_HOOK_FIRED'"
400timeout_secs = 5
401fail_closed = false
402"#;
403        let cfg: HooksConfig = toml::from_str(toml).unwrap();
404        assert_eq!(cfg.cwd_changed.len(), 1, "expected 1 cwd_changed hook");
405        assert!(
406            matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo 'CWD_CHANGED_HOOK_FIRED'")
407        );
408        let fc = cfg
409            .file_changed
410            .as_ref()
411            .expect("file_changed must be Some");
412        assert_eq!(fc.hooks.len(), 1, "expected 1 file_changed hook");
413        assert_eq!(fc.debounce_ms, 500);
414        assert_eq!(
415            cfg.permission_denied.len(),
416            1,
417            "expected 1 permission_denied hook"
418        );
419        assert!(!cfg.is_empty(), "hooks config must not be empty");
420    }
421
422    #[test]
423    fn hook_block_cap_default_is_8() {
424        let cfg = HooksConfig::default();
425        assert_eq!(cfg.hook_block_cap, 8);
426    }
427
428    #[test]
429    fn hook_block_cap_parses_from_toml() {
430        let toml = "hook_block_cap = 4\n";
431        let cfg: HooksConfig = toml::from_str(toml).unwrap();
432        assert_eq!(cfg.hook_block_cap, 4);
433    }
434
435    #[test]
436    fn hook_block_cap_zero_from_toml() {
437        let toml = "hook_block_cap = 0\n";
438        let cfg: HooksConfig = toml::from_str(toml).unwrap();
439        assert_eq!(cfg.hook_block_cap, 0);
440    }
441
442    #[test]
443    fn hooks_config_parses_mcp_tool_in_pre_tool_use() {
444        let toml = r#"
445[[pre_tool_use]]
446matcher = "Shell"
447[[pre_tool_use.hooks]]
448type = "mcp_tool"
449server = "policy"
450tool = "audit"
451[pre_tool_use.hooks.args]
452severity = "high"
453"#;
454        let cfg: HooksConfig = toml::from_str(toml).unwrap();
455        assert_eq!(cfg.pre_tool_use.len(), 1);
456        assert_eq!(cfg.pre_tool_use[0].matcher, "Shell");
457        assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
458        assert_matches!(
459            &cfg.pre_tool_use[0].hooks[0].action,
460            HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
461        );
462        assert!(!cfg.is_empty());
463    }
464
465    // ── HookDef `if` field serde ──────────────────────────────────────────────
466
467    #[test]
468    fn hook_def_if_none_omits_field_in_toml() {
469        let hook = cmd_hook("echo hi");
470        // r#if: None must not serialize the `if` key.
471        let serialized = toml::to_string(&hook).unwrap();
472        assert!(
473            !serialized.contains("if"),
474            "unexpected `if` key: {serialized}"
475        );
476    }
477
478    #[test]
479    fn hook_def_if_some_roundtrips_via_toml() {
480        use crate::subagent::HookAction;
481        use crate::subagent::HookDef;
482        let hook = HookDef {
483            action: HookAction::Command {
484                command: "echo hi".into(),
485            },
486            timeout_secs: 10,
487            fail_closed: false,
488            r#if: Some("tool:shell".to_owned()),
489        };
490        let serialized = toml::to_string(&hook).unwrap();
491        assert!(
492            serialized.contains("if = \"tool:shell\""),
493            "missing `if` key: {serialized}"
494        );
495        let deserialized: HookDef = toml::from_str(&serialized).unwrap();
496        assert_eq!(deserialized.r#if.as_deref(), Some("tool:shell"));
497    }
498
499    #[test]
500    fn hook_def_if_condition_parses_from_toml() {
501        let toml = r#"
502[[post_tool_use]]
503matcher = "Shell"
504[[post_tool_use.hooks]]
505type = "command"
506command = "echo shell"
507timeout_secs = 5
508fail_closed = false
509if = "tool:shell"
510"#;
511        let cfg: HooksConfig = toml::from_str(toml).unwrap();
512        let hook = &cfg.post_tool_use[0].hooks[0];
513        assert_eq!(hook.r#if.as_deref(), Some("tool:shell"));
514    }
515}