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