1use crate::error::AgentConfigError;
10
11use super::validate::{validate_identifier, IdentifierKind};
12
13mod builder;
14
15pub use builder::HookSpecBuilder;
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
19#[non_exhaustive]
20pub struct HookRuntimeOptions {
21 pub timeout_seconds: Option<u64>,
23 pub status_message: Option<String>,
25 pub async_run: Option<bool>,
27 pub shell: Option<String>,
29 pub windows_command: Option<HookCommand>,
31 pub codex_inline_toml: Option<bool>,
33}
34
35#[derive(Debug, Clone)]
40pub struct HookSpec {
41 pub tag: String,
46
47 pub command: HookCommand,
53
54 pub matcher: Matcher,
56
57 pub event: Event,
59
60 pub rules: Option<RulesBlock>,
63
64 pub script: Option<ScriptTemplate>,
68
69 pub friendly_name: Option<String>,
72
73 pub options: HookRuntimeOptions,
75}
76
77impl HookSpec {
78 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 pub(crate) fn validate_tag(tag: &str) -> Result<(), AgentConfigError> {
94 validate_identifier(tag, IdentifierKind::Tag)
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum HookCommand {
109 Program {
111 program: String,
113 args: Vec<String>,
115 },
116 ShellUnchecked {
118 command: String,
120 },
121}
122
123impl HookCommand {
124 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 pub fn shell_unchecked(command: impl Into<String>) -> Self {
138 Self::ShellUnchecked {
139 command: command.into(),
140 }
141 }
142
143 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#[derive(Debug, Clone, PartialEq, Eq)]
263#[non_exhaustive]
264pub enum Matcher {
265 All,
267 Bash,
270 Exact(String),
272 AnyOf(Vec<String>),
274 Regex(String),
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
282#[non_exhaustive]
283pub enum Event {
284 PreToolUse,
287 PostToolUse,
289
290 Notification,
292 UserPromptSubmit,
294 Stop,
296 SubagentStop,
298 PreCompact,
300 SessionStart,
302 SessionEnd,
304 PermissionRequest,
306 PermissionDenied,
308 ConfigChange,
310 FileChanged,
312 InstructionsLoaded,
314
315 Custom(String),
317}
318
319impl Event {
320 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#[derive(Debug, Clone)]
345pub struct RulesBlock {
346 pub content: String,
348}
349
350#[derive(Debug, Clone)]
352#[non_exhaustive]
353pub enum ScriptTemplate {
354 Shell(String),
358 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}