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    /// - `ZEPH_TURN_TOOL_CALLS`    — number of tool calls dispatched this turn.
98    #[serde(default)]
99    pub turn_complete: Vec<HookDef>,
100    /// Maximum number of `PreToolUse` hook blocks allowed per turn before the turn is ended
101    /// with a warning message. Counts individual tool blocks — if a tier has N blocked tools,
102    /// the counter increments by N. Default: 8. Use `0` for no cap (unlimited blocks).
103    #[serde(default = "default_hook_block_cap")]
104    pub hook_block_cap: usize,
105    /// Hooks fired before each tool execution, matched by tool name pattern.
106    ///
107    /// Uses pipe-separated pattern matching (same as subagent hooks). Hooks fire
108    /// before the `RuntimeLayer::before_tool` permission check — they observe every
109    /// attempted tool call, including calls that will be subsequently blocked.
110    ///
111    /// Hook serialization within a tier: hooks for tools in the same dependency tier
112    /// are dispatched sequentially (one tool's hooks complete before the next tool's
113    /// hooks start). Hooks for tools in different tiers may overlap.
114    ///
115    /// Hooks are fail-open: errors are logged but do not block tool execution.
116    ///
117    /// Environment variables set for `Command` hooks:
118    /// - `ZEPH_TOOL_NAME`      — name of the tool being invoked.
119    /// - `ZEPH_TOOL_ARGS_JSON` — JSON-serialized tool arguments (truncated at 64 KiB).
120    /// - `ZEPH_SESSION_ID`     — current conversation identifier, omitted when unavailable.
121    #[serde(default)]
122    pub pre_tool_use: Vec<HookMatcher>,
123    /// Hooks fired after each tool execution completes, matched by tool name pattern.
124    ///
125    /// Fires after the tool result is available. Same pattern matching and
126    /// fail-open semantics as `pre_tool_use`.
127    ///
128    /// Environment variables set for `Command` hooks:
129    /// - `ZEPH_TOOL_NAME`        — name of the tool that was invoked.
130    /// - `ZEPH_TOOL_ARGS_JSON`   — JSON-serialized tool arguments (truncated at 64 KiB).
131    /// - `ZEPH_SESSION_ID`       — current conversation identifier, omitted when unavailable.
132    /// - `ZEPH_TOOL_DURATION_MS` — wall-clock execution time in milliseconds.
133    #[serde(default)]
134    pub post_tool_use: Vec<HookMatcher>,
135}
136
137impl Default for HooksConfig {
138    fn default() -> Self {
139        Self {
140            cwd_changed: Vec::new(),
141            file_changed: None,
142            permission_denied: Vec::new(),
143            turn_complete: Vec::new(),
144            hook_block_cap: default_hook_block_cap(),
145            pre_tool_use: Vec::new(),
146            post_tool_use: Vec::new(),
147        }
148    }
149}
150
151impl HooksConfig {
152    /// Returns `true` when no hooks are configured (all sections are empty or absent).
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use zeph_config::hooks::HooksConfig;
158    ///
159    /// assert!(HooksConfig::default().is_empty());
160    /// ```
161    #[must_use]
162    pub fn is_empty(&self) -> bool {
163        self.cwd_changed.is_empty()
164            && self.file_changed.is_none()
165            && self.permission_denied.is_empty()
166            && self.turn_complete.is_empty()
167            && self.pre_tool_use.is_empty()
168            && self.post_tool_use.is_empty()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::subagent::HookAction;
176    use std::assert_matches;
177
178    fn cmd_hook(command: &str) -> HookDef {
179        HookDef {
180            action: HookAction::Command {
181                command: command.into(),
182            },
183            timeout_secs: 10,
184            fail_closed: false,
185            r#if: None,
186        }
187    }
188
189    #[test]
190    fn hooks_config_default_is_empty() {
191        let cfg = HooksConfig::default();
192        assert!(cfg.is_empty());
193    }
194
195    #[test]
196    fn file_changed_config_default_debounce() {
197        let cfg = FileChangedConfig::default();
198        assert_eq!(cfg.debounce_ms, 500);
199        assert!(cfg.watch_paths.is_empty());
200        assert!(cfg.hooks.is_empty());
201    }
202
203    #[test]
204    fn hooks_config_parses_from_toml() {
205        let toml = r#"
206[[cwd_changed]]
207type = "command"
208command = "echo changed"
209timeout_secs = 10
210fail_closed = false
211
212[file_changed]
213watch_paths = ["src/", "Cargo.toml"]
214debounce_ms = 300
215[[file_changed.hooks]]
216type = "command"
217command = "cargo check"
218timeout_secs = 30
219fail_closed = false
220
221[[permission_denied]]
222type = "command"
223command = "echo denied"
224timeout_secs = 5
225fail_closed = false
226"#;
227        let cfg: HooksConfig = toml::from_str(toml).unwrap();
228        assert_eq!(cfg.cwd_changed.len(), 1);
229        assert!(
230            matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo changed")
231        );
232        let fc = cfg.file_changed.as_ref().unwrap();
233        assert_eq!(fc.watch_paths.len(), 2);
234        assert_eq!(fc.debounce_ms, 300);
235        assert_eq!(fc.hooks.len(), 1);
236        assert_eq!(cfg.permission_denied.len(), 1);
237        assert!(
238            matches!(&cfg.permission_denied[0].action, HookAction::Command { command } if command == "echo denied")
239        );
240    }
241
242    #[test]
243    fn hooks_config_parses_mcp_tool_hook() {
244        let toml = r#"
245[[permission_denied]]
246type = "mcp_tool"
247server = "policy"
248tool = "audit"
249[permission_denied.args]
250severity = "high"
251"#;
252        let cfg: HooksConfig = toml::from_str(toml).unwrap();
253        assert_eq!(cfg.permission_denied.len(), 1);
254        assert_matches!(
255            &cfg.permission_denied[0].action,
256            HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
257        );
258    }
259
260    #[test]
261    fn hooks_config_not_empty_with_cwd_hooks() {
262        let cfg = HooksConfig {
263            cwd_changed: vec![cmd_hook("echo hi")],
264            file_changed: None,
265            permission_denied: Vec::new(),
266            turn_complete: Vec::new(),
267            hook_block_cap: 8,
268            pre_tool_use: Vec::new(),
269            post_tool_use: Vec::new(),
270        };
271        assert!(!cfg.is_empty());
272    }
273
274    #[test]
275    fn hooks_config_not_empty_with_permission_denied_hooks() {
276        let cfg = HooksConfig {
277            cwd_changed: Vec::new(),
278            file_changed: None,
279            permission_denied: vec![cmd_hook("echo denied")],
280            turn_complete: Vec::new(),
281            hook_block_cap: 8,
282            pre_tool_use: Vec::new(),
283            post_tool_use: Vec::new(),
284        };
285        assert!(!cfg.is_empty());
286    }
287
288    #[test]
289    fn hooks_config_not_empty_with_turn_complete_hooks() {
290        let cfg = HooksConfig {
291            cwd_changed: Vec::new(),
292            file_changed: None,
293            permission_denied: Vec::new(),
294            turn_complete: vec![cmd_hook("notify-send Zeph done")],
295            hook_block_cap: 8,
296            pre_tool_use: Vec::new(),
297            post_tool_use: Vec::new(),
298        };
299        assert!(!cfg.is_empty());
300    }
301
302    #[test]
303    fn hooks_config_is_empty_when_all_empty_including_turn_complete() {
304        let cfg = HooksConfig {
305            cwd_changed: Vec::new(),
306            file_changed: None,
307            permission_denied: Vec::new(),
308            turn_complete: Vec::new(),
309            hook_block_cap: 8,
310            pre_tool_use: Vec::new(),
311            post_tool_use: Vec::new(),
312        };
313        assert!(cfg.is_empty());
314    }
315
316    #[test]
317    fn hooks_config_parses_turn_complete_from_toml() {
318        let toml = r#"
319[[turn_complete]]
320type = "command"
321command = "osascript -e 'display notification \"$ZEPH_TURN_PREVIEW\" with title \"Zeph\"'"
322timeout_secs = 3
323fail_closed = false
324"#;
325        let cfg: HooksConfig = toml::from_str(toml).unwrap();
326        assert_eq!(cfg.turn_complete.len(), 1);
327        assert!(cfg.cwd_changed.is_empty());
328        assert!(cfg.permission_denied.is_empty());
329    }
330
331    #[test]
332    fn hooks_config_not_empty_with_pre_tool_use() {
333        use crate::subagent::HookMatcher;
334        let cfg = HooksConfig {
335            cwd_changed: Vec::new(),
336            file_changed: None,
337            permission_denied: Vec::new(),
338            turn_complete: Vec::new(),
339            hook_block_cap: 8,
340            pre_tool_use: vec![HookMatcher {
341                matcher: "Edit|Write".to_owned(),
342                hooks: vec![cmd_hook("echo pre")],
343            }],
344            post_tool_use: Vec::new(),
345        };
346        assert!(!cfg.is_empty());
347    }
348
349    #[test]
350    fn hooks_config_parses_pre_and_post_tool_use_from_toml() {
351        let toml = r#"
352[[pre_tool_use]]
353matcher = "Edit|Write"
354[[pre_tool_use.hooks]]
355type = "command"
356command = "echo pre $ZEPH_TOOL_NAME"
357timeout_secs = 5
358fail_closed = false
359
360[[post_tool_use]]
361matcher = "Shell"
362[[post_tool_use.hooks]]
363type = "command"
364command = "echo post $ZEPH_TOOL_DURATION_MS"
365timeout_secs = 5
366fail_closed = false
367"#;
368        let cfg: HooksConfig = toml::from_str(toml).unwrap();
369        assert_eq!(cfg.pre_tool_use.len(), 1);
370        assert_eq!(cfg.pre_tool_use[0].matcher, "Edit|Write");
371        assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
372        assert_eq!(cfg.post_tool_use.len(), 1);
373        assert_eq!(cfg.post_tool_use[0].matcher, "Shell");
374        assert!(!cfg.is_empty());
375    }
376
377    /// Exercises the full testing.toml hooks pattern: `cwd_changed` + `file_changed` + `permission_denied`
378    /// all in one TOML document, in the order they appear in testing.toml. Prevents regression of
379    /// issue #3625 where hooks appeared empty despite correct TOML config.
380    #[test]
381    fn hooks_config_parses_all_sections_in_sequence() {
382        let toml = r#"
383[[cwd_changed]]
384type = "command"
385command = "echo 'CWD_CHANGED_HOOK_FIRED'"
386timeout_secs = 10
387fail_closed = false
388
389[file_changed]
390watch_paths = ["src/", "Cargo.toml"]
391debounce_ms = 500
392[[file_changed.hooks]]
393type = "command"
394command = "cargo check"
395timeout_secs = 30
396fail_closed = false
397
398[[permission_denied]]
399type = "command"
400command = "echo 'PERMISSION_DENIED_HOOK_FIRED'"
401timeout_secs = 5
402fail_closed = false
403"#;
404        let cfg: HooksConfig = toml::from_str(toml).unwrap();
405        assert_eq!(cfg.cwd_changed.len(), 1, "expected 1 cwd_changed hook");
406        assert!(
407            matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo 'CWD_CHANGED_HOOK_FIRED'")
408        );
409        let fc = cfg
410            .file_changed
411            .as_ref()
412            .expect("file_changed must be Some");
413        assert_eq!(fc.hooks.len(), 1, "expected 1 file_changed hook");
414        assert_eq!(fc.debounce_ms, 500);
415        assert_eq!(
416            cfg.permission_denied.len(),
417            1,
418            "expected 1 permission_denied hook"
419        );
420        assert!(!cfg.is_empty(), "hooks config must not be empty");
421    }
422
423    #[test]
424    fn hook_block_cap_default_is_8() {
425        let cfg = HooksConfig::default();
426        assert_eq!(cfg.hook_block_cap, 8);
427    }
428
429    #[test]
430    fn hook_block_cap_parses_from_toml() {
431        let toml = "hook_block_cap = 4\n";
432        let cfg: HooksConfig = toml::from_str(toml).unwrap();
433        assert_eq!(cfg.hook_block_cap, 4);
434    }
435
436    #[test]
437    fn hook_block_cap_zero_from_toml() {
438        let toml = "hook_block_cap = 0\n";
439        let cfg: HooksConfig = toml::from_str(toml).unwrap();
440        assert_eq!(cfg.hook_block_cap, 0);
441    }
442
443    #[test]
444    fn hooks_config_parses_mcp_tool_in_pre_tool_use() {
445        let toml = r#"
446[[pre_tool_use]]
447matcher = "Shell"
448[[pre_tool_use.hooks]]
449type = "mcp_tool"
450server = "policy"
451tool = "audit"
452[pre_tool_use.hooks.args]
453severity = "high"
454"#;
455        let cfg: HooksConfig = toml::from_str(toml).unwrap();
456        assert_eq!(cfg.pre_tool_use.len(), 1);
457        assert_eq!(cfg.pre_tool_use[0].matcher, "Shell");
458        assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
459        assert_matches!(
460            &cfg.pre_tool_use[0].hooks[0].action,
461            HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
462        );
463        assert!(!cfg.is_empty());
464    }
465
466    // ── HookDef `if` field serde ──────────────────────────────────────────────
467
468    #[test]
469    fn hook_def_if_none_omits_field_in_toml() {
470        let hook = cmd_hook("echo hi");
471        // r#if: None must not serialize the `if` key.
472        let serialized = toml::to_string(&hook).unwrap();
473        assert!(
474            !serialized.contains("if"),
475            "unexpected `if` key: {serialized}"
476        );
477    }
478
479    #[test]
480    fn hook_def_if_some_roundtrips_via_toml() {
481        use crate::subagent::HookAction;
482        use crate::subagent::HookDef;
483        let hook = HookDef {
484            action: HookAction::Command {
485                command: "echo hi".into(),
486            },
487            timeout_secs: 10,
488            fail_closed: false,
489            r#if: Some("tool:shell".to_owned()),
490        };
491        let serialized = toml::to_string(&hook).unwrap();
492        assert!(
493            serialized.contains("if = \"tool:shell\""),
494            "missing `if` key: {serialized}"
495        );
496        let deserialized: HookDef = toml::from_str(&serialized).unwrap();
497        assert_eq!(deserialized.r#if.as_deref(), Some("tool:shell"));
498    }
499
500    #[test]
501    fn hook_def_if_condition_parses_from_toml() {
502        let toml = r#"
503[[post_tool_use]]
504matcher = "Shell"
505[[post_tool_use.hooks]]
506type = "command"
507command = "echo shell"
508timeout_secs = 5
509fail_closed = false
510if = "tool:shell"
511"#;
512        let cfg: HooksConfig = toml::from_str(toml).unwrap();
513        let hook = &cfg.post_tool_use[0].hooks[0];
514        assert_eq!(hook.r#if.as_deref(), Some("tool:shell"));
515    }
516}