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