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