Skip to main content

agent_config/spec/hook/
mod.rs

1//! Hook spec, builder, and supporting types.
2//!
3//! Layout: this module is a directory split into `builder.rs` (the
4//! [`HookSpecBuilder`] fluent API) and the rest of the spec types
5//! ([`HookSpec`], [`HookCommand`], [`Matcher`], [`Event`], [`RulesBlock`],
6//! [`ScriptTemplate`]) plus shared validators here. The flat re-exports
7//! below match the parent `spec` module's expected surface.
8
9use crate::error::AgentConfigError;
10
11use super::validate::{validate_identifier, IdentifierKind};
12
13mod builder;
14
15pub use builder::HookSpecBuilder;
16
17/// Everything an [`Integration`](crate::Integration) needs to install a hook.
18///
19/// Build via [`HookSpec::builder`]. For fallible construction see
20/// [`HookSpecBuilder::try_build`].
21#[derive(Debug, Clone)]
22pub struct HookSpec {
23    /// Unique identifier for *the consumer of this library*. Used to namespace
24    /// fenced markdown blocks, JSON entries, and per-tag filenames so multiple
25    /// CLIs can coexist without stomping each other. Must be ASCII alnum / `_`
26    /// / `-`, non-empty.
27    pub tag: String,
28
29    /// The command the harness should execute.
30    ///
31    /// Use [`HookSpecBuilder::command_program`] for the safe default. Raw shell
32    /// remains available through [`HookSpecBuilder::command_shell_unchecked`]
33    /// for callers that intentionally need shell syntax.
34    pub command: HookCommand,
35
36    /// Which tool calls the hook should match.
37    pub matcher: Matcher,
38
39    /// Which lifecycle event to attach to.
40    pub event: Event,
41
42    /// Optional markdown to inject into the harness's rules/memory file
43    /// (e.g., `~/.claude/CLAUDE.md`, `./.clinerules`, `~/.gemini/GEMINI.md`).
44    pub rules: Option<RulesBlock>,
45
46    /// Optional script body for harnesses that delegate via a script file
47    /// (currently Gemini's `~/.gemini/hooks/*.sh`) or a TS plugin
48    /// (OpenCode, OpenClaw).
49    pub script: Option<ScriptTemplate>,
50
51    /// Optional human-friendly display name for log/UI output. If absent the
52    /// integration's `display_name` is used.
53    pub friendly_name: Option<String>,
54}
55
56impl HookSpec {
57    /// Start building a spec with the given consumer tag.
58    pub fn builder(tag: impl Into<String>) -> HookSpecBuilder {
59        HookSpecBuilder {
60            tag: tag.into(),
61            command: None,
62            matcher: Matcher::All,
63            event: Event::PreToolUse,
64            rules: None,
65            script: None,
66            friendly_name: None,
67        }
68    }
69
70    /// Validate that the tag is non-empty and contains only safe characters.
71    pub(crate) fn validate_tag(tag: &str) -> Result<(), AgentConfigError> {
72        validate_identifier(tag, IdentifierKind::Tag)
73    }
74}
75
76/// Hook command representation.
77///
78/// [`HookCommand::Program`] is the safe default. It stores argv-like program
79/// and argument values, then renders them with **POSIX shell quoting** (see
80/// [`HookCommand::render_shell`]) for harnesses whose hook APIs only accept
81/// strings. [`HookCommand::ShellUnchecked`] is an escape hatch for trusted
82/// raw shell syntax — including the case where the target shell is not POSIX
83/// (e.g. PowerShell on native Windows).
84#[derive(Debug, Clone, PartialEq, Eq)]
85#[non_exhaustive]
86pub enum HookCommand {
87    /// A program plus arguments, rendered safely when a shell string is needed.
88    Program {
89        /// Executable name or path.
90        program: String,
91        /// Command arguments.
92        args: Vec<String>,
93    },
94    /// Trusted raw shell syntax.
95    ShellUnchecked {
96        /// Shell command passed through without quoting or escaping.
97        command: String,
98    },
99}
100
101impl HookCommand {
102    /// Construct a safe program command.
103    pub fn program<I, S>(program: impl Into<String>, args: I) -> Self
104    where
105        I: IntoIterator<Item = S>,
106        S: Into<String>,
107    {
108        Self::Program {
109            program: program.into(),
110            args: args.into_iter().map(Into::into).collect(),
111        }
112    }
113
114    /// Construct an unchecked raw shell command.
115    pub fn shell_unchecked(command: impl Into<String>) -> Self {
116        Self::ShellUnchecked {
117            command: command.into(),
118        }
119    }
120
121    /// Render the command for harnesses whose hook contract accepts a shell
122    /// command string.
123    ///
124    /// The output is **POSIX-shell quoted**: arguments are escaped using
125    /// single-quote rules suitable for `sh`/`bash`. It is not safe for
126    /// `cmd.exe` or PowerShell; on native Windows, callers targeting a
127    /// non-POSIX shell should construct [`HookCommand::ShellUnchecked`] and
128    /// quote the command themselves.
129    ///
130    /// Integrations that store the rendered string in a JSON or YAML field
131    /// (Copilot's `bash` field, Windsurf's `bash`, Gemini's `command`, etc.)
132    /// inherit POSIX semantics through this method. A platform-aware shell
133    /// abstraction (`ShellKind`/PowerShell quoting) is intentionally
134    /// deferred; see the project README for the supported-platforms model.
135    pub fn render_shell(&self) -> String {
136        match self {
137            Self::Program { program, args } => std::iter::once(program.as_str())
138                .chain(args.iter().map(String::as_str))
139                .map(shell_quote)
140                .collect::<Vec<_>>()
141                .join(" "),
142            Self::ShellUnchecked { command } => command.clone(),
143        }
144    }
145
146    pub(super) fn validate(&self) -> Result<(), AgentConfigError> {
147        match self {
148            Self::Program { program, args } => {
149                if program.is_empty() {
150                    return Err(AgentConfigError::InvalidCommand {
151                        reason: "program must not be empty",
152                    });
153                }
154                validate_no_nul(program)?;
155                for arg in args {
156                    validate_no_nul(arg)?;
157                }
158            }
159            Self::ShellUnchecked { command } => {
160                if command.trim().is_empty() {
161                    return Err(AgentConfigError::InvalidCommand {
162                        reason: "shell command must not be empty",
163                    });
164                }
165                validate_no_nul(command)?;
166            }
167        }
168        Ok(())
169    }
170}
171
172fn validate_no_nul(value: &str) -> Result<(), AgentConfigError> {
173    if value.contains('\0') {
174        return Err(AgentConfigError::InvalidCommand {
175            reason: "command values must not contain NUL bytes",
176        });
177    }
178    Ok(())
179}
180
181#[derive(Copy, Clone)]
182pub(super) enum HookStringKind {
183    Matcher,
184    CustomEvent,
185}
186
187impl HookStringKind {
188    fn empty_reason(self) -> &'static str {
189        match self {
190            Self::Matcher => "matcher value must not be empty",
191            Self::CustomEvent => "custom event name must not be empty",
192        }
193    }
194
195    fn control_reason(self) -> &'static str {
196        match self {
197            Self::Matcher => "matcher value must not contain control characters",
198            Self::CustomEvent => "custom event name must not contain control characters",
199        }
200    }
201}
202
203pub(super) fn validate_hook_string(
204    value: &str,
205    kind: HookStringKind,
206) -> Result<(), AgentConfigError> {
207    if value.is_empty() {
208        return Err(AgentConfigError::InvalidTag {
209            tag: value.to_string(),
210            reason: kind.empty_reason(),
211        });
212    }
213    if value.chars().any(|c| (c as u32) < 0x20 || c == '\u{007F}') {
214        return Err(AgentConfigError::InvalidTag {
215            tag: value.to_string(),
216            reason: kind.control_reason(),
217        });
218    }
219    Ok(())
220}
221
222fn shell_quote(value: &str) -> String {
223    if value.is_empty() {
224        return "''".to_string();
225    }
226    if value
227        .bytes()
228        .all(|b| matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'%' | b'+' | b'=' | b','))
229    {
230        return value.to_string();
231    }
232    format!("'{}'", value.replace('\'', "'\\''"))
233}
234
235/// Which tool calls a hook should match.
236///
237/// Each integration translates this to its harness's native syntax. For
238/// example, Claude Code accepts a regex when the matcher contains characters
239/// outside `[A-Za-z0-9_|]`, so [`Matcher::Regex`] passes through verbatim.
240#[derive(Debug, Clone)]
241#[non_exhaustive]
242pub enum Matcher {
243    /// Match any tool call.
244    All,
245    /// Match the harness's "Bash"/"Shell"/"shell" tool (whatever it calls
246    /// command execution). Each integration maps this to the right literal.
247    Bash,
248    /// Match exactly this tool name (e.g., `"Edit"`, `"Read"`).
249    Exact(String),
250    /// Match any of these tool names.
251    AnyOf(Vec<String>),
252    /// Match using the harness's regex syntax (passed through unchanged).
253    Regex(String),
254}
255
256/// Lifecycle event to attach to. Each integration maps this to its harness's
257/// own event name (e.g., `PreToolUse` on Claude Code, `BeforeTool` on Gemini,
258/// `tool.execute.before` on OpenCode).
259#[derive(Debug, Clone)]
260#[non_exhaustive]
261pub enum Event {
262    /// Fire before a tool call is executed (the most common case; lets the
263    /// hook modify or block the call).
264    PreToolUse,
265    /// Fire after a tool call completes.
266    PostToolUse,
267    /// Pass through a custom event name verbatim.
268    Custom(String),
269}
270
271/// Markdown content to inject into the harness's memory/rules file, fenced by
272/// HTML comments keyed on the [`HookSpec::tag`].
273#[derive(Debug, Clone)]
274pub struct RulesBlock {
275    /// Raw markdown body (no fences — they are added by the library).
276    pub content: String,
277}
278
279/// Optional script body for harnesses that need a shell script or TS plugin.
280#[derive(Debug, Clone)]
281#[non_exhaustive]
282pub enum ScriptTemplate {
283    /// POSIX shell script body. The library adds the shebang if absent and
284    /// chmods the file `0755`. A SHA-256 sidecar is written for harnesses
285    /// (Gemini) that verify integrity.
286    Shell(String),
287    /// TypeScript plugin body for OpenCode / OpenClaw.
288    TypeScript(String),
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn try_build_rejects_empty_tag() {
297        let err = HookSpec::builder("")
298            .command_program("x", [] as [&str; 0])
299            .try_build()
300            .unwrap_err();
301        assert!(
302            matches!(err, AgentConfigError::InvalidTag { reason, .. } if reason == "tag must not be empty")
303        );
304    }
305
306    #[test]
307    fn try_build_rejects_tag_with_spaces() {
308        let err = HookSpec::builder("not valid")
309            .command_program("x", [] as [&str; 0])
310            .try_build()
311            .unwrap_err();
312        assert!(
313            matches!(err, AgentConfigError::InvalidTag { reason, .. } if reason.contains("ASCII"))
314        );
315    }
316
317    #[test]
318    fn try_build_rejects_tag_with_special_chars() {
319        for bad in ["tag/slash", "tag.dot", "tag!bang", "tag@at"] {
320            let err = HookSpec::builder(bad)
321                .command_program("x", [] as [&str; 0])
322                .try_build()
323                .unwrap_err();
324            assert!(
325                matches!(err, AgentConfigError::InvalidTag { .. }),
326                "expected InvalidTag for {bad:?}"
327            );
328        }
329    }
330
331    #[test]
332    fn try_build_accepts_valid_tags() {
333        for ok in ["myapp", "my-app", "my_app", "App123", "A", "z9_z"] {
334            HookSpec::builder(ok)
335                .command_program("x", [] as [&str; 0])
336                .try_build()
337                .expect("expected valid tag");
338        }
339    }
340
341    #[test]
342    fn try_build_rejects_missing_command() {
343        let err = HookSpec::builder("ok").try_build().unwrap_err();
344        assert!(
345            matches!(err, AgentConfigError::MissingSpecField { field, .. } if field == "command")
346        );
347    }
348
349    #[test]
350    fn build_panics_on_missing_command() {
351        let result = std::panic::catch_unwind(|| {
352            HookSpec::builder("ok").build();
353        });
354        assert!(result.is_err());
355    }
356
357    #[test]
358    fn builder_sets_all_fields() {
359        let spec = HookSpec::builder("myapp")
360            .command_program("run", ["--flag"])
361            .matcher(Matcher::Bash)
362            .event(Event::PostToolUse)
363            .rules("my rules")
364            .script(ScriptTemplate::Shell("set -e".into()))
365            .friendly_name("My App")
366            .build();
367
368        assert_eq!(spec.tag, "myapp");
369        assert_eq!(
370            spec.command,
371            HookCommand::Program {
372                program: "run".into(),
373                args: vec!["--flag".into()]
374            }
375        );
376        assert!(matches!(spec.matcher, Matcher::Bash));
377        assert!(matches!(spec.event, Event::PostToolUse));
378        assert!(spec.rules.is_some());
379        assert!(spec.script.is_some());
380        assert_eq!(spec.friendly_name.as_deref(), Some("My App"));
381    }
382
383    #[test]
384    fn builder_defaults() {
385        let spec = HookSpec::builder("myapp")
386            .command_program("run", [] as [&str; 0])
387            .build();
388
389        assert!(matches!(spec.matcher, Matcher::All));
390        assert!(matches!(spec.event, Event::PreToolUse));
391        assert!(spec.rules.is_none());
392        assert!(spec.script.is_none());
393        assert!(spec.friendly_name.is_none());
394    }
395
396    #[test]
397    fn program_command_renders_shell_safe_arguments() {
398        let command = HookCommand::program(
399            "my hook",
400            [
401                "repo path",
402                "semi;colon",
403                "$(not run)",
404                "`not run`",
405                "line\nbreak",
406                "quote's",
407                "",
408            ],
409        );
410        assert_eq!(
411            command.render_shell(),
412            "'my hook' 'repo path' 'semi;colon' '$(not run)' '`not run`' 'line\nbreak' 'quote'\\''s' ''"
413        );
414    }
415
416    #[test]
417    fn raw_shell_command_is_explicitly_unchecked() {
418        let spec = HookSpec::builder("myapp")
419            .command_shell_unchecked("myapp hook \"$REPO\"")
420            .build();
421        assert_eq!(spec.command.render_shell(), "myapp hook \"$REPO\"");
422    }
423
424    #[test]
425    fn try_build_rejects_invalid_command_values() {
426        let empty_program = HookSpec::builder("myapp")
427            .command_program("", [] as [&str; 0])
428            .try_build()
429            .unwrap_err();
430        assert!(matches!(
431            empty_program,
432            AgentConfigError::InvalidCommand { .. }
433        ));
434
435        let nul_arg = HookSpec::builder("myapp")
436            .command_program("myapp", ["bad\0arg"])
437            .try_build()
438            .unwrap_err();
439        assert!(matches!(nul_arg, AgentConfigError::InvalidCommand { .. }));
440
441        let empty_shell = HookSpec::builder("myapp")
442            .command_shell_unchecked(" ")
443            .try_build()
444            .unwrap_err();
445        assert!(matches!(
446            empty_shell,
447            AgentConfigError::InvalidCommand { .. }
448        ));
449    }
450
451    #[test]
452    fn try_build_rejects_empty_exact_matcher() {
453        let err = HookSpec::builder("ok")
454            .command_program("x", [] as [&str; 0])
455            .matcher(Matcher::Exact(String::new()))
456            .try_build()
457            .unwrap_err();
458        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
459    }
460
461    #[test]
462    fn try_build_rejects_empty_anyof_matcher() {
463        let err = HookSpec::builder("ok")
464            .command_program("x", [] as [&str; 0])
465            .matcher(Matcher::AnyOf(Vec::new()))
466            .try_build()
467            .unwrap_err();
468        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
469    }
470
471    #[test]
472    fn try_build_rejects_empty_string_in_anyof() {
473        let err = HookSpec::builder("ok")
474            .command_program("x", [] as [&str; 0])
475            .matcher(Matcher::AnyOf(vec!["Edit".into(), String::new()]))
476            .try_build()
477            .unwrap_err();
478        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
479    }
480
481    #[test]
482    fn try_build_rejects_control_char_in_regex() {
483        let err = HookSpec::builder("ok")
484            .command_program("x", [] as [&str; 0])
485            .matcher(Matcher::Regex("foo\u{0007}".into()))
486            .try_build()
487            .unwrap_err();
488        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
489    }
490
491    #[test]
492    fn try_build_rejects_empty_custom_event() {
493        let err = HookSpec::builder("ok")
494            .command_program("x", [] as [&str; 0])
495            .event(Event::Custom(String::new()))
496            .try_build()
497            .unwrap_err();
498        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
499    }
500
501    #[test]
502    fn try_build_rejects_control_char_in_custom_event() {
503        let err = HookSpec::builder("ok")
504            .command_program("x", [] as [&str; 0])
505            .event(Event::Custom("before\nShell".into()))
506            .try_build()
507            .unwrap_err();
508        assert!(matches!(err, AgentConfigError::InvalidTag { .. }));
509    }
510
511    #[test]
512    fn try_build_accepts_valid_custom_event_and_matcher() {
513        HookSpec::builder("ok")
514            .command_program("x", [] as [&str; 0])
515            .event(Event::Custom("beforeShellExecution".into()))
516            .matcher(Matcher::AnyOf(vec!["Edit".into(), "Read".into()]))
517            .try_build()
518            .expect("valid");
519    }
520}