返回 CodeWhale
config.rs
根目录 / crates / tui / src / hooks / config.rs
1 use serde::{Deserialize, Serialize};
2 use std::io::Read as _;
3 use std::path::{Path, PathBuf};
4
5 /// Project hook files are executable configuration and must not become an
6 /// unbounded startup allocation merely because a trusted repository supplied
7 /// a very large file.
8 const PROJECT_HOOKS_FILE_MAX_BYTES: usize = 1024 * 1024;
9
10 fn read_project_hooks_file(path: &Path) -> std::io::Result<String> {
11 let file = std::fs::File::open(path)?;
12 let mut contents = String::new();
13 file.take((PROJECT_HOOKS_FILE_MAX_BYTES + 1) as u64)
14 .read_to_string(&mut contents)?;
15 if contents.len() > PROJECT_HOOKS_FILE_MAX_BYTES {
16 return Err(std::io::Error::new(
17 std::io::ErrorKind::InvalidData,
18 "project hooks file exceeds the 1 MiB limit",
19 ));
20 }
21 Ok(contents)
22 }
23
24 /// Events that can trigger hook execution
25 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26 #[serde(rename_all = "snake_case")]
27 pub enum HookEvent {
28 /// Triggered when a new session starts
29 SessionStart,
30 /// Triggered when a session ends (quit, Ctrl+C)
31 SessionEnd,
32 /// Triggered before a user message is sent to the LLM
33 MessageSubmit,
34 /// Triggered before a tool is executed
35 ToolCallBefore,
36 /// Triggered after a tool completes (success or failure)
37 ToolCallAfter,
38 /// Triggered when the user changes modes (Plan, Act, Operate)
39 ModeChange,
40 /// Triggered when an error occurs
41 OnError,
42 /// Triggered after a turn completes and post-turn state has been updated
43 TurnEnd,
44 /// Triggered when a sub-agent is spawned
45 SubagentSpawn,
46 /// Triggered when a sub-agent reaches a terminal state
47 SubagentComplete,
48 /// Triggered immediately before each `exec_shell` invocation. The hook's
49 /// stdout is parsed as `KEY=VALUE\n` lines and merged on top of the
50 /// shell command's environment — useful for ephemeral credentials,
51 /// per-skill PATH adjustments, or short-lived tokens (#456). Hooks that
52 /// fail or time out are logged but do *not* abort the shell call; they
53 /// simply contribute no env vars.
54 ShellEnv,
55 }
56
57 /// Every event name the runtime actually fires, in the order `/hooks events`
58 /// and `docs/HOOKS.md` list them. Tests assert this is exhaustive so a new
59 /// variant cannot ship without a documented firing point.
60 #[cfg(test)]
61 pub const ALL_HOOK_EVENTS: [HookEvent; 11] = [
62 HookEvent::SessionStart,
63 HookEvent::SessionEnd,
64 HookEvent::TurnEnd,
65 HookEvent::MessageSubmit,
66 HookEvent::ToolCallBefore,
67 HookEvent::ToolCallAfter,
68 HookEvent::ModeChange,
69 HookEvent::OnError,
70 HookEvent::SubagentSpawn,
71 HookEvent::SubagentComplete,
72 HookEvent::ShellEnv,
73 ];
74
75 /// How much a hook's result can change what Codewhale does next.
76 ///
77 /// This is the steering allowlist. "Observer" is a statement about
78 /// Codewhale's control flow only — an observer hook is still an arbitrary
79 /// shell command and can have any external side effect it likes.
80 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
81 pub enum HookSteering {
82 /// stdout/exit code can replace or block the submitted text.
83 TransformsSubmittedText,
84 /// stdout/exit code can allow, deny, ask, rewrite input, or add context.
85 DecidesToolCall,
86 /// stdout contributes `KEY=VALUE` pairs to one `exec_shell` invocation.
87 ContributesShellEnv,
88 /// stdout is ignored and the result cannot change Codewhale's behavior.
89 Observer,
90 }
91
92 impl HookEvent {
93 /// Get string representation for environment variable
94 #[allow(dead_code)] // Used in tests and future hook dispatch
95 pub fn as_str(self) -> &'static str {
96 match self {
97 HookEvent::SessionStart => "session_start",
98 HookEvent::SessionEnd => "session_end",
99 HookEvent::MessageSubmit => "message_submit",
100 HookEvent::ToolCallBefore => "tool_call_before",
101 HookEvent::ToolCallAfter => "tool_call_after",
102 HookEvent::ModeChange => "mode_change",
103 HookEvent::OnError => "on_error",
104 HookEvent::TurnEnd => "turn_end",
105 HookEvent::SubagentSpawn => "subagent_spawn",
106 HookEvent::SubagentComplete => "subagent_complete",
107 HookEvent::ShellEnv => "shell_env",
108 }
109 }
110
111 /// The steering contract for this event, as implemented.
112 #[must_use]
113 pub fn steering(self) -> HookSteering {
114 match self {
115 HookEvent::MessageSubmit => HookSteering::TransformsSubmittedText,
116 HookEvent::ToolCallBefore => HookSteering::DecidesToolCall,
117 HookEvent::ShellEnv => HookSteering::ContributesShellEnv,
118 HookEvent::SessionStart
119 | HookEvent::SessionEnd
120 | HookEvent::ToolCallAfter
121 | HookEvent::ModeChange
122 | HookEvent::OnError
123 | HookEvent::TurnEnd
124 | HookEvent::SubagentSpawn
125 | HookEvent::SubagentComplete => HookSteering::Observer,
126 }
127 }
128
129 /// Whether a hook result for this event can change Codewhale's own
130 /// behavior. Never read this as "side-effect free" — see [`HookSteering`].
131 #[must_use]
132 pub fn can_steer(self) -> bool {
133 !matches!(self.steering(), HookSteering::Observer)
134 }
135
136 /// Whether this event's context carries a tool name/arguments, so
137 /// `tool_name` / `tool_category` conditions can ever match.
138 ///
139 /// `on_error` is included because the tool-failure path in
140 /// `tui/tool_routing.rs` fires it with the tool name, call id, and result
141 /// attached. An `on_error` firing that has no tool behind it (a transport
142 /// or capacity error) simply does not match a tool predicate — it is
143 /// skipped at dispatch, not rejected at load.
144 #[must_use]
145 pub fn provides_tool_identity(self) -> bool {
146 matches!(
147 self,
148 HookEvent::ToolCallBefore
149 | HookEvent::ToolCallAfter
150 | HookEvent::ShellEnv
151 | HookEvent::OnError
152 )
153 }
154
155 /// Whether this event's context can carry a real process exit code, so
156 /// `exit_code` conditions can ever match. `tool_call_after` observes every
157 /// completed tool and `on_error` observes the failing ones; in both cases
158 /// the code is only present when the tool actually reported one
159 /// (`exec_shell` and friends).
160 #[must_use]
161 pub fn provides_exit_code(self) -> bool {
162 matches!(self, HookEvent::ToolCallAfter | HookEvent::OnError)
163 }
164
165 /// Whether this event's context carries a mode label, so `mode`
166 /// conditions can ever match. `shell_env` fires inside the `exec_shell`
167 /// tool with a deliberately narrow context and has no mode.
168 #[must_use]
169 pub fn provides_mode(self) -> bool {
170 !matches!(self, HookEvent::ShellEnv)
171 }
172
173 /// Whether `background = true` is honored as actual scheduling for this
174 /// event. Events whose result is part of the contract are always run in
175 /// the foreground, so declaring them background is a config error rather
176 /// than a scheduling choice.
177 #[must_use]
178 pub fn honors_background(self) -> bool {
179 !matches!(self, HookEvent::ShellEnv)
180 }
181 }
182
183 /// Condition for when a hook should run
184 #[derive(Debug, Clone, Serialize, Deserialize)]
185 #[serde(tag = "type", rename_all = "snake_case")]
186 #[derive(Default)]
187 pub enum HookCondition {
188 /// Always run this hook
189 #[default]
190 Always,
191 /// Only run for specific tool names
192 ToolName {
193 /// Tool name to match (e.g., "`exec_shell`", "`write_file`")
194 name: String,
195 },
196 /// Only run for specific tool categories
197 ToolCategory {
198 /// Category: "safe", "`file_write`", "shell"
199 category: String,
200 },
201 /// Only run in specific modes
202 Mode {
203 /// Mode: "plan", "agent", "yolo"
204 mode: String,
205 },
206 /// Only run when exit code matches (for `ToolCallAfter` / `OnError`)
207 ExitCode {
208 /// Exit code to match.
209 ///
210 /// `i64`, not `i32`: a Windows crash code such as `3221225477`
211 /// (`0xC0000005`, access violation) is a real code a shell tool
212 /// reports, and narrowing it would silently turn the predicate into
213 /// one that can never match.
214 code: i64,
215 },
216 /// Combine multiple conditions with AND
217 All { conditions: Vec<HookCondition> },
218 /// Combine multiple conditions with OR
219 Any { conditions: Vec<HookCondition> },
220 }
221
222 /// A single hook definition
223 #[derive(Debug, Clone, Serialize, Deserialize)]
224 pub struct Hook {
225 /// The event that triggers this hook
226 pub event: HookEvent,
227
228 /// Shell command to execute (platform shell: `sh -c` on Unix, `cmd /C` on Windows)
229 pub command: String,
230
231 /// Optional condition for when this hook should run
232 #[serde(default)]
233 pub condition: Option<HookCondition>,
234
235 /// Timeout in seconds (default: 30)
236 #[serde(default = "default_timeout")]
237 pub timeout_secs: u64,
238
239 /// Run in background (don't wait for completion)
240 #[serde(default)]
241 pub background: bool,
242
243 /// Continue if this hook fails (default: true)
244 #[serde(default = "default_continue_on_error")]
245 pub continue_on_error: bool,
246
247 /// Optional name for logging/debugging
248 #[serde(default)]
249 pub name: Option<String>,
250 }
251
252 fn default_timeout() -> u64 {
253 30
254 }
255
256 fn default_continue_on_error() -> bool {
257 true
258 }
259
260 impl Hook {
261 /// Create a new hook with minimal configuration
262 #[allow(dead_code)] // Public builder API, used in tests
263 pub fn new(event: HookEvent, command: &str) -> Self {
264 Self {
265 event,
266 command: command.to_string(),
267 condition: None,
268 timeout_secs: 30,
269 background: false,
270 continue_on_error: true,
271 name: None,
272 }
273 }
274
275 /// Builder: set condition
276 #[allow(dead_code)] // Public builder API, used in tests
277 pub fn with_condition(mut self, condition: HookCondition) -> Self {
278 self.condition = Some(condition);
279 self
280 }
281
282 /// Builder: set timeout
283 #[allow(dead_code)] // Public builder API, used in tests
284 pub fn with_timeout(mut self, secs: u64) -> Self {
285 self.timeout_secs = secs;
286 self
287 }
288
289 /// Builder: run in background
290 #[allow(dead_code)] // Public builder API, used in tests
291 pub fn background(mut self) -> Self {
292 self.background = true;
293 self
294 }
295
296 /// Builder: set name
297 #[allow(dead_code)] // Public builder API, used in tests
298 pub fn with_name(mut self, name: &str) -> Self {
299 self.name = Some(name.to_string());
300 self
301 }
302 }
303
304 /// A configured hook that can never behave the way it is written.
305 ///
306 /// Reported by [`HooksConfig::validate`] and, for rejections, surfaced in
307 /// `/hooks list` so a broken hook is visible instead of silently inert.
308 #[derive(Debug, Clone, PartialEq, Eq)]
309 pub struct HookConfigProblem {
310 /// Hook `name`, or `None` for an unnamed entry.
311 pub name: Option<String>,
312 /// The event the hook is registered for, or `None` when the problem is
313 /// with a setting in the `[hooks]` table itself rather than with one
314 /// entry — `default_timeout_secs` governs every hook, so pinning its
315 /// rejection on an arbitrary hook would misreport the blast radius.
316 pub event: Option<HookEvent>,
317 /// What is wrong, in one line, with no paths or payload content.
318 pub detail: String,
319 /// `true` when the hook is dropped at load and will never run.
320 pub rejected: bool,
321 }
322
323 impl HookConfigProblem {
324 /// Stable, redaction-safe one-line rendering for logs and `/hooks`.
325 #[must_use]
326 pub fn summary(&self) -> String {
327 let disposition = if self.rejected { "rejected" } else { "warning" };
328 let Some(event) = self.event else {
329 return format!("{disposition}: `[hooks]` setting — {}", self.detail);
330 };
331 // The name is operator-supplied and lands in `/hooks list` and the
332 // tracing stream; bound it and strip control characters here rather
333 // than trusting every caller to remember.
334 let label = super::executor::sanitize_hook_label(self.name.as_deref());
335 format!(
336 "{disposition}: `{}` hook `{label}` — {}",
337 event.as_str(),
338 self.detail
339 )
340 }
341 }
342
343 /// Configuration for hooks (loaded from config.toml)
344 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
345 pub struct HooksConfig {
346 /// List of hooks to execute
347 #[serde(default)]
348 pub hooks: Vec<Hook>,
349
350 /// Global enable/disable for all hooks
351 #[serde(default = "default_enabled")]
352 pub enabled: bool,
353
354 /// Global timeout override. When set this **replaces** every hook's own
355 /// `timeout_secs` rather than only filling in for hooks that omit one —
356 /// see `HookExecutor::execute_sync_inner`. Documented as-implemented in
357 /// `docs/HOOKS.md`; leave unset for per-hook timeouts.
358 #[serde(default)]
359 pub default_timeout_secs: Option<u64>,
360
361 /// Working directory for hook execution (default: workspace)
362 #[serde(default)]
363 pub working_dir: Option<PathBuf>,
364
365 /// Problems found by [`HooksConfig::validate`] at load time. Never read
366 /// from or written to `config.toml`; populated by
367 /// [`HooksConfig::load_with_project`] so `/hooks` can show a rejected
368 /// hook instead of leaving it silently inert.
369 #[serde(skip)]
370 pub problems: Vec<HookConfigProblem>,
371 }
372
373 fn default_enabled() -> bool {
374 true
375 }
376
377 impl HooksConfig {
378 /// Load global hooks merged with project-local `.codewhale/hooks.toml` (#3026).
379 ///
380 /// Project hooks are executable repository configuration, so they are only
381 /// honored after the workspace has been trusted in user-owned config.
382 /// Trusted project hooks are appended after global hooks. A malformed
383 /// trusted project file logs a warning and falls back to global-only.
384 pub fn load_with_project(global: HooksConfig, workspace: &Path) -> HooksConfig {
385 let mut merged = global;
386 let project_path = workspace.join(".codewhale").join("hooks.toml");
387 if project_path.exists() && workspace_allows_project_hooks(workspace) {
388 match read_project_hooks_file(&project_path) {
389 Ok(contents) => match toml::from_str::<HooksConfig>(&contents) {
390 Ok(project) => merged.hooks.extend(project.hooks),
391 Err(e) => tracing::warn!(
392 "Failed to parse project hooks at {}: {e}; falling back to global hooks only",
393 project_path.display()
394 ),
395 },
396 Err(e) => tracing::warn!(
397 "Failed to read project hooks at {}: {e}; falling back to global hooks only",
398 project_path.display()
399 ),
400 }
401 }
402 // Validation runs on every path, not just the project-hooks path, so a
403 // globally-configured hook that can never match is rejected too.
404 merged.apply_validation();
405 merged
406 }
407
408 /// Report every configured hook that cannot behave as written.
409 ///
410 /// A condition that references context the event never carries can never
411 /// match, so a hook wearing one is inert — the dangerous version of that
412 /// is a `deny` gate the operator believes is armed. Those are reported as
413 /// `rejected` and dropped by [`Self::apply_validation`] rather than left
414 /// to fail silently at dispatch time. Problems that only affect how a
415 /// hook is scheduled are reported as warnings and the hook still runs.
416 #[must_use]
417 pub fn validate(&self) -> Vec<HookConfigProblem> {
418 self.validate_settings()
419 .into_iter()
420 .chain(self.validate_indexed().into_iter().map(|(_, p)| p))
421 .collect()
422 }
423
424 /// Problems with the `[hooks]` table itself, independent of any entry.
425 ///
426 /// `default_timeout_secs = 0` is the one that matters: it *replaces* every
427 /// hook's own `timeout_secs`, so the per-hook `timeout_secs = 0` rejection
428 /// does nothing to stop one line from killing every hook in the config
429 /// before it can produce output — including a `tool_call_before` gate,
430 /// which then fails closed on every tool call.
431 fn validate_settings(&self) -> Vec<HookConfigProblem> {
432 let mut problems = Vec::new();
433 if self.default_timeout_secs == Some(0) {
434 problems.push(HookConfigProblem {
435 name: None,
436 event: None,
437 detail: "`default_timeout_secs = 0` would expire every hook immediately; \
438 the override is ignored and per-hook `timeout_secs` applies"
439 .to_string(),
440 rejected: true,
441 });
442 }
443 problems
444 }
445
446 /// [`Self::validate`], but each problem is paired with the index of the
447 /// entry that produced it.
448 ///
449 /// The index is the hook's identity for rejection purposes. Keying on
450 /// `(name, event)` instead would make one invalid unnamed `session_start`
451 /// entry delete *every* unnamed `session_start` entry, and one invalid
452 /// `gate` delete every other hook also called `gate` — innocent hooks
453 /// dropped because they share a label with a broken one.
454 fn validate_indexed(&self) -> Vec<(usize, HookConfigProblem)> {
455 let mut problems = Vec::new();
456 for (index, hook) in self.hooks.iter().enumerate() {
457 let mut condition_rejections = Vec::new();
458 collect_condition_problems(hook.event, hook.condition.as_ref(), &mut |detail| {
459 condition_rejections.push(detail);
460 });
461
462 let mut push = |detail: String, rejected: bool| {
463 problems.push((
464 index,
465 HookConfigProblem {
466 name: hook.name.clone(),
467 event: Some(hook.event),
468 detail,
469 rejected,
470 },
471 ));
472 };
473 // A condition that can never match makes the hook inert, so it is
474 // dropped; the rest only affect how the hook is scheduled, so the
475 // hook still runs and the problem is a warning.
476 for detail in condition_rejections {
477 push(detail, true);
478 }
479
480 if hook.background && !hook.event.honors_background() {
481 push(
482 format!(
483 "`background = true` is not honored for `{}`; its stdout is the \
484 contract, so it always runs in the foreground",
485 hook.event.as_str()
486 ),
487 false,
488 );
489 } else if hook.background && hook.event.can_steer() {
490 push(
491 format!(
492 "`background = true` makes this `{}` hook observer-only — it is \
493 submitted and never awaited, so it cannot steer the turn",
494 hook.event.as_str()
495 ),
496 false,
497 );
498 }
499
500 if hook.timeout_secs == 0 {
501 push(
502 "`timeout_secs = 0` expires immediately; the command is killed \
503 before it can produce output"
504 .to_string(),
505 true,
506 );
507 }
508
509 if hook.command.trim().is_empty() {
510 push("`command` is empty".to_string(), true);
511 }
512 }
513 problems
514 }
515
516 /// Run [`Self::validate_indexed`], drop every rejected hook, and record the
517 /// problems so `/hooks` and the logs can show them.
518 ///
519 /// Rejection is by position, so a broken entry never takes an innocent one
520 /// with it just because the two share a name (or share the absence of one).
521 fn apply_validation(&mut self) {
522 let setting_problems = self.validate_settings();
523 // Reject the value, not just report it: the executor reads
524 // `default_timeout_secs` directly, so leaving `Some(0)` in place would
525 // make the warning cosmetic.
526 if setting_problems.iter().any(|p| p.rejected) {
527 self.default_timeout_secs = self.default_timeout_secs.filter(|secs| *secs > 0);
528 }
529 let problems = self.validate_indexed();
530 for problem in setting_problems
531 .iter()
532 .chain(problems.iter().map(|(_, p)| p))
533 {
534 tracing::warn!(target: "hooks", "{}", problem.summary());
535 }
536 let rejected: std::collections::HashSet<usize> = problems
537 .iter()
538 .filter(|(_, problem)| problem.rejected)
539 .map(|(index, _)| *index)
540 .collect();
541 if !rejected.is_empty() {
542 let mut index = 0;
543 self.hooks.retain(|_| {
544 let keep = !rejected.contains(&index);
545 index += 1;
546 keep
547 });
548 }
549 self.problems = setting_problems
550 .into_iter()
551 .chain(problems.into_iter().map(|(_, problem)| problem))
552 .collect();
553 }
554
555 /// Get hooks for a specific event
556 pub fn hooks_for_event(&self, event: HookEvent) -> Vec<&Hook> {
557 if !self.enabled {
558 return Vec::new();
559 }
560 self.hooks.iter().filter(|h| h.event == event).collect()
561 }
562
563 /// Check if hooks are configured and enabled
564 #[allow(dead_code)] // Public API for hook system consumers
565 pub fn has_hooks(&self) -> bool {
566 self.enabled && !self.hooks.is_empty()
567 }
568
569 /// The timeout the runtime will actually apply to `hook`.
570 ///
571 /// `[hooks].default_timeout_secs` *replaces* the per-hook value when set.
572 /// This is the single owner of that rule: the executor enforces it and
573 /// `/hooks list` displays it, so the listing cannot advertise a per-hook
574 /// number the runtime will not use.
575 #[must_use]
576 pub fn effective_timeout_secs(&self, hook: &Hook) -> u64 {
577 // `filter`, not `unwrap_or`: `apply_validation` already strips a zero
578 // override at load, but a `HooksConfig` can also be built in code, and
579 // a zero here means "kill every hook before it speaks".
580 self.default_timeout_secs
581 .filter(|secs| *secs > 0)
582 .unwrap_or(hook.timeout_secs)
583 }
584
585 /// `true` when `[hooks].default_timeout_secs` is overriding per-hook
586 /// timeouts, so surfaces can name the provenance of the number they show.
587 #[must_use]
588 pub fn timeout_is_overridden(&self) -> bool {
589 // An ignored zero override is not provenance: `/hooks list` must not
590 // credit a number to a setting the runtime refused to apply.
591 self.default_timeout_secs.is_some_and(|secs| secs > 0)
592 }
593 }
594
595 fn workspace_allows_project_hooks(workspace: &Path) -> bool {
596 crate::config::is_workspace_trusted(workspace)
597 }
598
599 /// Walk a condition tree and report every predicate the event can never
600 /// satisfy. `all` / `any` are walked so a nested unsupported predicate is
601 /// caught rather than hidden behind a combinator.
602 fn collect_condition_problems(
603 event: HookEvent,
604 condition: Option<&HookCondition>,
605 reject: &mut impl FnMut(String),
606 ) {
607 let Some(condition) = condition else {
608 return;
609 };
610 match condition {
611 HookCondition::Always => {}
612 HookCondition::ToolName { .. } | HookCondition::ToolCategory { .. } => {
613 if !event.provides_tool_identity() {
614 reject(format!(
615 "`{}` never carries a tool name, so this tool condition can never match",
616 event.as_str()
617 ));
618 }
619 }
620 HookCondition::Mode { .. } => {
621 if !event.provides_mode() {
622 reject(format!(
623 "`{}` runs with a narrow context that has no mode, so a `mode` \
624 condition can never match; scope it with `tool_name` or \
625 `tool_category` instead",
626 event.as_str()
627 ));
628 }
629 }
630 HookCondition::ExitCode { .. } => {
631 if !event.provides_exit_code() {
632 reject(format!(
633 "`{}` has no completed process to read an exit code from; \
634 `exit_code` conditions are only supported on `tool_call_after` \
635 and `on_error`",
636 event.as_str()
637 ));
638 }
639 }
640 HookCondition::All { conditions } | HookCondition::Any { conditions } => {
641 for nested in conditions {
642 // Reborrow rather than pass `reject` itself: `&mut F` also
643 // implements `FnMut`, so passing it directly would recurse in
644 // the type parameter and never finish monomorphizing.
645 collect_condition_problems(event, Some(nested), &mut *reject);
646 }
647 }
648 }
649 }
650
651 #[cfg(test)]
652 mod contract_tests {
653 use super::*;
654
655 /// The eleven event names are a public contract: they appear in
656 /// `config.toml`, in `/hooks events`, and in `docs/HOOKS.md`. A rename is
657 /// a breaking change, and a new variant must be added deliberately.
658 #[test]
659 fn all_eleven_event_names_are_stable_and_exhaustive() {
660 let names: Vec<&str> = ALL_HOOK_EVENTS.iter().map(|e| e.as_str()).collect();
661 assert_eq!(
662 names,
663 vec![
664 "session_start",
665 "session_end",
666 "turn_end",
667 "message_submit",
668 "tool_call_before",
669 "tool_call_after",
670 "mode_change",
671 "on_error",
672 "subagent_spawn",
673 "subagent_complete",
674 "shell_env",
675 ]
676 );
677
678 // Exhaustiveness: every variant appears exactly once. The `match` here
679 // fails to compile if a variant is added without updating the list.
680 for event in ALL_HOOK_EVENTS {
681 let covered = match event {
682 HookEvent::SessionStart
683 | HookEvent::SessionEnd
684 | HookEvent::TurnEnd
685 | HookEvent::MessageSubmit
686 | HookEvent::ToolCallBefore
687 | HookEvent::ToolCallAfter
688 | HookEvent::ModeChange
689 | HookEvent::OnError
690 | HookEvent::SubagentSpawn
691 | HookEvent::SubagentComplete
692 | HookEvent::ShellEnv => true,
693 };
694 assert!(covered);
695 }
696 let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
697 assert_eq!(unique.len(), 11);
698 }
699
700 /// Serde round-trip for every event name, in the exact `event = "..."`
701 /// spelling users write in `config.toml`.
702 #[test]
703 fn every_event_name_round_trips_through_serde() {
704 for event in ALL_HOOK_EVENTS {
705 let json = serde_json::to_string(&event).expect("serialize");
706 assert_eq!(json, format!("\"{}\"", event.as_str()));
707 let parsed: HookEvent = serde_json::from_str(&json).expect("deserialize");
708 assert_eq!(parsed, event);
709
710 let toml_src = format!("event = \"{}\"\ncommand = \"true\"\n", event.as_str());
711 let hook: Hook = toml::from_str(&toml_src).expect("hook parses from minimal toml");
712 assert_eq!(hook.event, event);
713 }
714 }
715
716 /// Backward compatibility: a pre-existing hook table with only the two
717 /// required keys still parses, and the defaults are the documented ones.
718 #[test]
719 fn minimal_hook_toml_keeps_its_documented_defaults() {
720 let hook: Hook = toml::from_str(
721 r#"
722 event = "session_start"
723 command = "echo hi"
724 "#,
725 )
726 .expect("parse");
727 assert_eq!(hook.timeout_secs, 30);
728 assert!(!hook.background);
729 assert!(hook.continue_on_error);
730 assert!(hook.condition.is_none());
731 assert!(hook.name.is_none());
732 }
733
734 /// `problems` is runtime-only state. It must never appear in a serialized
735 /// config, and its absence must not break deserialization.
736 #[test]
737 fn config_problems_are_not_part_of_the_serialized_config() {
738 let config = HooksConfig {
739 enabled: true,
740 hooks: vec![Hook::new(HookEvent::SessionStart, "true")],
741 problems: vec![HookConfigProblem {
742 name: Some("x".to_string()),
743 event: Some(HookEvent::SessionStart),
744 detail: "example".to_string(),
745 rejected: true,
746 }],
747 ..HooksConfig::default()
748 };
749 let serialized = serde_json::to_string(&config).expect("serialize");
750 assert!(!serialized.contains("problems"), "{serialized}");
751 assert!(!serialized.contains("example"), "{serialized}");
752
753 let reparsed: HooksConfig = serde_json::from_str(&serialized).expect("reparse");
754 assert!(reparsed.problems.is_empty());
755 assert_eq!(reparsed.hooks.len(), 1);
756
757 // And a config that predates the field still deserializes.
758 let legacy: HooksConfig = toml::from_str(
759 r#"
760 enabled = true
761
762 [[hooks]]
763 event = "session_start"
764 command = "echo hi"
765 "#,
766 )
767 .expect("legacy config parses");
768 assert!(legacy.problems.is_empty());
769 assert_eq!(legacy.hooks.len(), 1);
770 }
771
772 /// The steering allowlist. Exactly three events can change what Codewhale
773 /// does; every other event defaults to observer.
774 #[test]
775 fn steering_allowlist_is_exactly_three_events() {
776 let steering: Vec<&str> = ALL_HOOK_EVENTS
777 .iter()
778 .filter(|e| e.can_steer())
779 .map(|e| e.as_str())
780 .collect();
781 assert_eq!(
782 steering,
783 vec!["message_submit", "tool_call_before", "shell_env"]
784 );
785
786 assert_eq!(
787 HookEvent::MessageSubmit.steering(),
788 HookSteering::TransformsSubmittedText
789 );
790 assert_eq!(
791 HookEvent::ToolCallBefore.steering(),
792 HookSteering::DecidesToolCall
793 );
794 assert_eq!(
795 HookEvent::ShellEnv.steering(),
796 HookSteering::ContributesShellEnv
797 );
798
799 for event in ALL_HOOK_EVENTS {
800 if !steering.contains(&event.as_str()) {
801 assert_eq!(
802 event.steering(),
803 HookSteering::Observer,
804 "`{}` must default to observer",
805 event.as_str()
806 );
807 }
808 }
809 }
810
811 /// Observer-only is a claim about Codewhale's control flow, not about the
812 /// command. This test exists so the distinction is written down somewhere
813 /// executable: an observer hook is still an arbitrary shell command and
814 /// its external side effects are entirely real.
815 #[test]
816 fn observer_only_still_runs_a_real_command_with_real_side_effects() {
817 let dir = tempfile::tempdir().expect("tempdir");
818 let marker = dir.path().join("observer-side-effect.txt");
819 assert!(!marker.exists());
820
821 let command = if cfg!(windows) {
822 format!("echo touched> {}", marker.display())
823 } else {
824 format!("echo touched > {}", marker.display())
825 };
826 let executor = crate::hooks::HookExecutor::new(
827 HooksConfig {
828 enabled: true,
829 hooks: vec![Hook::new(HookEvent::SessionEnd, &command).with_name("observer")],
830 ..HooksConfig::default()
831 },
832 dir.path().to_path_buf(),
833 );
834
835 let results = executor.execute(
836 HookEvent::SessionEnd,
837 &crate::hooks::HookContext::new().with_session_id("sess_test"),
838 );
839
840 // Codewhale ignored the result...
841 assert_eq!(results.len(), 1);
842 assert_eq!(HookEvent::SessionEnd.steering(), HookSteering::Observer);
843 // ...and the command still changed the filesystem.
844 assert!(
845 marker.exists(),
846 "an observer hook is not side-effect free; it just cannot steer"
847 );
848 }
849
850 #[test]
851 fn exit_code_conditions_are_rejected_only_where_no_exit_code_exists() {
852 for event in ALL_HOOK_EVENTS {
853 let config = HooksConfig {
854 enabled: true,
855 hooks: vec![
856 Hook::new(event, "true")
857 .with_name("gate")
858 .with_condition(HookCondition::ExitCode { code: 1 }),
859 ],
860 ..HooksConfig::default()
861 };
862 let problems = config.validate();
863 if event.provides_exit_code() {
864 assert!(
865 problems.is_empty(),
866 "`{}` should accept an exit_code condition: {problems:?}",
867 event.as_str()
868 );
869 } else {
870 assert!(
871 problems.iter().any(|p| p.rejected),
872 "`{}` must reject an exit_code condition",
873 event.as_str()
874 );
875 }
876 }
877 assert!(HookEvent::ToolCallAfter.provides_exit_code());
878 // `on_error` fires for tool failures with the tool name, call id, and
879 // reported exit code attached (`tui/tool_routing.rs`), so scoping an
880 // `on_error` hook by tool or exit code is a supported configuration —
881 // it used to be rejected at load while the runtime and docs both
882 // promised those fields.
883 assert!(HookEvent::OnError.provides_exit_code());
884 assert!(HookEvent::OnError.provides_tool_identity());
885 }
886
887 /// A tool-scoped `on_error` hook — the shape `docs/HOOKS.md` documents and
888 /// `tool_routing.rs` supplies context for — must survive load intact.
889 #[test]
890 fn tool_scoped_on_error_hooks_load_and_dispatch() {
891 let dir = tempfile::tempdir().expect("tempdir");
892 let global = HooksConfig {
893 enabled: true,
894 hooks: vec![
895 Hook::new(HookEvent::OnError, "notify.sh")
896 .with_name("shell-failure")
897 .with_condition(HookCondition::All {
898 conditions: vec![
899 HookCondition::ToolName {
900 name: "exec_shell".to_string(),
901 },
902 HookCondition::ExitCode { code: 127 },
903 ],
904 }),
905 ],
906 ..HooksConfig::default()
907 };
908
909 let loaded = HooksConfig::load_with_project(global, dir.path());
910
911 assert_eq!(loaded.hooks.len(), 1, "{:?}", loaded.problems);
912 assert!(
913 loaded.problems.iter().all(|p| !p.rejected),
914 "{:?}",
915 loaded.problems
916 );
917 assert_eq!(loaded.hooks_for_event(HookEvent::OnError).len(), 1);
918 }
919
920 /// A Windows crash code such as `0xC0000005` does not fit in `i32`. The
921 /// predicate has to hold it, or the hook silently never matches.
922 #[test]
923 fn exit_code_conditions_hold_large_windows_crash_codes() {
924 let hook: Hook = toml::from_str(
925 r#"
926 event = "tool_call_after"
927 command = "echo crashed"
928 condition = { type = "exit_code", code = 3221225477 }
929 "#,
930 )
931 .expect("large exit code parses");
932 assert!(matches!(
933 hook.condition,
934 Some(HookCondition::ExitCode {
935 code: 3_221_225_477
936 })
937 ));
938
939 // Old, small values keep parsing exactly as before.
940 let legacy: Hook = toml::from_str(
941 r#"
942 event = "tool_call_after"
943 command = "echo failed"
944 condition = { type = "exit_code", code = 1 }
945 "#,
946 )
947 .expect("small exit code still parses");
948 assert!(matches!(
949 legacy.condition,
950 Some(HookCondition::ExitCode { code: 1 })
951 ));
952 }
953
954 /// Rejection is per entry. One broken hook must not delete the hooks that
955 /// merely share its name — or share its lack of one.
956 #[test]
957 fn rejection_drops_only_the_offending_entry() {
958 let dir = tempfile::tempdir().expect("tempdir");
959 let global = HooksConfig {
960 enabled: true,
961 hooks: vec![
962 // Two unnamed `session_start` entries; only the second is
963 // invalid (an `exit_code` predicate that can never match).
964 Hook::new(HookEvent::SessionStart, "echo innocent-unnamed"),
965 Hook::new(HookEvent::SessionStart, "echo broken-unnamed")
966 .with_condition(HookCondition::ExitCode { code: 0 }),
967 // Two hooks sharing the name `gate`; only the second is empty.
968 Hook::new(HookEvent::ToolCallBefore, "echo innocent-gate").with_name("gate"),
969 Hook::new(HookEvent::ToolCallBefore, " ").with_name("gate"),
970 ],
971 ..HooksConfig::default()
972 };
973
974 let loaded = HooksConfig::load_with_project(global, dir.path());
975
976 let surviving: Vec<&str> = loaded.hooks.iter().map(|h| h.command.as_str()).collect();
977 assert_eq!(
978 surviving,
979 vec!["echo innocent-unnamed", "echo innocent-gate"],
980 "an invalid entry took an innocent same-identity entry with it"
981 );
982 assert_eq!(
983 loaded.problems.iter().filter(|p| p.rejected).count(),
984 2,
985 "{:?}",
986 loaded.problems
987 );
988 assert_eq!(loaded.hooks_for_event(HookEvent::SessionStart).len(), 1);
989 assert_eq!(loaded.hooks_for_event(HookEvent::ToolCallBefore).len(), 1);
990 }
991
992 #[test]
993 fn effective_timeout_reports_the_global_override() {
994 let hook = Hook::new(HookEvent::SessionStart, "true").with_timeout(90);
995
996 let per_hook = HooksConfig::default();
997 assert_eq!(per_hook.effective_timeout_secs(&hook), 90);
998 assert!(!per_hook.timeout_is_overridden());
999
1000 let overridden = HooksConfig {
1001 default_timeout_secs: Some(5),
1002 ..HooksConfig::default()
1003 };
1004 assert_eq!(overridden.effective_timeout_secs(&hook), 5);
1005 assert!(overridden.timeout_is_overridden());
1006 }
1007
1008 /// `timeout_secs = 0` was rejected per hook, but the override that
1009 /// *replaces* every hook's value was not checked at all — so a single
1010 /// `default_timeout_secs = 0` killed every hook before it could speak,
1011 /// including `tool_call_before` gates that then fail closed on every call.
1012 #[test]
1013 fn zero_default_timeout_is_rejected_and_ignored() {
1014 let hook = Hook::new(HookEvent::SessionStart, "true").with_timeout(90);
1015 let zeroed = HooksConfig {
1016 enabled: true,
1017 hooks: vec![hook.clone()],
1018 default_timeout_secs: Some(0),
1019 ..HooksConfig::default()
1020 };
1021
1022 let problems = zeroed.validate();
1023 let problem = problems
1024 .iter()
1025 .find(|p| p.detail.contains("default_timeout_secs"))
1026 .expect("zero override reported");
1027 assert!(problem.rejected, "{problem:?}");
1028 assert!(
1029 problem.event.is_none(),
1030 "the override is not one hook's problem: {problem:?}"
1031 );
1032 assert!(problem.summary().contains("`[hooks]` setting"));
1033
1034 // Even unvalidated, the accessors refuse the value rather than hand a
1035 // zero budget to the executor.
1036 assert_eq!(zeroed.effective_timeout_secs(&hook), 90);
1037 assert!(!zeroed.timeout_is_overridden());
1038 }
1039
1040 /// The load path must strip the value, not merely warn about it: the
1041 /// executor reads `default_timeout_secs` and the hook itself is innocent,
1042 /// so it has to survive.
1043 #[test]
1044 fn zero_default_timeout_is_stripped_at_load_and_the_hook_survives() {
1045 let dir = tempfile::tempdir().expect("tempdir");
1046 let global = HooksConfig {
1047 enabled: true,
1048 hooks: vec![
1049 Hook::new(HookEvent::SessionStart, "true")
1050 .with_name("greet")
1051 .with_timeout(90),
1052 ],
1053 default_timeout_secs: Some(0),
1054 ..HooksConfig::default()
1055 };
1056
1057 let loaded = HooksConfig::load_with_project(global, dir.path());
1058 assert_eq!(loaded.default_timeout_secs, None, "override not stripped");
1059 assert_eq!(loaded.hooks.len(), 1, "the hook itself was not at fault");
1060 assert_eq!(loaded.effective_timeout_secs(&loaded.hooks[0]), 90);
1061 assert!(
1062 loaded
1063 .problems
1064 .iter()
1065 .any(|p| p.rejected && p.event.is_none()),
1066 "{:?}",
1067 loaded.problems
1068 );
1069 }
1070
1071 /// A positive override still loads untouched — the rejection is for zero
1072 /// only, not a general distrust of the setting.
1073 #[test]
1074 fn positive_default_timeout_survives_load() {
1075 let dir = tempfile::tempdir().expect("tempdir");
1076 let loaded = HooksConfig::load_with_project(
1077 HooksConfig {
1078 enabled: true,
1079 hooks: vec![Hook::new(HookEvent::SessionStart, "true").with_timeout(90)],
1080 default_timeout_secs: Some(5),
1081 ..HooksConfig::default()
1082 },
1083 dir.path(),
1084 );
1085 assert_eq!(loaded.default_timeout_secs, Some(5));
1086 assert!(loaded.timeout_is_overridden());
1087 assert_eq!(loaded.effective_timeout_secs(&loaded.hooks[0]), 5);
1088 assert!(loaded.problems.is_empty(), "{:?}", loaded.problems);
1089 }
1090
1091 /// Names are operator text and reach `/hooks list` and the tracing stream.
1092 #[test]
1093 fn problem_summaries_bound_and_defang_the_hook_name() {
1094 let problem = HookConfigProblem {
1095 name: Some(format!("\u{1b}[2Jgate\n{}", "n".repeat(500))),
1096 event: Some(HookEvent::ToolCallBefore),
1097 detail: "example detail".to_string(),
1098 rejected: true,
1099 };
1100 let summary = problem.summary();
1101 assert!(!summary.contains('\u{1b}'), "{summary}");
1102 assert!(!summary.contains('\n'), "{summary}");
1103 assert!(summary.contains("gate"), "{summary}");
1104 assert!(
1105 summary.chars().count() < 200,
1106 "unbounded summary: {} chars",
1107 summary.chars().count()
1108 );
1109 }
1110
1111 #[test]
1112 fn mode_conditions_are_rejected_on_shell_env_only() {
1113 for event in ALL_HOOK_EVENTS {
1114 let config = HooksConfig {
1115 enabled: true,
1116 hooks: vec![
1117 Hook::new(event, "true").with_condition(HookCondition::Mode {
1118 mode: "plan".to_string(),
1119 }),
1120 ],
1121 ..HooksConfig::default()
1122 };
1123 let rejected = config.validate().iter().any(|p| p.rejected);
1124 assert_eq!(
1125 rejected,
1126 matches!(event, HookEvent::ShellEnv),
1127 "unexpected mode-condition disposition for `{}`",
1128 event.as_str()
1129 );
1130 }
1131 }
1132
1133 #[test]
1134 fn tool_conditions_are_rejected_on_events_with_no_tool() {
1135 for event in ALL_HOOK_EVENTS {
1136 for condition in [
1137 HookCondition::ToolName {
1138 name: "exec_shell".to_string(),
1139 },
1140 HookCondition::ToolCategory {
1141 category: "shell".to_string(),
1142 },
1143 ] {
1144 let config = HooksConfig {
1145 enabled: true,
1146 hooks: vec![Hook::new(event, "true").with_condition(condition)],
1147 ..HooksConfig::default()
1148 };
1149 let rejected = config.validate().iter().any(|p| p.rejected);
1150 assert_eq!(
1151 rejected,
1152 !event.provides_tool_identity(),
1153 "unexpected tool-condition disposition for `{}`",
1154 event.as_str()
1155 );
1156 }
1157 }
1158 }
1159
1160 #[test]
1161 fn unsupported_conditions_nested_in_combinators_are_still_rejected() {
1162 let config = HooksConfig {
1163 enabled: true,
1164 hooks: vec![
1165 Hook::new(HookEvent::SessionStart, "true")
1166 .with_name("sneaky")
1167 .with_condition(HookCondition::Any {
1168 conditions: vec![
1169 HookCondition::Always,
1170 HookCondition::All {
1171 conditions: vec![HookCondition::ExitCode { code: 0 }],
1172 },
1173 ],
1174 }),
1175 ],
1176 ..HooksConfig::default()
1177 };
1178 let problems = config.validate();
1179 assert!(
1180 problems.iter().any(|p| p.rejected),
1181 "a nested unsupported predicate must not hide behind a combinator"
1182 );
1183 }
1184
1185 #[test]
1186 fn rejected_hooks_are_dropped_at_load_and_reported() {
1187 let dir = tempfile::tempdir().expect("tempdir");
1188 let global = HooksConfig {
1189 enabled: true,
1190 hooks: vec![
1191 Hook::new(HookEvent::SessionStart, "echo ok").with_name("good"),
1192 Hook::new(HookEvent::SessionStart, "echo never")
1193 .with_name("inert")
1194 .with_condition(HookCondition::ExitCode { code: 0 }),
1195 ],
1196 ..HooksConfig::default()
1197 };
1198
1199 let loaded = HooksConfig::load_with_project(global, dir.path());
1200
1201 assert_eq!(
1202 loaded.hooks.len(),
1203 1,
1204 "the inert hook must not survive load"
1205 );
1206 assert_eq!(loaded.hooks[0].name.as_deref(), Some("good"));
1207 assert!(loaded.problems.iter().any(|p| p.rejected));
1208 // It is also invisible to dispatch, not merely to the listing.
1209 assert_eq!(loaded.hooks_for_event(HookEvent::SessionStart).len(), 1);
1210 }
1211
1212 #[test]
1213 fn empty_command_and_zero_timeout_are_rejected() {
1214 let config = HooksConfig {
1215 enabled: true,
1216 hooks: vec![
1217 Hook::new(HookEvent::SessionStart, " ").with_name("blank"),
1218 Hook::new(HookEvent::SessionEnd, "true")
1219 .with_name("instant")
1220 .with_timeout(0),
1221 ],
1222 ..HooksConfig::default()
1223 };
1224 let problems = config.validate();
1225 assert_eq!(problems.iter().filter(|p| p.rejected).count(), 2);
1226 }
1227
1228 #[test]
1229 fn background_flag_truth_is_reported_per_event() {
1230 // `shell_env` does not honor the flag at all — that is a warning, and
1231 // the hook still runs.
1232 let shell_env = HooksConfig {
1233 enabled: true,
1234 hooks: vec![
1235 Hook::new(HookEvent::ShellEnv, "true")
1236 .with_name("creds")
1237 .background(),
1238 ],
1239 ..HooksConfig::default()
1240 };
1241 let problems = shell_env.validate();
1242 assert_eq!(problems.len(), 1);
1243 assert!(!problems[0].rejected, "the hook still runs, in foreground");
1244 assert!(problems[0].detail.contains("not honored"));
1245 assert!(!HookEvent::ShellEnv.honors_background());
1246
1247 // A background steering hook is honored scheduling, but it silently
1248 // stops steering — worth saying out loud.
1249 for event in [HookEvent::MessageSubmit, HookEvent::ToolCallBefore] {
1250 let config = HooksConfig {
1251 enabled: true,
1252 hooks: vec![Hook::new(event, "true").with_name("gate").background()],
1253 ..HooksConfig::default()
1254 };
1255 let problems = config.validate();
1256 assert_eq!(problems.len(), 1, "{}", event.as_str());
1257 assert!(!problems[0].rejected);
1258 assert!(problems[0].detail.contains("observer-only"));
1259 assert!(event.honors_background());
1260 }
1261
1262 // A background observer hook is unremarkable.
1263 let observer = HooksConfig {
1264 enabled: true,
1265 hooks: vec![Hook::new(HookEvent::TurnEnd, "true").background()],
1266 ..HooksConfig::default()
1267 };
1268 assert!(observer.validate().is_empty());
1269 }
1270
1271 #[test]
1272 fn problem_summaries_carry_no_command_or_path() {
1273 let problem = HookConfigProblem {
1274 name: Some("gate".to_string()),
1275 event: Some(HookEvent::ToolCallBefore),
1276 detail: "example detail".to_string(),
1277 rejected: true,
1278 };
1279 let summary = problem.summary();
1280 assert!(summary.contains("rejected"));
1281 assert!(summary.contains("tool_call_before"));
1282 assert!(summary.contains("gate"));
1283
1284 let unnamed = HookConfigProblem {
1285 name: None,
1286 rejected: false,
1287 ..problem
1288 };
1289 assert!(unnamed.summary().contains("(unnamed)"));
1290 assert!(unnamed.summary().contains("warning"));
1291 }
1292
1293 #[test]
1294 fn project_hook_file_read_is_bounded_before_toml_parse() {
1295 let dir = tempfile::tempdir().expect("tempdir");
1296 let path = dir.path().join("hooks.toml");
1297 std::fs::write(&path, "x".repeat(super::PROJECT_HOOKS_FILE_MAX_BYTES + 1))
1298 .expect("write oversized hook config");
1299 let error = super::read_project_hooks_file(&path)
1300 .expect_err("oversized project hook config must be rejected");
1301 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
1302 assert!(error.to_string().contains("1 MiB"));
1303 }
1304 }
1305
1305 lines RUST